from __future__ import annotations import argparse import json import logging import re import unicodedata from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Optional REGISTRY_FILENAME = "registry.json" IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".avif") logging.basicConfig(level=logging.INFO, format="%(message)s") logger = logging.getLogger("pack-maintenance") @dataclass(frozen=True) class PackPaths: pack_dir: Path manifest_path: Path characters_dir: Path # ───────────────────────────────────────────────────────────── # Generic helpers # ───────────────────────────────────────────────────────────── def load_json(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) def save_json(path: Path, data: dict, dry_run: bool = False) -> None: if dry_run: logger.info(f"[DRY RUN] Would write {path.name}") return temp_path = path.with_suffix(path.suffix + ".tmp") temp_path.write_text( json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) temp_path.replace(path) def format_bytes(size: int) -> str: return f"{size / 1024:.1f} KB" def get_root() -> Path: return Path(__file__).parent.resolve() def get_registry_path(root: Path) -> Path: return root / REGISTRY_FILENAME def normalize(text: str) -> str: text = Path(text).stem.lower().strip() text = unicodedata.normalize("NFKD", text) text = text.encode("ascii", "ignore").decode("ascii") return re.sub(r"[^a-z0-9]", "", text) # ───────────────────────────────────────────────────────────── # Pack / manifest helpers # ───────────────────────────────────────────────────────────── def get_pack_paths(root: Path, pack: dict) -> PackPaths: pack_dir = root / pack["id"] return PackPaths( pack_dir=pack_dir, manifest_path=pack_dir / "manifest.json", characters_dir=pack_dir / "characters", ) def validate_pack_paths(manifest_path: Path, characters_dir: Path) -> bool: if not manifest_path.exists(): logger.error("❌ manifest.json not found") return False if not characters_dir.exists(): logger.error("❌ characters folder not found") return False return True def get_image_files(characters_dir: Path) -> list[Path]: return [ file for file in characters_dir.iterdir() if file.is_file() and file.suffix.lower() in IMAGE_EXTENSIONS ] def find_character_image(characters_dir: Path, slug: str) -> Optional[Path]: for ext in IMAGE_EXTENSIONS: candidate = characters_dir / f"{slug}{ext}" if candidate.exists(): return candidate return None def validate_manifest_characters(characters: list[dict]) -> None: seen_slugs: set[str] = set() for character in characters: slug = character.get("slug") name = character.get("name") if not slug: logger.warning("⚠ Character without slug found") continue if not name: logger.warning(f"⚠ Character without name: {slug}") if slug in seen_slugs: logger.warning(f"⚠ Duplicate slug detected: {slug}") seen_slugs.add(slug) def build_character_lookup(characters: list[dict]) -> dict[str, str]: lookup: dict[str, str] = {} for character in characters: name = character.get("name") slug = character.get("slug") if not name or not slug: continue lookup[normalize(name)] = slug return lookup # ───────────────────────────────────────────────────────────── # Pack selection # ───────────────────────────────────────────────────────────── def select_pack(packs: list[dict]) -> Optional[dict]: print("\nAvailable packs:\n") for index, pack in enumerate(packs, start=1): print(f"{index}. {pack.get('name', 'Unnamed')} ({pack.get('id', 'no-id')})") raw = input("\nSelect pack number: ").strip() try: selected_index = int(raw) - 1 except ValueError: logger.error("❌ Invalid number") return None if selected_index < 0 or selected_index >= len(packs): logger.error("❌ Invalid selection") return None return packs[selected_index] # ───────────────────────────────────────────────────────────── # Manifest writer # ───────────────────────────────────────────────────────────── def ordered_character_payload(character: dict) -> dict: ordered: dict = {} for key in ("name", "slug", "dlc", "sizeBytes"): if key in character: ordered[key] = character[key] for key, value in character.items(): if key not in ordered: ordered[key] = value return ordered def write_manifest(manifest_path: Path, manifest: dict, dry_run: bool = False) -> None: manifest_copy = dict(manifest) characters = manifest_copy.pop("characters", []) base_json = json.dumps( manifest_copy, indent=2, ensure_ascii=False, ) if base_json.endswith("\n}"): base_json = base_json[:-2] else: base_json = base_json[:-1] if characters: character_lines = [ " " + json.dumps( ordered_character_payload(character), ensure_ascii=False, ) for character in characters ] characters_json = ( ' "characters": [\n' + ",\n".join(character_lines) + "\n ]\n}" ) else: characters_json = ' "characters": []\n}' final_content = base_json + ",\n" + characters_json + "\n" if dry_run: logger.info(f"[DRY RUN] Would update manifest: {manifest_path.name}") return temp_path = manifest_path.with_suffix(".tmp") temp_path.write_text(final_content, encoding="utf-8") temp_path.replace(manifest_path) # ───────────────────────────────────────────────────────────── # Action 1: rename images to slug # ───────────────────────────────────────────────────────────── def rename_images(pack_dir: Path, dry_run: bool = False) -> None: manifest_path = pack_dir / "manifest.json" characters_dir = pack_dir / "characters" if not validate_pack_paths(manifest_path, characters_dir): return manifest = load_json(manifest_path) characters = manifest.get("characters", []) validate_manifest_characters(characters) lookup = build_character_lookup(characters) image_files = get_image_files(characters_dir) if not image_files: logger.error("❌ No images found") return print(f'\nScanning "{manifest.get("name", pack_dir.name)}"...\n') renamed_count = 0 for image_file in image_files: normalized_filename = normalize(image_file.name) matched_slug: Optional[str] = None if normalized_filename in lookup: matched_slug = lookup[normalized_filename] else: matches = [ slug for normalized_name, slug in lookup.items() if normalized_name in normalized_filename or normalized_filename in normalized_name ] if len(matches) == 1: matched_slug = matches[0] elif len(matches) > 1: logger.warning(f"⚠ Multiple matches for {image_file.name}") continue if matched_slug is None: logger.warning(f"⚠ No character match for {image_file.name}") continue new_filename = matched_slug + image_file.suffix.lower() new_path = characters_dir / new_filename if image_file.name.lower() == new_filename.lower(): if image_file.name != new_filename: if dry_run: logger.info(f"[DRY RUN] {image_file.name} -> {new_filename}") else: image_file.rename(new_path) logger.info(f"✓ {image_file.name} -> {new_filename}") renamed_count += 1 else: logger.info(f"✓ {image_file.name}") continue if new_path.exists(): logger.warning(f"⚠ Target already exists: {new_filename}") continue if dry_run: logger.info(f"[DRY RUN] {image_file.name} -> {new_filename}") else: image_file.rename(new_path) renamed_count += 1 logger.info(f"✓ {image_file.name} -> {new_filename}") print("\n✅ Done!\n") print(f"Renamed: {renamed_count}") # ───────────────────────────────────────────────────────────── # Action 2: update sizeBytes and registry totals # ───────────────────────────────────────────────────────────── def update_pack_sizes(root: Path, registry: dict, selected_pack: dict, dry_run: bool = False) -> None: paths = get_pack_paths(root, selected_pack) if not validate_pack_paths(paths.manifest_path, paths.characters_dir): return manifest = load_json(paths.manifest_path) characters = manifest.get("characters", []) print(f'\nUpdating "{manifest.get("name", paths.pack_dir.name)}"...\n') total_size_bytes = 0 valid_character_count = 0 for character in characters: slug = character.get("slug") if not slug: character["sizeBytes"] = 0 logger.warning("⚠ Character without slug found") continue image_path = find_character_image(paths.characters_dir, slug) if image_path is None: character["sizeBytes"] = 0 logger.warning(f"⚠ Missing image for {slug}") continue size = image_path.stat().st_size character["sizeBytes"] = size total_size_bytes += size valid_character_count += 1 logger.info(f"✓ {slug} -> {format_bytes(size)}") registry_pack = next( (pack for pack in registry.get("packs", []) if pack.get("id") == manifest.get("id")), None, ) if registry_pack is None: logger.error("❌ Pack not found in registry") return registry_pack["totalSizeBytes"] = total_size_bytes registry_pack["characterCount"] = valid_character_count registry["updatedAt"] = datetime.now().strftime("%Y-%m-%d") write_manifest(paths.manifest_path, manifest, dry_run=dry_run) save_json(root / REGISTRY_FILENAME, registry, dry_run=dry_run) print("\n✅ Done!\n") print(f"Characters: {valid_character_count}") print(f"Total size: {format_bytes(total_size_bytes)} ({total_size_bytes} bytes)") # ───────────────────────────────────────────────────────────── # Orchestration # ───────────────────────────────────────────────────────────── def load_registry(root: Path) -> Optional[dict]: registry_path = get_registry_path(root) if not registry_path.exists(): logger.error("❌ registry.json not found") return None return load_json(registry_path) def get_selected_pack_from_registry(registry: dict) -> Optional[dict]: packs = registry.get("packs", []) if not packs: logger.error("❌ No packs found in registry") return None return select_pack(packs) def process_pack(action: str, root: Path, registry: dict, selected_pack: dict, dry_run: bool) -> None: paths = get_pack_paths(root, selected_pack) if action == "rename": rename_images(paths.pack_dir, dry_run=dry_run) elif action == "sizes": update_pack_sizes(root, registry, selected_pack, dry_run=dry_run) elif action == "both": rename_images(paths.pack_dir, dry_run=dry_run) update_pack_sizes(root, registry, selected_pack, dry_run=dry_run) else: raise ValueError(f"Unknown action: {action}") def run(action: str, dry_run: bool = False, process_all: bool = False) -> None: root = get_root() registry = load_registry(root) if registry is None: return packs = registry.get("packs", []) if process_all: print(f"\nProcessing {len(packs)} pack(s)...") for pack in packs: print("\n────────────────────────────") print(f"{pack.get('name', 'Unnamed')} ({pack.get('id', 'no-id')})") print("────────────────────────────") process_pack(action, root, registry, pack, dry_run) return selected_pack = get_selected_pack_from_registry(registry) if selected_pack is None: return process_pack(action, root, registry, selected_pack, dry_run) # ───────────────────────────────────────────────────────────── # CLI # ───────────────────────────────────────────────────────────── def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Pack maintenance tool") parser.add_argument( "action", choices=("rename", "sizes", "both"), nargs="?", default="both", help="What to run", ) parser.add_argument( "--dry-run", action="store_true", help="Preview changes without writing files", ) parser.add_argument( "--all", action="store_true", help="Process all packs", ) return parser.parse_args() def main() -> None: args = parse_args() run(action=args.action, dry_run=args.dry_run, process_all=args.all) if __name__ == "__main__": main()