#!/usr/bin/env python3 """Rebuild Lenormand lexicon intersections and stamp [Ленорман:] tags.""" 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" LENO_TAG_RE = re.compile(r"\[Ленорман:[^\]]*\]\s*") DART_RESTORE = { "пилигрим": "всадник", "выйти": "клевер", "войти": "корабль", "прийти": "туча", "уйти": "гроб", "ответить": "букет", "потерять": "коса", "согласиться": "метла", "объяснить": "аист", "позволить": "крыса", "успеть": "лилии", "поднять": "птицы", "рыба": "рыбы", } RESTORE_META = { "всадник": ( "существительное", "Тотем / Архетип", "Гонец скорых вестей, импульс в пути, тот кто выводит застой в движение.", ), "клевер": ( "существительное", "Сакральный Символ", "Малая удача, короткий шанс, лёгкость удачного мига, который нельзя удержать силой.", ), "корабль": ( "существительное", "Священный Предмет", "Дальняя дорога, торговля, новые горизонты, ковчег перехода через воду.", ), "туча": ( "существительное", "Образ / Архетип", "Неясность, сомнение, грозовой фронт, помрачение взора перед решением.", ), "гроб": ( "существительное", "Священный Предмет", "Окончание цикла, глубокая пауза, саркофаг старой формы, место где отмирает отжившее.", ), "букет": ( "существительное", "Священный Предмет", "Дар, признание, красота визита, радостный знак внимания.", ), "коса": ( "существительное", "Священный Предмет", "Внезапный срез, жатва, резкое решение, опасность отсечения лишнего.", ), "метла": ( "существительное", "Священный Предмет", "Ссора и очищение, спор, выметание сора, наказание, что метёт дом.", ), "аист": ( "существительное", "Тотем / Архетип", "Перемена места, рождение, возврат цикла, вестник семейного очага.", ), "крыса": ( "существительное", "Тотем / Архетип", "Утрата по капле, разъедание запасов, стресс скрытой утечки.", ), "лилии": ( "существительное", "Сакральный Символ", "Чистота, благородство, зрелый покой, покровительство без жара страсти.", ), "птицы": ( "существительное", "Тотем / Архетип", "Щебет речей, звонки и суета переговоров, обмен мнениями на лету.", ), "рыбы": ( "существительное", "Тотем / Архетип", "Изобилие, поток средств, глубина чувства, плодородие воды.", ), } # Extra lexicon words on top of the station primary. CLUSTERS = { "card_1": ["вестник", "путь", "дорога"], "card_2": ["радость", "успех", "смех"], "card_3": ["путь", "вода", "море", "дорога"], "card_4": ["семья", "род", "очаг"], "card_5": ["корень", "жизнь", "род", "древо"], "card_6": ["туман", "тревога", "сомнение", "тьма"], "card_7": ["искушение", "яд", "враг", "мудрость"], "card_8": ["смерть", "конец", "тьма", "пепел"], "card_9": ["дар", "радость", "сад", "пир"], "card_10": ["удар", "смерть", "урожай"], "card_11": ["борьба", "гнев", "чистота"], "card_12": ["птицы", "голос", "слово", "вестник"], "card_13": ["дитя", "начало", "чистота", "рождение"], "card_14": ["ложь", "тайна", "маска"], "card_15": ["сила", "власть", "защита"], "card_16": ["надежда", "свет", "небо"], "card_17": ["рождение", "дом", "весна", "цикл"], "card_18": ["друг", "страж"], "card_19": ["закон", "высота", "покой"], "card_20": ["встреча", "пир"], "card_21": ["камень", "холод", "граница"], "card_22": ["путь", "дорога", "ключ"], "card_23": ["нищета", "голод"], "card_24": ["любовь", "страсть", "союз"], "card_25": ["союз", "обет", "клятва"], "card_26": ["тайна", "знание", "мудрость"], "card_27": ["слово", "вестник", "ключ"], "card_28": ["отец", "воин", "воля"], "card_29": ["мать", "сердце", "луна"], "card_30": ["чистота", "покой", "белый"], "card_31": ["свет", "радость", "успех", "жизнь"], "card_32": ["сон", "тайна", "вода"], "card_33": ["дверь", "тайна", "выход"], "card_34": ["изобилие", "вода", "золото"], "card_35": ["дом", "покой", "море"], "card_36": ["судьба", "карма", "жертва", "вера"], } 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(word: str, dart_by_word: dict, lex_by_word: dict, old: dict | None = None) -> dict: lex = lex_by_word.get(word) or {} old = old or {} return { "id": dart_by_word[word], "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": "", } def main() -> None: dart_text = restore_dart(DART.read_text(encoding="utf-8")) DART.write_text(dart_text, encoding="utf-8") dart_by_word = { w: int(i) for i, w in re.findall(r'WordItem\(\s*id:\s*(\d+),\s*word:\s*"([^"]+)"', dart_text) } lex_by_word = {e["word"]: e for e in json.loads(LEX.read_text(encoding="utf-8"))} canon = json.loads(CANON.read_text(encoding="utf-8")) ln = canon["lenormand"] word_to_tags: dict[str, list[str]] = defaultdict(list) missing = [] for st in ln["stations"]: sid = st["id"] prim_old = st.get("primary") or {} prim_w = prim_old.get("word") if prim_w == "рыба": prim_w = "рыбы" if prim_w not in dart_by_word: missing.append(f"primary {sid}/{prim_w}") continue st["primary"] = payload(prim_w, dart_by_word, lex_by_word, prim_old) extra = [] seen = {prim_w} for w in CLUSTERS.get(sid, []): if w in seen or w not in dart_by_word: if w not in dart_by_word: missing.append(f"cluster {sid}/{w}") continue seen.add(w) extra.append(w) st["cluster"] = [payload(w, dart_by_word, lex_by_word) for w in extra] tag = f"[Ленорман: {st['name']}]" word_to_tags[prim_w].append(tag) for w in extra: word_to_tags[w].append(tag) if missing: print("WARN", missing) 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 = LENO_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} tagged {len(word_to_tags)}") empty = 0 for st in ln["stations"]: cl = [c["word"] for c in st.get("cluster") or []] if not cl: empty += 1 print(f" {st['id']:8} prim={(st.get('primary') or {}).get('word'):12} n={len(cl)} {cl}") print("empty", empty) if __name__ == "__main__": main()