From 8224a8fddb6de883632ba016d5ff43cc74f63ee1 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Mon, 8 Jun 2026 04:25:58 -0500 Subject: [PATCH] chore(romhacks): catalog publisher tooling (analyze + render Obsidian notes) --- .../romhacks/orchestrator/analyze_catalog.py | 130 ++++++++++++++++++ .../orchestrator/render_catalog_notes.py | 118 ++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 stacks/romhacks/orchestrator/analyze_catalog.py create mode 100644 stacks/romhacks/orchestrator/render_catalog_notes.py diff --git a/stacks/romhacks/orchestrator/analyze_catalog.py b/stacks/romhacks/orchestrator/analyze_catalog.py new file mode 100644 index 0000000..87dcf50 --- /dev/null +++ b/stacks/romhacks/orchestrator/analyze_catalog.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Analysis pass for the Obsidian catalog publisher (read-only). + +For every routed library// game, derive its catalog fields: + - name, platform (UPPERCASE), kind, base, version, status, generation, + library_path, source link, #guides, #art. +Generation comes from which forum the thread lived in (exports//); +base/version/status are parsed from the staged description. + +Emits one JSON object per line (so the caller can dedupe against the existing +vault notes) plus a summary. Writes nothing outside stdout. +""" +import json +import os +import pathlib +import re + +LIBRARY = pathlib.Path(os.environ.get("LIBRARY_DIR", "/library")) +METADATA = pathlib.Path(os.environ.get("METADATA_DIR", "/metadata")) +EXPORTS = pathlib.Path(os.environ.get("EXPORTS_DIR", "/exports")) + +CHANNEL_GEN = { + "1474174360926290105": "Gen I", "1474174532196761802": "Gen II", + "1474174608587489416": "Gen III", "1474174672210755714": "Gen IV", + "1474174736438132756": "Gen V", "1474174789680632084": "Gen VI", + "1474174859671240898": "Gen VII", "1474174950138183880": "Gen VIII", + "1474175004248903935": "Gen IX", "1474175244209098986": "Joiplay/RPGXP", + "1475605095805747351": "Unique", +} +PLAT_UP = {"gba": "GBA", "gbc": "GBC", "gb": "GB", "nds": "NDS", + "3ds": "3DS", "switch": "Switch", "pc": "PC", "patch": "Patch", + "unknown": "—"} + + +def norm(name): + n = name.lower() + n = re.sub(r"pok[eé]mon", "", n) + n = re.sub(r"[^a-z0-9]+", "", n) + return n + + +def gen_map(): + m = {} + for chan_dir in (EXPORTS.iterdir() if EXPORTS.exists() else []): + if not chan_dir.is_dir(): + continue + gen = CHANNEL_GEN.get(chan_dir.name) + if not gen: + continue + for jf in chan_dir.glob("*.json"): + try: + d = json.loads(jf.read_text(encoding="utf-8")) + nm = d.get("channel", {}).get("name") + if nm: + m[norm(nm)] = gen + except Exception: + pass + return m + + +def parse_desc(desc): + base = ver = status = "—" + m = re.search(r"based on\s*:?\s*([A-Za-z0-9 .&'+-]{2,40})", desc, re.I) + if m: + base = m.group(1).strip().rstrip(".").strip() + m = re.search(r"version\s*:?\s*([^\n]{1,30})", desc, re.I) + if m: + ver = m.group(1).strip() + low = desc.lower() + if "completed" in low or re.search(r"\bcomplete\b", low): + status = "Complete" + elif "beta" in low: + status = "Beta" + elif "ongoing" in low or "in development" in low: + status = "Ongoing" + return base, ver, status + + +def main(): + gens = gen_map() + rows = [] + for folder in sorted(p for p in LIBRARY.iterdir() if p.is_dir()): + hj = folder / "handoff.json" + if not hj.exists(): + continue + h = json.loads(hj.read_text(encoding="utf-8")) + name = h.get("name", folder.name) + slug = h.get("slug", folder.name) + plat = PLAT_UP.get(h.get("platform", "unknown"), "—") + desc = "" + mp = METADATA / slug / "metadata.json" + if mp.exists(): + try: + desc = json.loads(mp.read_text(encoding="utf-8")).get("description", "") + except Exception: + pass + base, ver, status = parse_desc(desc) + gen = gens.get(norm(name), "—") + art = h.get("art", []) + guides = h.get("guides", []) + links = h.get("links", []) + artifact = h.get("artifact") + kind = h.get("kind") + lib_path = (f"/storage1/Emulation/roms/{h.get('platform')}/{artifact}" + if kind == "console" and artifact else + f"/storage1/labdata/romhacks/library/{slug}/") + rows.append({ + "name": name, "slug": slug, "norm": norm(name), "platform": plat, + "kind": kind, "base": base, "version": ver, "status": status, + "generation": gen, "library_path": lib_path, + "source": links[0] if links else None, "n_links": len(links), + "n_guides": len(guides), "n_art": len(art), "artifact": artifact, + }) + + for r in rows: + print(json.dumps(r, ensure_ascii=False)) + + # summary to stderr-ish (still stdout, prefixed) + from collections import Counter + pc = Counter(r["platform"] for r in rows) + kc = Counter(r["kind"] for r in rows) + print("@@@SUMMARY", json.dumps({ + "total": len(rows), "by_platform": dict(pc), "by_kind": dict(kc), + "with_base": sum(1 for r in rows if r["base"] != "—"), + "with_gen": sum(1 for r in rows if r["generation"] != "—"), + }, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/stacks/romhacks/orchestrator/render_catalog_notes.py b/stacks/romhacks/orchestrator/render_catalog_notes.py new file mode 100644 index 0000000..7094feb --- /dev/null +++ b/stacks/romhacks/orchestrator/render_catalog_notes.py @@ -0,0 +1,118 @@ +import json, re, pathlib, datetime + +rows = json.load(open("/tmp/catalog_new.json", encoding="utf-8")) +META = pathlib.Path("/storage1/labdata/romhacks/metadata") +OUT = pathlib.Path("/tmp/catalog_notes"); OUT.mkdir(exist_ok=True) +TODAY = "2026-06-08" + +BASES = ["HeartGold","SoulSilver","FireRed","LeafGreen","Omega Ruby","Alpha Sapphire", + "Black 2","White 2","Emerald","Crystal","Platinum","Diamond","Pearl", + "Ruby","Sapphire","Gold","Silver","Black","White","Red","Blue","Yellow", + "Sun","Moon","Sword","Shield","Scarlet","Violet"] +PLAT_TAG = {"GBA":"gba","GBC":"gbc","GB":"gb","NDS":"nds","3DS":"3ds","Switch":"switch","PC":"pc","Patch":"patch","—":"unknown"} + +def q(v): + return '"' + str(v).replace("\\", "\\\\").replace('"', '\\"') + '"' + +def clean_version(v): + v = re.sub(r"^[\s*:_-]+", "", str(v or "")).strip() + return v or "—" + +def strip_prefix(name): + return re.sub(r"^\s*pok[eé]mon\s+", "", name, flags=re.I).strip() + +def safe_filename(name): + return re.sub(r'[\\/:*?"<>|]+', " ", strip_prefix(name)).strip() or "Unknown" + +def detect_base(desc, parsed): + # Only trust an explicit "based on : " segment — scanning the whole + # description gives false positives (e.g. "Black Market", "Red Gyarados"). + m = re.search(r"based on\s*:?\s*(.{0,40})", desc, re.I) + if not m: + return None + seg = m.group(1) + for spaced, canon in [(r"fire\s*red","firered"),(r"leaf\s*green","leafgreen"), + (r"heart\s*gold","heartgold"),(r"soul\s*silver","soulsilver"), + (r"omega\s*ruby","omegaruby"),(r"alpha\s*sapphire","alphasapphire")]: + seg = re.sub(spaced, canon, seg, flags=re.I) + for b in BASES: + if re.search(r"\b" + re.escape(b) + r"\b", seg, re.I): + return b + return None + +def clean_desc(desc): + # drop the markdown download links already captured separately; keep prose + lines = [] + for ln in desc.splitlines(): + if re.search(r"\bdownload\b", ln, re.I) and "http" in ln.lower(): + continue + lines.append(ln) + return "\n".join(lines).strip() + +def render(r): + name = r["name"]; title = "Pokémon " + strip_prefix(name) + desc = "" + mp = META / r["slug"] / "metadata.json" + if mp.exists(): + try: desc = json.loads(mp.read_text(encoding="utf-8")).get("description","") + except Exception: pass + base = detect_base(desc, r["base"]) or "—" + ptag = PLAT_TAG.get(r["platform"],"unknown") + tags = ["hack", f"platform/{ptag}", f"status/{r['status'].lower()}" if r["status"]!="—" else None, + f"base/{base.lower().replace(' ','')}" if base!="—" else None, + f"kind/{r['kind']}", "source/discord"] + tags = [t for t in tags if t] + fm = [ + "---", + f"title: {q(title)}", + f"platform: {q(r['platform'])}", + f"base: {q(base)}", + f"version: {q(clean_version(r['version']))}", + f"status: {q(r['status'])}", + "type: []", + f"generation: {q(r['generation'])}", + f"library_path: {q(r['library_path'])}", + f"source: {q(r['source'] or '—')}", + f"added: {TODAY}", + "play_status: Unplayed", + f"tags: [{', '.join(tags)}]", + "---", + "", + f"# {title}", + "", + ] + kindline = {"console":"Console ROM hack — in RomM.","fangame":"RPG-Maker / Joiplay fan-game (PC) — not in RomM.", + "patch":"Patch file — apply to a base ROM.","unknown":"Imported."}.get(r["kind"],"Imported.") + fm.append(f"> [!info] Auto-imported from the Discord catalog ({r['generation']}) on {TODAY}. {kindline}") + fm.append("") + cd = clean_desc(desc) + if cd: + fm += ["## Summary", "", cd, ""] + # download + links = [] + if mp.exists(): + try: links = json.loads(mp.read_text(encoding="utf-8")).get("links",[]) + except Exception: pass + if links: + fm += ["## Download", ""] + [f"- {l}" for l in links] + [""] + fm += ["## Files", "", + f"- **Artifact:** `{r['artifact'] or 'n/a'}`", + f"- **Library:** `/storage1/labdata/romhacks/library/{r['slug']}/`" + + (f" — {r['n_guides']} guide/extra file(s)" if r['n_guides'] else "")] + fm.append("") + return safe_filename(name), "\n".join(fm) + +made = [] +for r in rows: + fn, body = render(r) + (OUT / (fn + ".md")).write_text(body, encoding="utf-8") + made.append((fn, r)) +print("rendered", len(made), "notes to /tmp/catalog_notes/") + +# show 2 samples: a console GBA hack and a fangame +for want in ("console","fangame"): + for fn, r in made: + if r["kind"] == want: + print("\n========== SAMPLE (%s): %s.md ==========" % (want, fn)) + print((OUT / (fn + ".md")).read_text(encoding="utf-8")[:1400]) + break