chore(romhacks): catalog publisher tooling (analyze + render Obsidian notes)
This commit is contained in:
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Analysis pass for the Obsidian catalog publisher (read-only).
|
||||||
|
|
||||||
|
For every routed library/<slug>/ 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/<channelId>/);
|
||||||
|
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()
|
||||||
@@ -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 : <base>" 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
|
||||||
Reference in New Issue
Block a user