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