feat: add various system and theme icons, configuration files, and support assets

This commit is contained in:
jschantli committed 2026-10-03 08:14:42 -07:00
1 parent 848dac4264
commit 54baea79f4
15893 files changed
+781426 -2

No files matched your search

@@ -0,0 +1,163 @@
"""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)