Files
pokemon/scripts/resync-vault-library-paths.py
ginnoirandClaude Opus 4.8 809600a04d feat: ROM-hack dedup, conflict resolution, and vault library_path resync
Catalog cleanup pass across gb/gbc/gba/nds/3ds:
- Standardize nonstandard hack filenames to `Pokemon - <Name> [Hack].<ext>`;
  remove verified byte-identical duplicates (conform-rom-names.sh).
- Resolve 9 version-conflicts via side-by-side emulator comparison
  (resolve-version-conflicts.sh): promote newer builds, keep genuine
  distinct hacks, fix a mislabeled Dark Rising slot (was the Digimon
  crossover "Worlds Collide"), and rebuild Mega Power 5.77 + HeartGold
  Generations v2.0 fresh from their patches.
- Resync 286 vault notes' `library_path` to the real on-disk [Hack] files
  and fix 15 stale platform fields (resync-vault-library-paths.py).
- Regenerate MOC tables + wiki/catalog.json (396 notes).

Also includes pending Playnite PC-hack setup/repair scripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:29:05 -05:00

132 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Resync each vault hack note's `library_path` to the actual on-disk ROM file.
The [Hack] filename standardization renamed ROMs on disk but left the vault
notes' `library_path` frontmatter pointing at old names/paths. This matches each
note to its real file (by normalized title, falling back to the old path's
basename) and rewrites `library_path` + any exact body copy of the old value.
Read-only by default; pass --apply to write. ROM listings are read from
/tmp/romsync/<plat>.txt (one filename per line), pulled from valhalla.
"""
import os, re, sys, glob
VAULT = r"C:\Users\MattC\Documents\Obsidian Vault"
HACKS = os.path.join(VAULT, "Pokémon ROM Hacks", "Hacks")
LISTS = os.path.join(os.path.dirname(__file__), "..", ".scrape", "romsync")
PLAT = {"GBA": "gba", "GBC": "gbc", "GB": "gb", "NDS": "nds", "3DS": "3ds", "PC": "windows"}
APPLY = "--apply" in sys.argv
def norm(s):
s = os.path.splitext(s)[0].lower()
s = re.sub(r"\[[^\]]*\]", "", s) # drop [Hack], [2.0.5], [Full], ...
s = re.sub(r"\([^)]*\)", "", s) # drop (Gold inc), (Spanish), ...
s = re.sub(r"pok[eé]?mon", "", s) # drop the franchise prefix/word
s = re.sub(r"[^a-z0-9]", "", s) # keep bare digits (Dark Rising 2)
return s
# ---- build per-platform file index: normkey -> [filenames] ----
idx = {}
for path in glob.glob(os.path.join(LISTS, "*.txt")):
plat = os.path.splitext(os.path.basename(path))[0]
idx[plat] = {}
for line in open(path, encoding="utf-8"):
fn = line.strip()
if not fn or not re.search(r"\.(gba|gbc|gb|nds|3ds|cci|cxi|zip)$", fn, re.I):
continue
idx[plat].setdefault(norm(fn), []).append(fn)
def fm_get(text, key):
m = re.search(rf"(?m)^{key}:\s*(.*)$", text)
return m.group(1).strip().strip('"').strip("'") if m else None
FUZZY = "--fuzzy" in sys.argv # also write the proposed fuzzy/cross-platform matches
def find_exact_all(key):
"""Exact normalized-key match across ALL platforms (catches wrong platform field)."""
hits = []
for p, files in idx.items():
for fn in files.get(key, []):
hits.append((p, fn))
return hits
# Notes whose only fuzzy candidate is a different/ambiguous hack — held for manual
# review, never auto-applied even with --fuzzy.
FUZZY_HOLD = {
"Bloody Fusion.md", "Too Many Types.md", "HeartGold Generation.md",
"FireRed Reignited LeafGreen Regrown.md", "Kanto Complete.md",
"Mystery Dungeon - Wigglytuff's Bizarre Adventure.md",
}
def find_fuzzy(nk):
"""One key is a substring of the other; require a [Hack] file; must be unique.
Skip very short keys (e.g. a hack literally named 'Z') to avoid spurious hits."""
if len(nk) < 4: return []
hits = []
for p, files in idx.items():
for k, fns in files.items():
if len(k) < 4 or not nk: continue
if nk in k or k in nk:
for fn in fns:
if "[hack]" in fn.lower():
hits.append((p, fn))
return sorted(set(hits))
changed = unchanged = unmatched = ambiguous = 0
report = {"CHANGED": [], "UNMATCHED": [], "AMBIGUOUS": [], "FUZZY": []}
for md in sorted(glob.glob(os.path.join(HACKS, "*.md"))):
text = open(md, encoding="utf-8").read()
title = fm_get(text, "title") or os.path.splitext(os.path.basename(md))[0]
platform = (fm_get(text, "platform") or "").upper()
cur = fm_get(text, "library_path") or ""
plat = PLAT.get(platform)
note = os.path.basename(md)
if not plat or plat not in idx:
continue
def write_path(newpath):
if not APPLY: return
new = re.sub(r"(?m)^(library_path:\s*).*$", lambda m: m.group(1) + newpath, text, count=1)
# Update body copies ONLY when the old value was an emulation-rom path;
# never touch /storage1/labdata/... served-files links.
if cur and cur.startswith(("roms/", "/storage1/Emulation/")):
new = new.replace(cur, newpath)
open(md, "w", encoding="utf-8", newline="").write(new)
nk = norm(title)
# 1) exact match within the declared platform (auto-apply)
cand = idx[plat].get(nk) or (idx[plat].get(norm(os.path.basename(cur))) if cur else None)
if cand and len(cand) == 1:
newpath = f"roms/{plat}/{cand[0]}"
if cur == newpath: unchanged += 1; continue
changed += 1; report["CHANGED"].append((note, cur, newpath)); write_path(newpath); continue
if cand and len(cand) > 1:
ambiguous += 1; report["AMBIGUOUS"].append((note, cand)); continue
# 2) cross-platform exact, then fuzzy substring -> PROPOSAL (apply only with --fuzzy)
prop = find_exact_all(nk) or find_fuzzy(nk)
if len(prop) == 1:
p2, fn = prop[0]; newpath = f"roms/{p2}/{fn}"
if cur == newpath: unchanged += 1; continue
report["FUZZY"].append((note, cur, newpath))
if FUZZY and note not in FUZZY_HOLD: changed += 1; write_path(newpath)
continue
unmatched += 1; report["UNMATCHED"].append((note, platform, cur))
summary = (f"{'APPLIED' if APPLY else 'DRY-RUN'}{' +FUZZY' if FUZZY else ''}: {changed} changed, "
f"{unchanged} already correct, {len(report['FUZZY'])} fuzzy proposals, "
f"{unmatched} unmatched (no on-disk file — likely Wanted/gated), {ambiguous} ambiguous")
lines = [summary, ""]
for tag in ("AMBIGUOUS", "FUZZY", "CHANGED", "UNMATCHED"):
rows = report[tag]
if not rows: continue
lines.append(f"--- {tag} ({len(rows)}) ---")
for r in rows:
if tag in ("CHANGED", "FUZZY"): lines.append(f" {r[0]}\n {r[1] or '—'} -> {r[2]}")
elif tag == "AMBIGUOUS": lines.append(f" {r[0]} -> {r[1]}")
else: lines.append(f" {r[0]} [{r[1]}] (cur: {r[2] or '—'})")
lines.append("")
rep = os.path.join(os.path.dirname(__file__), "..", ".scrape", "resync-report.txt")
open(rep, "w", encoding="utf-8").write("\n".join(lines))
print(summary)
print("full report ->", os.path.normpath(rep))