feat: add pack maintenance tool for renaming images and updating registry
This commit is contained in:
@@ -137,4 +137,3 @@ dist
|
|||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
# scripts
|
# scripts
|
||||||
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()
|
||||||
@@ -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()
|
|
||||||
-263
@@ -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()
|
|
||||||
Reference in New Issue
Block a user