diff --git a/.gitignore b/.gitignore index 901885f..64a247f 100644 --- a/.gitignore +++ b/.gitignore @@ -137,4 +137,3 @@ dist .pnp.* # scripts -update.py \ No newline at end of file diff --git a/update.py b/update.py new file mode 100644 index 0000000..e4e8500 --- /dev/null +++ b/update.py @@ -0,0 +1,453 @@ +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() diff --git a/update_manifest_registry.py b/update_manifest_registry.py deleted file mode 100644 index d70e2aa..0000000 --- a/update_manifest_registry.py +++ /dev/null @@ -1,193 +0,0 @@ -# update_pack_sizes.py -# -# Updates the selected pack by: -# - recalculating each character's sizeBytes from its image file -# - updating registry.json totalSizeBytes -# - updating registry.json characterCount -# - updating registry.json updatedAt -# -# Notes: -# - Missing images are skipped and assigned sizeBytes = 0 -# - Missing images are NOT counted in characterCount -# - Character objects are written on a single line for readability - -from __future__ import annotations - -import json -from datetime import datetime -from pathlib import Path -from typing import Optional - -REGISTRY_FILENAME = "registry.json" -IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".avif") - - -def format_bytes(size: int) -> str: - return f"{size / 1024:.1f} KB" - - -def load_json(path: Path) -> dict: - return json.loads(path.read_text(encoding="utf-8")) - - -def save_json(path: Path, data: dict) -> None: - path.write_text( - json.dumps(data, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - - -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 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) -> 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] - elif base_json.endswith("}"): - base_json = base_json[:-1] - else: - raise ValueError("Unexpected JSON format while writing manifest.") - - 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}' - - manifest_path.write_text( - base_json + ",\n" + characters_json + "\n", - encoding="utf-8", - ) - - -def select_pack(packs: list[dict]) -> Optional[dict]: - print("\nAvailable packs:\n") - for index, pack in enumerate(packs, start=1): - print(f"{index}. {pack['name']} ({pack['id']})") - - raw = input("\nSelect pack number: ").strip() - try: - selected_index = int(raw) - 1 - except ValueError: - print("\n❌ Invalid number") - return None - - if selected_index < 0 or selected_index >= len(packs): - print("\n❌ Invalid selection") - return None - - return packs[selected_index] - - -def update_pack(root: Path, registry: dict, selected_pack: dict) -> None: - pack_dir = root / selected_pack["id"] - manifest_path = pack_dir / "manifest.json" - characters_dir = pack_dir / "characters" - - if not manifest_path.exists(): - print("\n❌ manifest.json not found") - return - - if not characters_dir.exists(): - print("\n❌ characters folder not found") - return - - manifest = load_json(manifest_path) - characters = manifest.get("characters", []) - - print(f'\nUpdating "{manifest["name"]}"...\n') - - total_size_bytes = 0 - valid_character_count = 0 - - for character in characters: - slug = character["slug"] - image_path = find_character_image(characters_dir, slug) - - if image_path is None: - character["sizeBytes"] = 0 - print(f"⚠ Missing image for {slug}") - continue - - size = image_path.stat().st_size - character["sizeBytes"] = size - total_size_bytes += size - valid_character_count += 1 - - print(f"✓ {slug} -> {format_bytes(size)}") - - registry_pack = next( - (pack for pack in registry.get("packs", []) if pack["id"] == manifest["id"]), - None, - ) - - if registry_pack is None: - print("\n❌ 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(manifest_path, manifest) - save_json(root / REGISTRY_FILENAME, registry) - - print("\n✅ Done!\n") - print(f"Characters: {valid_character_count}") - print(f"Total size: {format_bytes(total_size_bytes)} ({total_size_bytes} bytes)") - - -def main() -> None: - root = Path(__file__).parent.resolve() - registry_path = root / REGISTRY_FILENAME - - if not registry_path.exists(): - print("❌ registry.json not found") - return - - registry = load_json(registry_path) - packs = registry.get("packs", []) - - if not packs: - print("❌ No packs found in registry") - return - - selected_pack = select_pack(packs) - if selected_pack is None: - return - - update_pack(root, registry, selected_pack) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/update_slug.py b/update_slug.py deleted file mode 100644 index e913ac0..0000000 --- a/update_slug.py +++ /dev/null @@ -1,263 +0,0 @@ -# rename_images_to_slug.py -# -# Renames character images inside a selected pack's /characters folder -# to their manifest slug. -# -# Example: -# "Ryu Render.png" -> "ryu.png" -# -# Matching rules: -# - compares normalized filenames against character names -# - ignores spaces, punctuation, apostrophes and case -# - preserves original extension -# -# Safe behavior: -# - skips already-correct files -# - warns about unmatched files -# - warns about duplicate matches -# - never overwrites existing files - -from __future__ import annotations - -import json -import re -from pathlib import Path -from typing import Optional - -REGISTRY_FILENAME = "registry.json" - -IMAGE_EXTENSIONS = ( - ".png", - ".jpg", - ".jpeg", - ".webp", - ".avif", -) - - -def load_json(path: Path) -> dict: - return json.loads(path.read_text(encoding="utf-8")) - - -def normalize(text: str) -> str: - """ - Normalize text for fuzzy filename matching. - - Example: - "Chun-Li" -> "chunli" - "Ryu Render" -> "ryurender" - """ - - text = text.lower() - - # Remove extension if present - text = Path(text).stem - - # Remove non-alphanumeric chars - text = re.sub(r"[^a-z0-9]", "", text) - - return text - - -def select_pack(packs: list[dict]) -> Optional[dict]: - print("\nAvailable packs:\n") - - for index, pack in enumerate(packs, start=1): - print(f"{index}. {pack['name']} ({pack['id']})") - - raw = input("\nSelect pack number: ").strip() - - try: - selected_index = int(raw) - 1 - except ValueError: - print("\n❌ Invalid number") - return None - - if selected_index < 0 or selected_index >= len(packs): - print("\n❌ Invalid selection") - return None - - return packs[selected_index] - - -def build_character_lookup(characters: list[dict]) -> dict[str, str]: - """ - Build normalized name -> slug mapping. - - Example: - "chunli" -> "chun-li" - """ - - lookup = {} - - for character in characters: - name = character["name"] - slug = character["slug"] - - lookup[normalize(name)] = slug - - return lookup - - -def rename_images(pack_dir: Path) -> None: - manifest_path = pack_dir / "manifest.json" - characters_dir = pack_dir / "characters" - - if not manifest_path.exists(): - print("\n❌ manifest.json not found") - return - - if not characters_dir.exists(): - print("\n❌ characters folder not found") - return - - manifest = load_json(manifest_path) - - lookup = build_character_lookup( - manifest.get("characters", []) - ) - - image_files = [ - file - for file in characters_dir.iterdir() - if file.is_file() - and file.suffix.lower() in IMAGE_EXTENSIONS - ] - - if not image_files: - print("\n❌ No images found") - return - - print(f'\nScanning "{manifest["name"]}"...\n') - - renamed_count = 0 - - for image_file in image_files: - normalized_filename = normalize( - image_file.name - ) - - matched_slug = None - - # Exact normalized match - if normalized_filename in lookup: - matched_slug = lookup[ - normalized_filename - ] - - else: - # Partial fuzzy fallback - 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: - print( - f"⚠ Multiple matches for " - f"{image_file.name}" - ) - continue - - if matched_slug is None: - print( - f"⚠ No character match for " - f"{image_file.name}" - ) - continue - - new_filename = ( - matched_slug - + image_file.suffix.lower() - ) - - new_path = ( - characters_dir - / new_filename - ) - - # Already correct - if image_file.name.lower() == new_filename.lower(): - if image_file.name != new_filename: - image_file.rename(new_path) - print( - f"✓ {image_file.name} " - f"-> {new_filename}" - ) - renamed_count += 1 - else: - print(f"✓ {image_file.name}") - - continue - - # Prevent overwrite - if new_path.exists(): - print( - f"⚠ Target already exists: " - f"{new_filename}" - ) - continue - - image_file.rename(new_path) - - renamed_count += 1 - - print( - f"✓ {image_file.name} " - f"-> {new_filename}" - ) - - print("\n✅ Done!\n") - print(f"Renamed: {renamed_count}") - - -def main() -> None: - root = Path(__file__).parent.resolve() - - registry_path = ( - root / REGISTRY_FILENAME - ) - - if not registry_path.exists(): - print( - "❌ registry.json not found" - ) - return - - registry = load_json( - registry_path - ) - - packs = registry.get( - "packs", - [], - ) - - if not packs: - print( - "❌ No packs found" - ) - return - - selected_pack = select_pack( - packs - ) - - if selected_pack is None: - return - - pack_dir = ( - root - / selected_pack["id"] - ) - - rename_images(pack_dir) - - -if __name__ == "__main__": - main() \ No newline at end of file