"""The mod library: import, scan, rename, delete. Never touches the game directory.""" from __future__ import annotations import os import re import shutil import time import uuid from dataclasses import asdict, dataclass, field from pathlib import Path from . import archive, layout, modinfo from .paths import Paths from .util import atomic_write_json, is_within, read_json, slugify class LibraryError(Exception): pass @dataclass class Mod: id: str name: str version: str = "" author: str = "" description: str = "" category: str = "" screenshot: str = "" # relative to files/ imported_at: float = 0.0 source: str = "" # original archive name dll_choice: str = "" # "", "plugin", "root", "skip" nexus_mod_id: str = "" nexus_file_id: str = "" nexus_url: str = "" root: Path = field(default=Path(), compare=False, repr=False) # mods/ @property def files_root(self) -> Path: return self.root / "files" @property def screenshot_path(self) -> Path | None: if not self.screenshot: return None p = self.files_root / self.screenshot return p if p.is_file() else None def classification(self) -> layout.Classification: return layout.classify(self.files_root, self.dll_choice or None) def to_meta(self) -> dict: d = asdict(self) d.pop("root") return d class Library: def __init__(self, paths: Paths): self.paths = paths self.mods: dict[str, Mod] = {} # -- loading ---------------------------------------------------------- def scan(self) -> None: self.mods.clear() base = self.paths.mods_dir if not base.is_dir(): return for d in sorted(base.iterdir()): meta = read_json(d / "meta.json", None) if d.is_dir() and not d.name.startswith(".") else None if not isinstance(meta, dict) or not (d / "files").is_dir(): continue known = Mod.__dataclass_fields__.keys() - {"root"} mod = Mod(**{k: v for k, v in meta.items() if k in known}) mod.id = d.name mod.root = d self.mods[mod.id] = mod def get(self, mod_id: str) -> Mod | None: return self.mods.get(mod_id) def sorted_mods(self) -> list[Mod]: return sorted(self.mods.values(), key=lambda m: m.name.lower()) # -- import ----------------------------------------------------------- def _unique_id(self, name: str) -> str: base = slugify(name) cand, n = base, 2 while (self.paths.mods_dir / cand).exists(): cand = f"{base}-{n}" n += 1 return cand def import_source(self, source: Path, nexus: dict | None = None) -> Mod: """Import an archive file or an extracted folder. `nexus` may prefill metadata.""" source = Path(source) self.paths.mods_dir.mkdir(parents=True, exist_ok=True) tmp = self.paths.mods_dir / f".incoming-{uuid.uuid4().hex[:8]}" try: tmp.mkdir() try: if source.is_dir(): archive.copy_tree(source, tmp) elif source.suffix.lower() in (".pak", ".dll", ".lua"): shutil.copy2(source, tmp / source.name) else: archive.extract(source, tmp) except archive.ArchiveError as e: raise LibraryError(str(e)) from e cls = layout.classify(tmp) if cls.empty: raise LibraryError("Nothing recognisable to install (no natives/, reframework/, .pak or .lua files).") info, info_dir = modinfo.read_modinfo(tmp) nexus = nexus or {} stem = re.sub(r"[-_ ]?\d+-\d+(-\d+)*(-\d+)?$", "", source.stem) if not source.is_dir() else source.name name = info.name or nexus.get("name") or stem or "Unnamed mod" mod_id = self._unique_id(name) shot = modinfo.find_screenshot(info_dir or tmp, info.screenshot) shot_rel = os.path.relpath(shot, tmp) if shot else "" mod = Mod( id=mod_id, name=name, version=info.version or str(nexus.get("version") or ""), author=info.author or str(nexus.get("author") or ""), description=info.description or str(nexus.get("summary") or ""), category=info.category, screenshot=shot_rel, imported_at=time.time(), source=source.name, nexus_mod_id=info.nexus_mod_id or str(nexus.get("mod_id") or ""), nexus_file_id=str(nexus.get("file_id") or ""), nexus_url=str(nexus.get("url") or ""), ) dest = self.paths.mods_dir / mod_id dest.mkdir() os.rename(tmp, dest / "files") mod.root = dest self.save(mod) self.mods[mod_id] = mod return mod finally: shutil.rmtree(tmp, ignore_errors=True) def save(self, mod: Mod) -> None: atomic_write_json(mod.root / "meta.json", mod.to_meta()) # -- edits ------------------------------------------------------------ def update(self, mod_id: str, **fields) -> Mod: mod = self.mods[mod_id] for k, v in fields.items(): if k in ("name", "category", "version", "author", "description", "dll_choice", "screenshot"): setattr(mod, k, v) self.save(mod) return mod def delete(self, mod_id: str) -> None: mod = self.mods.get(mod_id) if mod is None: return if not is_within(mod.root, self.paths.mods_dir) or mod.root.resolve() == self.paths.mods_dir.resolve(): raise LibraryError("refusing to delete outside the library") shutil.rmtree(mod.root) del self.mods[mod_id] def categories(self) -> list[str]: return sorted({m.category for m in self.mods.values() if m.category}, key=str.lower)