164 lines
5.9 KiB
Python
164 lines
5.9 KiB
Python
"""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/<id>
|
|
|
|
@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)
|