#!/usr/bin/env python3 """Expand rune clusters, restore ghost primaries, stamp [Руны: …] on the 1000.""" from __future__ import annotations import json import re from collections import defaultdict from pathlib import Path ROOT = Path(__file__).resolve().parents[1] CANON = ROOT / "oracle_enricher" / "data" / "traditions_canon.json" ASSET = ROOT / "assets" / "data" / "traditions_canon.json" LEX = ROOT / "oracle_enricher" / "data" / "lexicon.json" DART = ROOT / "lib" / "data" / "words_data.dart" HTML = ROOT / "lexicon_viewer.html" MD = ROOT / "words_alphabetical.md" RUNE_TAG_RE = re.compile(r"\[Руны:[^\]]*\]\s*") # Extra lexicon words on top of the station primary. CLUSTER_OVERRIDE = { "fehu": ["изобилие", "полнота", "успех", "благословение", "рост", "начало"], "uruz": ["сила", "тело", "жизнь", "воля", "желание", "кровь"], "jera": ["осень", "цикл", "год", "труд", "семя", "плод", "хлеб"], "perthro": ["тайна", "игра", "случай", "судьба", "завеса", "сосуд", "жребий", "кость"], "tiwaz": ["отец", "клятва", "закон", "мужчина", "меч", "копьё", "судья"], "laguz": ["вода", "глубина", "волна", "девушка", "море", "река", "родник", "озеро", "ручей"], } # Viewer/lexicon name -> live dart name WORD_REMAP = { "солнцестояние": "солнцеворот", } # Live dart still has frequency-list verbs on cells whose canon primary is a sacred noun. DART_RESTORE = { "глядеть": "золото", "забыть": "тёрн", "возникнуть": "град", "вспомнить": "нужда", "возвращаться": "урожай", } RESTORE_META = { "золото": ( "существительное", "Священный Предмет", "Совершенный солнечный металл алхимии, нетленный свет, царский венец и мера высшей ценности. " "Золото не ржавеет в земле и не тускнеет от времени, знаменуя победу духа над распадом материи. " "В раскладе это знак высшего успеха, обретения нетленного сокровища мудрости и кристальной чистоты намерения.", ), "тёрн": ( "существительное", "Сакральный Символ", "Черный шип неизбежности, страж предела, колючая ограда рока, через суровость ведущая к очищению духа.", ), "град": ( "существительное", "Образ / Архетип", "Стихийное ледяное зерно с небес, внезапный сокрушительный удар бури, ломающий старые формы ради очищения.", ), "нужда": ( "существительное", "Образ / Архетип", "Священный крест необходимости, стеснение рока, из которого высекается внутренний огонь спасения и несгибаемая стойкость.", ), "урожай": ( "существительное", "Образ / Архетип", "Благодатный сбор плодов долгого труда, венчание годового цикла, щедрость земли и торжество плодородия.", ), } def tag_for(st: dict) -> str: name = st["name"] symbol = st.get("symbol", "") group = st.get("group", "") if " (" in name: ru, rest = name.split(" (", 1) en = rest.rstrip(")") return f"[Руны: {group} {ru} ({en} / {symbol})]" return f"[Руны: {group} {name}]" def restore_dart(text: str) -> str: for old, new in DART_RESTORE.items(): pos, cat, desc = RESTORE_META[new] desc_esc = desc.replace("\\", "\\\\").replace('"', '\\"') def repl(m: re.Match, new=new, pos=pos, cat=cat, desc_esc=desc_esc) -> str: return ( f"{m.group(1)}{new}{m.group(2)}" f"{m.group(3)}{pos}{m.group(4)}" f"{m.group(5)}{cat}{m.group(6)}" f"{m.group(7)}{desc_esc}{m.group(8)}" ) pat = re.compile( rf'(word: "){re.escape(old)}(",\s*\n)' rf'(\s*partOfSpeech: ")[^"]+(",\s*\n)' rf'(\s*frequency: [^\n]+\n\s*category: ")[^"]+(",\s*\n)' rf'(\s*description: ")[^"]*(")', ) if f'word: "{new}",' in text and f'word: "{old}",' not in text: continue text2, n = pat.subn(repl, text, count=1) if n != 1: raise SystemExit(f"failed to restore {old} -> {new} (n={n})") text = text2 return text def payload_from(word: str, dart_id: int, lex_by_word: dict, old: dict | None) -> dict: lex = lex_by_word.get(word) or {} old = old or {} return { "id": dart_id, "word": word, "emoji": lex.get("emoji") or old.get("emoji") or "", "pos": lex.get("pos") or old.get("pos") or "существительное", "category": lex.get("category") or old.get("category") or "", "desc": old.get("desc") or "", } def main() -> None: dart_text = restore_dart(DART.read_text(encoding="utf-8")) DART.write_text(dart_text, encoding="utf-8") dart_by_word: dict[str, int] = {} dart_pos: dict[str, str] = {} dart_cat: dict[str, str] = {} for m in re.finditer( r'WordItem\(\s*id:\s*(\d+),\s*word:\s*"([^"]+)",\s*' r'partOfSpeech:\s*"([^"]+)",\s*frequency:\s*[^\n]+\n\s*category:\s*"([^"]+)"', dart_text, ): dart_by_word[m.group(2)] = int(m.group(1)) dart_pos[m.group(2)] = m.group(3) dart_cat[m.group(2)] = m.group(4) lex_list = json.loads(LEX.read_text(encoding="utf-8")) lex_by_word = {e["word"]: e for e in lex_list} canon = json.loads(CANON.read_text(encoding="utf-8")) runes = canon["runes"] word_to_tags: dict[str, list[str]] = defaultdict(list) for st in runes["stations"]: sid = st["id"] tag = tag_for(st) prim = st.get("primary") or {} prim_w = prim.get("word") if prim_w in WORD_REMAP: prim_w = WORD_REMAP[prim_w] if prim_w not in dart_by_word: raise SystemExit(f"missing primary {sid}/{prim_w} in dart") st["primary"] = payload_from(prim_w, dart_by_word[prim_w], lex_by_word, prim) # keep the existing primary desc (rune lore) if present if prim.get("desc"): st["primary"]["desc"] = prim["desc"] old_cluster = {c.get("word"): c for c in st.get("cluster") or []} if sid in CLUSTER_OVERRIDE: extra_names = CLUSTER_OVERRIDE[sid] else: extra_names = [c.get("word") for c in st.get("cluster") or [] if c.get("word")] extra: list[str] = [] for w in extra_names: w = WORD_REMAP.get(w, w) if w == prim_w or w not in dart_by_word or w in extra: continue extra.append(w) st["cluster"] = [ payload_from(w, dart_by_word[w], lex_by_word, old_cluster.get(w)) for w in extra ] word_to_tags[prim_w].append(tag) for w in extra: word_to_tags[w].append(tag) text = json.dumps(canon, ensure_ascii=False, indent=2) + "\n" CANON.write_text(text, encoding="utf-8") ASSET.write_text(text, encoding="utf-8") dart_lines = dart_text.splitlines(keepends=True) nstamp = 0 i = 0 while i < len(dart_lines): wm = re.match(r'(\s*word: ")([^"]+)(",\s*)$', dart_lines[i]) if wm: word = wm.group(2) for j in range(i + 1, min(i + 12, len(dart_lines))): raw = dart_lines[j].rstrip("\n") dm = re.match(r'^(\s*description: ")(.*)(",)\s*$', raw) if dm: body = RUNE_TAG_RE.sub("", dm.group(2)).strip() tags = word_to_tags.get(word) if tags: dart_lines[j] = f"{dm.group(1)}{' '.join(tags)} {body}{dm.group(3)}\n" nstamp += 1 else: dart_lines[j] = f"{dm.group(1)}{body}{dm.group(3)}\n" break if dart_lines[j].strip().startswith("WordItem("): break i += 1 dart2 = "".join(dart_lines) DART.write_text(dart2, encoding="utf-8") html = HTML.read_text(encoding="utf-8") start = html.find(" const TRADITIONS_CANON = ") end = html.find(" const WORDS_DATA = ") if start > 0 and end > start: html = ( html[:start] + " const TRADITIONS_CANON = " + json.dumps(canon, ensure_ascii=False, indent=2) + ";\n\n" + html[end:] ) m = re.search(r"const WORDS_DATA = (\[.*?\]);", html, flags=re.DOTALL) if not m: raise SystemExit("WORDS_DATA not found") words = json.loads(m.group(1)) dart_descs = dict(re.findall(r'word: "([^"]+)"[\s\S]*?description: "([^"]*)"', dart2)) for w in words: wd = w.get("word") if wd in dart_descs: w["description"] = dart_descs[wd] html = html[: m.start(1)] + json.dumps(words, ensure_ascii=False) + html[m.end(1) :] HTML.write_text(html, encoding="utf-8") if MD.exists(): md = MD.read_text(encoding="utf-8") def repl_block(mm: re.Match) -> str: word = mm.group(1) body = mm.group(3) if word not in dart_descs: return mm.group(0) body2 = re.sub( r"\* \*\*Толкование:\*\* .+", f"* **Толкование:** {dart_descs[word]}", body, count=1, ) return f"### {word} `{mm.group(2)}`\n{body2}" md2 = re.sub( r"### ([^\s`]+) `(#[^`]+)`\n([\s\S]*?)(?=\n### |\n## |\Z)", repl_block, md, ) MD.write_text(md2, encoding="utf-8") print(f"stamped {nstamp} dart words, tagged {len(word_to_tags)}") for st in runes["stations"]: prim = (st.get("primary") or {}).get("word") cl = [c["word"] for c in st.get("cluster") or []] print(f" {st['id']:12} prim={prim:12} n={len(cl)} {cl}") if __name__ == "__main__": main()