#!/usr/bin/env python3 """Enrich the Pokémon ROM-hack vault from the Discord scrape (.scrape/*/metadata.json). Three subcommands, each --dry-run by default (writes previews to .scrape/_preview/) and only touches the live vault with --apply: backfill 211 Discord-import notes: fill missing frontmatter (base, version, status, type, developer, release_date, banner, scrape_dir) parsed from the scrape. Body is preserved verbatim. merge 43 curated-overlap notes: keep the hand-written note, add a banner, append Story/Features/community Links parsed from the scrape, and add developer/release_date/banner/scrape_dir frontmatter. The curated Summary and existing frontmatter are preserved. create 83 unmatched scraped hacks: write brand-new wiki-schema notes. Source of truth for matching = .scrape/_match_report.json (scrape_match.py). Art is served at https://romhacks-files.ginnoir.com/_meta//. """ from __future__ import annotations import json, os, re, sys, glob, unicodedata from datetime import date from urllib.parse import quote ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) VAULT = r"C:\Users\MattC\Documents\Obsidian Vault" HACKS = os.path.join(VAULT, "Pokémon ROM Hacks", "Hacks") SCRAPE = os.path.join(ROOT, ".scrape") PREVIEW = os.path.join(SCRAPE, "_preview") ART_BASE = "https://romhacks-files.ginnoir.com/_meta" TODAY = date.today().isoformat() # ---------------------------------------------------------------- text cleaning def clean(s: str) -> str: if not s: return "" s = s.replace("​", "").replace("", "") s = s.replace("\xa0", " ").replace("’", "'").replace("‘", "'") s = s.replace("“", '"').replace("”", '"').replace("–", "-") s = re.sub(r"[ \t]+", " ", s) s = re.sub(r"\n{3,}", "\n\n", s) return s.strip() # ---------------------------------------------------------------- parsing MONTHS = {m: i for i, m in enumerate( ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"], 1)} PLATFORM_HINTS = [ (r"\bGBA\b|game boy advance", ("GBA", "console")), (r"\bGBC\b|game boy color", ("GBC", "console")), (r"\bNDS\b|nintendo ds\b|\bDS ROM", ("NDS", "console")), (r"\b3DS\b", ("3DS", "console")), (r"\bN64\b|Nintendo 64|Pokemon Stadium|Pokémon Stadium", ("N64", "console")), (r"\bGameCube\b|Nintendo GameCube|Pokemon Colosseum|Pokémon Colosseum", ("GameCube", "console")), (r"\bSwitch\b|Nintendo Switch", ("Switch", "console")), (r"\bGB\b|game boy(?! advance| color)", ("GB", "console")), (r"RPGXP|RPG ?Maker|Essentials|FanGame|fan game|GameMaker", ("PC", "fangame")), ] BASE_CANON = { "fire red": "FireRed", "firered": "FireRed", "fire-red": "FireRed", "leaf green": "LeafGreen", "leafgreen": "LeafGreen", "fire red/leaf green": "FireRed", "firered/leafgreen": "FireRed", "emerald": "Emerald", "ruby": "Ruby", "sapphire": "Sapphire", "ruby/sapphire": "Ruby", "crystal": "Crystal", "gold": "Gold", "silver": "Silver", "gold/silver": "Gold", "red": "Red", "blue": "Blue", "yellow": "Yellow", "diamond": "Diamond", "pearl": "Pearl", "platinum": "Platinum", "heart gold": "HeartGold", "heartgold": "HeartGold", "soul silver": "SoulSilver", "soulsilver": "SoulSilver", "black": "Black", "white": "White", "black 2": "Black 2", "white 2": "White 2", "black2": "Black 2", "white2": "White 2", } SECTION_ALIASES = { "description": "Description", "about": "Description", "overview": "Description", "plot": "Story", "story": "Story", "synopsis": "Story", "features": "Features", "feature": "Features", "key features": "Features", "download": "Download", "downloads": "Download", "rom link": "Download", "changelog": "Changelog", "credits": "Credits", } def norm_key(name: str) -> str: name = unicodedata.normalize("NFKD", name) name = "".join(c for c in name if not unicodedata.combining(c)).lower() name = re.sub(r"\bpok[e]?mon\b", " ", name) return re.sub(r"[^a-z0-9]+", "", name) def parse_release(text: str): m = re.search(r"(?:last updated on|released on|updated on|released)\s+" r"([A-Za-z]+)\s+(\d{1,2}),?\s*(\d{4})", text, re.I) if m: mo = MONTHS.get(m.group(1).lower()) if mo: return f"{m.group(3)}-{mo:02d}-{int(m.group(2)):02d}" m = re.search(r"\b(20\d{2})\b", text) return m.group(1) if m else None def parse_intro(text: str) -> dict: out: dict = {} head = text[:400] m = re.search(r"\bby\s+(.+?)(?:\s+based on|\s+made using|\s+using|\s+in English|" r"\.\s|\,\s|\s+and it| \(|\n)", head) if m: dev = m.group(1).strip(" .,-") if 1 < len(dev) < 60 and not dev.lower().startswith(("the ", "a ")): out["developer"] = dev m = re.search(r"based on\s+(?:Pok[eé]mon\s+)?([A-Za-z0-9][A-Za-z0-9 /&]+?)" r"(?:\.|,|\n| in | with |!)", head, re.I) if m: raw = m.group(1).strip().lower() raw = re.sub(r"\bversions?\b", "", raw).strip() out["base"] = BASE_CANON.get(raw, m.group(1).strip()) for pat, (plat, kind) in PLATFORM_HINTS: if re.search(pat, head, re.I): out["platform"], out["kind"] = plat, kind break return out # inline / line key-value metadata blocks, e.g. "**Creator:** X **Type:** GBA # **Hack of:** FireRed **Version:** Final **Last Updated:** May 26, 2020". KV_KEYS = (r"Creator|Developer|Dev|Author|Made by|Type of ROM|Type|Platform|" r"System|Hack of|Base ROM|Base game|Base|Based on|Version|Status|" r"Last Updated|Updated|Release Date|Released|Date|Language|Genre") KV_RE = re.compile(rf"(?i)\b(?P{KV_KEYS})\s*[:\-]\s*(?P.+?)" rf"(?=\s{{2,}}|\s*\b(?:{KV_KEYS})\b\s*[:\-]|$)", re.M) KV_LINE = re.compile(rf"(?i)^\s*(?:{KV_KEYS})\s*[:\-]") def parse_kv(text: str) -> dict: """Extract dev/platform/base/version/status/release from key-value blocks (bold or plain). De-bold first so '**Creator:** X' -> 'Creator: X'.""" flat = text.replace("*", "") got: dict[str, str] = {} for m in KV_RE.finditer(flat): k = m.group("key").lower().strip() v = m.group("val").strip(" .,-") if not v or len(v) > 80: continue got.setdefault(k, v) out = {} def first(*keys): for k in keys: if k in got: return got[k] return None dev = first("creator", "developer", "dev", "author", "made by") if dev: out["developer"] = dev typ = first("type of rom", "type", "platform", "system") if typ: for pat, (plat, kind) in PLATFORM_HINTS: if re.search(pat, typ, re.I): out["platform"], out["kind"] = plat, kind break base = first("hack of", "base rom", "base game", "base", "based on") if base: bl = re.sub(r"\bversions?\b", "", base.lower()).strip() out["base"] = BASE_CANON.get(bl, base.strip()) if first("version"): out["version"] = first("version") if first("status"): out["status"] = first("status") if first("language"): out["language"] = first("language") rel = first("last updated", "updated", "release date", "released", "date") if rel: pr = parse_release(rel) or parse_release("released " + rel) if pr: out["release_date"] = pr return out def split_sections(text: str, name: str = ""): lines = text.split("\n") sections: dict[str, list[str]] = {} lead: list[str] = [] cur = None name_key = norm_key(name) for raw in lines: line = re.sub(r"^>\s?", "", raw.strip()) hkey = re.sub(r"[*#:_~`]", "", line).strip().lower() if hkey in SECTION_ALIASES and len(line) < 40: cur = SECTION_ALIASES[hkey] sections.setdefault(cur, []) continue flat = line.replace("*", "") low = flat.lower() is_meta = (re.match(r"^pok[e]?mon", low) and (" is a" in low or " is an" in low)) \ or bool(KV_LINE.match(flat)) \ or low.startswith(("rom link", "- rom link", "it was last updated", "made using", "by ")) \ or (name_key and norm_key(line) == name_key) # bare title repeat if cur: sections[cur].append(line) elif not is_meta and line: lead.append(line) feats = [] for ln in sections.get("Features", []): s = ln.strip().lstrip("-*•").strip() if s and not s.lower().startswith("download"): feats.append(s) lead_text = " ".join(lead).strip() return lead_text, {k: clean("\n".join(v)) for k, v in sections.items()}, feats def collect_links(meta: dict, text: str): urls = list(meta.get("links") or []) for m in re.finditer(r"https?://[^\s)\]>'\"]+", text): u = m.group(0).rstrip(".,)") if u not in urls: urls.append(u) seen, out = set(), [] for u in urls: if u not in seen: seen.add(u); out.append(u) return out TYPE_RULES = [ ("Difficulty", r"kaizo|difficulty hack|harder|increased difficulty|" r"competitive|nuzlocke|challenging|boosted ai|smart ai"), ("Expansion", r"mega evolution|national dex|gen 8|gen 9|generation 8|" r"generation 9|physical/special split|fairy type|decomp|" r"expansion|all 8\d\d"), ("New Experience", r"new region|new story|original story|fakemon|" r"new pok[e]?mon|custom region|brand new"), ("Roguelite", r"roguelite|roguelike|run-based|permadeath|randomized run"), ("Demake", r"demake|de-make|in gen ?[12] style|on gbc|1-bit"), ("Cosmetic", r"moemon|sprite pack|tileset|cosmetic|visual overhaul|reskin"), ("QoL", r"quality of life|quality-of-life|\bqol\b|reusable tms|exp share"), ] def infer_types(text: str) -> list[str]: low = text.lower() hits = [name for name, pat in TYPE_RULES if re.search(pat, low)] return hits[:2] # base game -> (platform, canonical base) for name/desc heuristic fallback GAME_PLATFORM = { "emerald": ("GBA", "Emerald"), "fire red": ("GBA", "FireRed"), "firered": ("GBA", "FireRed"), "leaf green": ("GBA", "LeafGreen"), "leafgreen": ("GBA", "LeafGreen"), "ruby": ("GBA", "Ruby"), "sapphire": ("GBA", "Sapphire"), "crystal": ("GBC", "Crystal"), "gold": ("GBC", "Gold"), "silver": ("GBC", "Silver"), "diamond": ("NDS", "Diamond"), "pearl": ("NDS", "Pearl"), "platinum": ("NDS", "Platinum"), "heartgold": ("NDS", "HeartGold"), "soulsilver": ("NDS", "SoulSilver"), "heart gold": ("NDS", "HeartGold"), "soul silver": ("NDS", "SoulSilver"), "black": ("NDS", "Black"), "white": ("NDS", "White"), "black 2": ("NDS", "Black 2"), "white 2": ("NDS", "White 2"), "stadium": ("N64", "Stadium"), "pokemon stadium": ("N64", "Stadium"), "pokémon stadium": ("N64", "Stadium"), } def infer_platform_base(name, desc, platform, base): """Fallback: if platform unknown, infer GBA/GBC/GB/NDS from a base game word in the name or first part of the description. Never overrides a known value.""" if platform and base: return platform, base hay = (name + " " + desc[:300]).lower() # if we already know base, set platform from it if base and base != "—" and not platform: bp = GAME_PLATFORM.get(base.lower()) if bp: return bp[0], base if not platform: for word, (plat, canon) in GAME_PLATFORM.items(): if re.search(rf"\b{re.escape(word)}\b", hay): return plat, (base if base and base != "—" else canon) return platform, base LEAD_PREFIX = re.compile( r"^\s*(?:version|status|update[d]?)\b\s*[:\-]?\s*" r"(?:completed|complete|beta|ongoing|final|released?)?\s*" r"(?:v?\d[\d.\w]*)?\s*[:\-]?\s*", re.I) def clean_lead(lead: str): """Strip a leading 'Version '/'Completed' label off a lead line and return (cleaned_lead, version, status) harvested from it.""" ver = status = None mv = re.match(r"^\s*(?:version|ver)\b\s*[:\-]?\s*(?:completed|complete|" r"beta|ongoing|final)?\s*(v?\d[\d.\w]*)", lead, re.I) if mv: ver = mv.group(1) ms = re.match(r"^\s*(?:status\b\s*[:\-]?\s*)?(completed|complete|beta|ongoing)", lead, re.I) if ms: status = ms.group(1) new = LEAD_PREFIX.sub("", lead) # a second pass for 'Version completed 1.3.1' style double-prefix new = LEAD_PREFIX.sub("", new).strip(" :-") if len(new) < 25: new = "" return new, ver, status def build_record(meta: dict, libidx: dict) -> dict: name = clean(meta.get("name", "")) desc = clean(meta.get("description", "")) scrape_dir = meta["_dir"] li = libidx.get(scrape_dir, {}) intro = parse_intro(desc) kv = parse_kv(desc) lead, sections, feats = split_sections(desc, name) rec = { "name": name, "scrape_dir": scrape_dir, "platform": (li.get("platform") or intro.get("platform") or kv.get("platform") or "").upper() or None, "kind": li.get("kind") or intro.get("kind") or kv.get("kind"), "base": intro.get("base") or kv.get("base"), "developer": intro.get("developer") or kv.get("developer"), "release_date": kv.get("release_date") or parse_release(desc), "version": kv.get("version"), "status": kv.get("status"), "lead": lead, "story": sections.get("Story", ""), "description_body": sections.get("Description", ""), "features": feats, "links": [u for u in collect_links(meta, desc) if not u.startswith(ART_BASE)], "art": (meta.get("art") or [None])[0], "types": infer_types(desc), } # tidy the lead: strip leading 'Version x'/'Completed' labels, fall back to # the Description section, harvest version/status if still unknown. cl, lv, ls = clean_lead(rec["lead"]) rec["lead"] = cl or sections.get("Description", "")[:600] if not rec["version"] and lv: rec["version"] = lv if not rec["status"] and ls: rec["status"] = ls if rec["platform"] == "PC" and not rec["base"]: rec["base"] = "—" if rec["platform"] != "PC": p, b = infer_platform_base(name, desc, rec["platform"], rec["base"]) rec["platform"], rec["base"] = p, b return rec # ---------------------------------------------------------------- frontmatter io def read_note(path): text = open(path, encoding="utf-8").read() m = re.match(r"^---\n(.*?)\n---\n?(.*)$", text, re.S) if not m: return "", text, text return m.group(1), m.group(2), text def fm_get(fm_text, key): m = re.search(rf"(?im)^{re.escape(key)}:\s*(.*)$", fm_text) return m.group(1).strip().strip('"') if m else None def fm_set(fm_text, key, value, quote_it=True): v = f'"{value}"' if quote_it and not str(value).startswith(("[", '"')) else value line = f"{key}: {v}" if re.search(rf"(?im)^{re.escape(key)}:\s*.*$", fm_text): return re.sub(rf"(?im)^{re.escape(key)}:\s*.*$", line, fm_text, count=1) return fm_text.rstrip() + "\n" + line def art_url(rec): if rec.get("art"): return f"{ART_BASE}/{rec['scrape_dir']}/{quote(rec['art'])}" return None # ---------------------------------------------------------------- loaders def load_scrape(): out = {} for jf in glob.glob(os.path.join(SCRAPE, "*", "metadata.json")): m = json.load(open(jf, encoding="utf-8")) m["_dir"] = os.path.basename(os.path.dirname(jf)) out[m["_dir"]] = m return out # ---------------------------------------------------------------- frontmatter (de)serialize FM_ORDER = ["title", "platform", "base", "version", "status", "type", "generation", "generations", "fakemon", "developer", "release_date", "banner", "library_path", "source", "homepage", "scrape_dir", "added", "play_status", "rating", "tags"] LIST_KEYS = {"type", "tags"} def parse_fm(fm_text): """Parse simple block-style frontmatter into an ordered dict. Scalars -> str; block/flow lists -> list[str].""" fm, lines, i = {}, fm_text.split("\n"), 0 while i < len(lines): km = re.match(r"^([\w]+):\s*(.*)$", lines[i]) if km: k, v = km.group(1), km.group(2) if v == "" and i + 1 < len(lines) and re.match(r"^\s*-\s", lines[i + 1]): items = [] while i + 1 < len(lines) and re.match(r"^\s*-\s", lines[i + 1]): items.append(lines[i + 1].strip()[2:].strip().strip('"')) i += 1 fm[k] = items elif v.strip().startswith("[") and v.strip().endswith("]"): inner = v.strip()[1:-1].strip() fm[k] = [x.strip().strip('"') for x in inner.split(",")] if inner else [] else: fm[k] = v.strip().strip('"') i += 1 return fm def emit_fm(fm): out = [] keys = [k for k in FM_ORDER if k in fm] + [k for k in fm if k not in FM_ORDER] for k in keys: v = fm[k] if k in LIST_KEYS or isinstance(v, list): v = v or [] if not v: out.append(f"{k}: []") else: out.append(f"{k}:") out.extend(f" - {x}" for x in v) elif k == "rating": out.append(f"{k}: {v}") elif k == "added" and re.match(r"^\d{4}-\d{2}-\d{2}$", str(v)): out.append(f"{k}: {v}") elif k in ("version", "release_date", "generation"): # always strings — avoid YAML number coercion (4.10 -> 4.1, 2024 -> int) out.append(f'{k}: "{v}"') else: sv = str(v) if sv == "" or re.search(r'[:#\[\]"\']', sv) or sv == "—": out.append(f'{k}: "{sv}"') else: out.append(f"{k}: {sv}") return "\n".join(out) def canon_status(s): if not s: return None low = s.lower() if "beta" in low: return "Beta" if "complete" in low or "finished" in low or "full" in low: return "Complete" if any(w in low for w in ("ongoing", "wip", "development", "in progress", "updates", "demo", "unfinished")): return "Ongoing" return None PLATFORM_LABEL = {"GB": "Game Boy", "GBC": "Game Boy Color", "GBA": "Game Boy Advance", "NDS": "Nintendo DS", "3DS": "Nintendo 3DS", "N64": "Nintendo 64", "GameCube": "Nintendo GameCube", "Switch": "Nintendo Switch", "PC": "PC / Joiplay", "—": "Unknown"} def derive_tags(fm): tags = ["hack"] p = (fm.get("platform") or "").lower() if p and p != "—": tags.append(f"platform/{p}") b = (fm.get("base") or "") if b and b != "—": tags.append(f"base/{b.lower().replace(' ', '')}") st = (fm.get("status") or "") if st and st != "—": tags.append(f"status/{st.lower().replace(' ', '')}") for t in (fm.get("type") or []): tags.append(f"type/{t.lower().replace(' ', '').replace('+', 'plus')}") if fm.get("scrape_dir"): tags.append("source/discord") return tags # ---------------------------------------------------------------- body builders def features_block(feats, limit=24): return "\n".join(f"- {clean(f)}" for f in feats[:limit]) def links_block(links, limit=8): out = [] for u in links[:limit]: label = "Download" if re.search(r"mediafire|drive\.google|mega\.nz|" r"github|\.zip|\.rar|/file", u) else "Link" if "docs.google" in u: label = "Guide / docs" if "pokecommunity" in u or "pokeharbor" in u or "hackdex" in u: label = "Info / thread" out.append(f"- {label}: {u}") return "\n".join(out) def type_links(types): return " ".join(f"[[Types#{t}|{t}]]" for t in types) if types else "" # ---------------------------------------------------------------- writers def out_write(path, content, apply): if apply: os.makedirs(os.path.dirname(path), exist_ok=True) open(path, "w", encoding="utf-8").write(content) else: pp = os.path.join(PREVIEW, os.path.basename(path)) os.makedirs(PREVIEW, exist_ok=True) open(pp, "w", encoding="utf-8").write(content) def cmd_backfill(report, scrape, libidx, apply): """211 Discord notes: fill missing frontmatter only; body preserved.""" changed = 0 log = [] for e in report["results"]["discord_match"]: stem = e["note_stem"] path = os.path.join(HACKS, stem + ".md") if not os.path.exists(path): continue rec = build_record(scrape[e["scrape_dir"]], libidx) fm_text, body, _ = read_note(path) fm = parse_fm(fm_text) before = dict(fm) # fill only missing / dash def missing(k): v = fm.get(k) return v in (None, "", "—") or (k == "type" and not v) if missing("base") and rec["base"] and rec["base"] != "—": fm["base"] = rec["base"] if missing("version") and rec["version"]: fm["version"] = rec["version"] if missing("status"): cs = canon_status(rec["status"]) or canon_status(rec["lead"]) if cs: fm["status"] = cs if missing("type") and rec["types"]: fm["type"] = rec["types"] if missing("developer") and rec["developer"]: fm["developer"] = rec["developer"] if missing("release_date") and rec["release_date"]: fm["release_date"] = rec["release_date"] if missing("banner"): au = art_url(rec) if au: fm["banner"] = au fm["scrape_dir"] = e["scrape_dir"] fm["tags"] = derive_tags(fm) if fm != before: changed += 1 new = f"---\n{emit_fm(fm)}\n---\n{body}" out_write(path, new, apply) log.append(f" {stem}: +{sorted(set(fm)-set(k for k in before if before[k] not in (None,'','—')))}") print(f"backfill: {changed} notes updated" f"{' (APPLIED)' if apply else ' (dry-run -> .scrape/_preview)'}") for l in log[:30]: print(l) def cmd_merge(report, scrape, libidx, apply): """43 curated overlaps: keep note, add banner + Story/Features/community links.""" changed = 0 for e in report["results"]["curated_overlap"]: stem = e["note_stem"] path = os.path.join(HACKS, stem + ".md") if not os.path.exists(path): continue rec = build_record(scrape[e["scrape_dir"]], libidx) fm_text, body, _ = read_note(path) fm = parse_fm(fm_text) if rec["developer"] and not fm.get("developer"): fm["developer"] = rec["developer"] if rec["release_date"] and not fm.get("release_date"): fm["release_date"] = rec["release_date"] au = art_url(rec) if au and not fm.get("banner"): fm["banner"] = au fm["scrape_dir"] = e["scrape_dir"] # body: insert banner image after callout blockquote (if no image yet) if au and "![" not in body: m = re.search(r"((?:^>.*\n)+)", body, re.M) if m: ins = m.end() body = body[:ins] + f"\n![banner|480]({au})\n" + body[ins:] # build append sections before the [[Index footer add = [] if rec["story"] and "## Story" not in body: add.append(f"## Story\n\n{rec['story']}") if rec["features"] and "## Features" not in body: add.append(f"## Features\n\n{features_block(rec['features'])}") new_links = [u for u in rec["links"] if u not in body] if new_links and "## Community links" not in body: add.append(f"## Community links\n\n{links_block(new_links)}") if add: block = "\n" + "\n\n".join(add) + "\n\n" # insert after Summary: before the first of Type/Links/In the library, # else before the [[Index footer. anchor = None for h in (r"\n## Type", r"\n## Links", r"\n## In the library", r"\n## Files", r"\n\[\[Index"): m2 = re.search(h, body) if m2: anchor = m2.start() break if anchor is not None: body = body[:anchor] + "\n" + block.rstrip() + "\n" + body[anchor:] else: body = body.rstrip() + "\n\n" + block new = f"---\n{emit_fm(fm)}\n---\n{body}" out_write(path, new, apply) changed += 1 print(f"merge: {changed} curated notes enriched" f"{' (APPLIED)' if apply else ' (dry-run -> .scrape/_preview)'}") def safe_stem(name): s = re.sub(r"^\s*pok[eé]mon\s+", "", name, flags=re.I).strip() s = s.replace("/", " ").replace(":", " -").replace("|", " ") s = re.sub(r'[<>"\\?*]', "", s) s = re.sub(r"\s+", " ", s).strip() return s or name def build_body(rec, fm, *, owned, library_note=None): """Clean, uniform wiki body shared by create + rebuild. owned=True writes an 'In the library' (RomM/served) block; owned=False writes a catalog-entry note.""" title = fm["title"] plat, base = fm.get("platform", "—"), fm.get("base", "—") status = fm.get("status", "—") ver = fm.get("version", "—") au = fm.get("banner") kindline = {"console": "ROM hack", "fangame": "fan-game", "patch": "patch"}.get(rec["kind"], "ROM hack / fan-game") callout = (f"> [!info] {', '.join(fm.get('type') or []) or kindline} — **{status}**\n" f"> {PLATFORM_LABEL.get(plat, plat)} · base **{base}** · " f"version **{ver}**" + (f" · by **{fm['developer']}**" if fm.get("developer") else "")) parts = [f"# {title}", "", callout, ""] if au: parts += [f"![banner|480]({au})", ""] parts += ["## Summary", "", rec["lead"] or rec["description_body"] or "Catalogued from the Discord ROM-hack archive. See links below.", ""] if rec["story"]: parts += ["## Story", "", rec["story"], ""] if rec["features"]: parts += ["## Features", "", features_block(rec["features"]), ""] if fm.get("type"): parts += ["## Type", type_links(fm["type"]), ""] if rec["links"]: parts += ["## Links", "", links_block(rec["links"]), ""] if owned: slug = library_note or rec["scrape_dir"] parts += ["## In the library", "", f"- Served files: [browse]({ART_BASE.replace('/_meta','')}/{slug}/)", f"- Library path: `/storage1/labdata/romhacks/library/{slug}/`", ""] else: parts += ["## In the library", "", "> [!note] Catalog entry imported from the Discord archive on " f"{TODAY}. Not yet placed in the RomM library.", ""] parts += ["[[Index|← back to directory]]", ""] return "\n".join(parts) def cmd_create(report, scrape, libidx, apply): """83 unmatched: brand-new wiki-schema notes.""" created = skipped = 0 existing = {os.path.splitext(f)[0].lower() for f in os.listdir(HACKS)} for e in report["results"]["unmatched"]: rec = build_record(scrape[e["scrape_dir"]], libidx) stem = safe_stem(rec["name"]) if stem.lower() in existing: skipped += 1 continue plat = rec["platform"] or "—" base = rec["base"] or "—" status = canon_status(rec["status"]) or canon_status(rec["lead"]) or "—" au = art_url(rec) title = rec["name"] if rec["name"].lower().startswith(("pokémon", "pokemon")) \ else f"Pokémon {stem}" title = title.replace("Pokemon", "Pokémon") fm = {"title": title, "platform": plat, "base": base, "version": rec["version"] or "—", "status": status, "type": rec["types"], "generation": "—"} if rec["developer"]: fm["developer"] = rec["developer"] if rec["release_date"]: fm["release_date"] = rec["release_date"] if au: fm["banner"] = au fm["library_path"] = "—" if rec["links"]: fm["source"] = rec["links"][0] fm["scrape_dir"] = e["scrape_dir"] fm["added"] = TODAY fm["play_status"] = "Unplayed" fm["tags"] = derive_tags(fm) content = f"---\n{emit_fm(fm)}\n---\n" + build_body(rec, fm, owned=False) out_write(os.path.join(HACKS, stem + ".md"), content, apply) created += 1 print(f"create: {created} new notes, {skipped} skipped (name collision)" f"{' (APPLIED)' if apply else ' (dry-run -> .scrape/_preview)'}") def cmd_rebuild(report, scrape, libidx, apply): """211 Discord notes: regenerate the BODY into the clean uniform schema, preserving (already-backfilled) frontmatter. Removes the messy auto-import callout / blockquoted Description / duplicated Download artifacts.""" done = 0 owned_slugs = set(libidx) for e in report["results"]["discord_match"]: stem = e["note_stem"] path = os.path.join(HACKS, stem + ".md") if not os.path.exists(path): continue rec = build_record(scrape[e["scrape_dir"]], libidx) fm_text, _, _ = read_note(path) fm = parse_fm(fm_text) owned = e["scrape_dir"] in owned_slugs body = build_body(rec, fm, owned=owned, library_note=e["scrape_dir"]) content = f"---\n{emit_fm(fm)}\n---\n{body}" out_write(path, content, apply) done += 1 print(f"rebuild: {done} Discord note bodies normalized" f"{' (APPLIED)' if apply else ' (dry-run -> .scrape/_preview)'}") def main(): cmds = {"backfill": cmd_backfill, "merge": cmd_merge, "create": cmd_create, "rebuild": cmd_rebuild} if len(sys.argv) < 2 or sys.argv[1] not in cmds: sys.exit(f"usage: enrich_vault.py {{{'|'.join(cmds)}}} [--apply]") apply = "--apply" in sys.argv report = json.load(open(os.path.join(SCRAPE, "_match_report.json"), encoding="utf-8")) scrape = load_scrape() libidx = json.load(open(os.path.join(SCRAPE, "_library_index.json"), encoding="utf-8")) cmds[sys.argv[1]](report, scrape, libidx, apply) if __name__ == "__main__": main()