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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ae56d101b7
commit
809600a04d
@@ -0,0 +1,131 @@
|
||||
#!/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))
|
||||
Reference in New Issue
Block a user