diff --git a/tools/migrate_lang_to_json.py b/tools/migrate_lang_to_json.py new file mode 100644 index 0000000..011a08e --- /dev/null +++ b/tools/migrate_lang_to_json.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Einmalige Migration: extrahiert die 403 Eintraege der Texttabelle *ssg-texts* +aus dem Quelltext von Lisp/ssg_lang.lsp und schreibt sie in +lang/de_DE.json und lang/en_GB.json. + +Danach wird ein unabhaengiger Round-Trip-Test gefahren: die geschriebenen +JSON-Dateien werden mit einer Python-Nachbildung des LISP-JSON-Parsers +(ssg-cfg-parse-nested + ssg-cfg-parse-value) und der Unescape-Logik +(ssg-lang-unescape: \\n -> Zeilenumbruch) neu eingelesen und exakt gegen die +LISP-Laufzeitwerte verglichen. + +Aufruf (aus dem Repo-Wurzelverzeichnis): + py tools/migrate_lang_to_json.py +""" +import re +import sys +import io +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SRC = REPO / "Lisp" / "ssg_lang.lsp" +LANG_DIR = REPO / "lang" +OUT_DE = LANG_DIR / "de_DE.json" +OUT_EN = LANG_DIR / "en_GB.json" + +# (list "key" (cons "de_DE" "...") (cons "en_GB" "...")) +# Wert erlaubt Escape-Sequenzen (\\.) und beliebige Nicht-Quote-Zeichen. +ENTRY_RE = re.compile( + r'\(list\s+"([^"]+)"\s*' + r'\(cons\s+"de_DE"\s+"((?:[^"\\]|\\.)*)"\s*\)\s*' + r'\(cons\s+"en_GB"\s+"((?:[^"\\]|\\.)*)"\s*\)', + re.DOTALL, +) + + +def extract_entries(text): + return [(m.group(1), m.group(2), m.group(3)) for m in ENTRY_RE.finditer(text)] + + +# Erlaubte Backslash-Sequenzen im Quelltext-Wert: \n und \uXXXX. +ESC_TOKEN_RE = re.compile(r'\\(u[0-9a-fA-F]{4}|.)') + + +def assert_only_known_escapes(entries): + """Erlaubt sind nur \\n und \\uXXXX; jede andere \\X-Sequenz sowie nackte + Anfuehrungszeichen wuerden die JSON-Erzeugung/-Gueltigkeit gefaehrden. + Echte Zeilenumbrueche im Wert sind zulaessig (werden zu \\n gewandelt).""" + bad = [] + for key, de, en in entries: + for lang, val in (("de_DE", de), ("en_GB", en)): + for m in ESC_TOKEN_RE.finditer(val): + tok = m.group(1) + if not (tok == 'n' or (tok.startswith('u') and len(tok) == 5)): + bad.append((key, lang, m.group(0))) + if '"' in val: + bad.append((key, lang, 'nacktes Anfuehrungszeichen')) + if bad: + for b in bad[:20]: + print(" UNERWARTET:", b, file=sys.stderr) + raise SystemExit(f"Abbruch: {len(bad)} unerwartete Escapes/Zeichen gefunden.") + + +def convert_for_json(raw): + """Rohwert (LISP-Quelltext) in einen einzeiligen, gueltigen JSON-String-Inhalt + ueberfuehren: + - echter Zeilenumbruch -> \\n-Token (Eintrag bleibt einzeilig) + - \\uXXXX -> echtes Unicode-Zeichen (konsistent zu de_DE '△') + - vorhandene \\n-Token bleiben unveraendert + Kein json.dumps, damit die \\n-Token nicht zu \\\\n verdoppelt werden.""" + s = raw.replace('\r\n', '\n').replace('\n', '\\n') + s = ESC_TOKEN_RE.sub( + lambda m: chr(int(m.group(1)[1:], 16)) + if (m.group(1).startswith('u') and len(m.group(1)) == 5) + else m.group(0), + s) + return s + + +def write_json(path, entries, idx): + """Schreibt {"texts": {"key": "wert", ...}}.""" + lines = ['{', ' "texts": {'] + n = len(entries) + for i, (key, de, en) in enumerate(entries): + val = convert_for_json(de if idx == 1 else en) + comma = ',' if i < n - 1 else '' + lines.append(f' "{key}": "{val}"{comma}') + lines.append(' }') + lines.append('}') + with io.open(path, "w", encoding="utf-8", newline="\n") as f: + f.write("\n".join(lines) + "\n") + + +# --------------------------------------------------------------------------- +# Python-Nachbildung des LISP-Parsers fuer den Round-Trip-Test +# --------------------------------------------------------------------------- +def lisp_cfg_trim(s): + s = s.strip(" \t\r\n") + if s.endswith(","): + s = s[:-1] + return s.strip(" \t\r\n") + + +def lisp_parse_value(val): + val = lisp_cfg_trim(val) + if val == "null": + return None + if val == "true": + return True + if val == "false": + return None + if val.startswith('"'): + if len(val) > 1 and val.endswith('"'): + return val[1:len(val) - 1] + return val + # Zahlen/Arrays kommen in den Sprachdateien nicht vor + return val + + +def lisp_parse_kv(zeile): + zeile = lisp_cfg_trim(zeile) + if not (len(zeile) > 0 and zeile.startswith('"')): + return None + pos = zeile.find('"', 1) # schliessendes Quote des Keys (0-basiert) + if pos == -1: + return None + key = zeile[1:pos] + rest = zeile[pos + 1:] + pos2 = rest.find(":") + if pos2 == -1: + return None + val = rest[pos2 + 1:] + return (key, lisp_parse_value(val)) + + +def lisp_parse_nested(lines): + """Nachbildung von ssg-cfg-parse-nested (1 Ebene tief).""" + ergebnis = {} + section = None + in_inner = False + for zeile in lines: + trimmed = lisp_cfg_trim(zeile) + if not in_inner and trimmed == "{": + continue + if not in_inner and trimmed == "}": + continue + if (not in_inner) and (": {" in trimmed): + # Section-Start "name": { + q = trimmed.find('"', 1) + section = trimmed[1:q] + ergebnis[section] = {} + in_inner = True + continue + if in_inner and trimmed in ("}", "},"): + in_inner = False + section = None + continue + if in_inner: + kv = lisp_parse_kv(trimmed) + if kv: + ergebnis[section][kv[0]] = kv[1] + return ergebnis + + +def lisp_lang_unescape(val): + """Nachbildung von ssg-lang-unescape: \\n -> Zeilenumbruch.""" + if val is None: + return None + return val.replace("\\n", "\n") + + +def lisp_runtime_value(raw): + """Der erwartete Laufzeitwert nach der gewaehlten Semantik: + - \\n und echter Zeilenumbruch -> Zeilenumbruch + - \\uXXXX -> echtes Unicode-Zeichen + - \\t \\r \\\\ \\\" defensiv aufgeloest""" + raw = raw.replace('\r\n', '\n') + out = [] + i = 0 + while i < len(raw): + if raw[i] == "\\" and i + 1 < len(raw): + nxt = raw[i + 1] + if nxt == "u" and i + 6 <= len(raw) and \ + re.match(r'[0-9a-fA-F]{4}', raw[i + 2:i + 6]): + out.append(chr(int(raw[i + 2:i + 6], 16))); i += 6; continue + if nxt == "n": + out.append("\n"); i += 2; continue + if nxt == "t": + out.append("\t"); i += 2; continue + if nxt == "r": + out.append("\r"); i += 2; continue + if nxt in ('"', "\\"): + out.append(nxt); i += 2; continue + out.append(raw[i]); i += 1 + return "".join(out) + + +def roundtrip_check(entries): + de_tree = lisp_parse_nested( + io.open(OUT_DE, encoding="utf-8").read().splitlines()) + en_tree = lisp_parse_nested( + io.open(OUT_EN, encoding="utf-8").read().splitlines()) + de_sec = de_tree.get("texts", {}) + en_sec = en_tree.get("texts", {}) + assert len(de_sec) == len(entries), \ + f"de_DE: {len(de_sec)} Keys re-geparst, erwartet {len(entries)}" + assert len(en_sec) == len(entries), \ + f"en_GB: {len(en_sec)} Keys re-geparst, erwartet {len(entries)}" + fails = 0 + for key, de_raw, en_raw in entries: + want_de = lisp_runtime_value(de_raw) + want_en = lisp_runtime_value(en_raw) + got_de = lisp_lang_unescape(de_sec.get(key)) + got_en = lisp_lang_unescape(en_sec.get(key)) + if got_de != want_de: + fails += 1 + print(f" DE-DIFF {key!r}: {want_de!r} != {got_de!r}", file=sys.stderr) + if got_en != want_en: + fails += 1 + print(f" EN-DIFF {key!r}: {want_en!r} != {got_en!r}", file=sys.stderr) + if fails: + raise SystemExit(f"Round-Trip fehlgeschlagen: {fails} Abweichungen.") + print("Round-Trip OK: alle Werte identisch nach Re-Parse.") + + +def main(): + text = io.open(SRC, encoding="utf-8").read() + entries = extract_entries(text) + print(f"{len(entries)} Eintraege extrahiert.") + assert len(entries) == 403, f"Erwartet 403, gefunden {len(entries)}" + keys = [k for k, _, _ in entries] + assert len(set(keys)) == len(keys), "Duplikat-Keys gefunden" + assert_only_known_escapes(entries) + + LANG_DIR.mkdir(exist_ok=True) + write_json(OUT_DE, entries, 1) + write_json(OUT_EN, entries, 2) + print(f"Geschrieben: {OUT_DE.relative_to(REPO)}, {OUT_EN.relative_to(REPO)}") + + roundtrip_check(entries) + + +if __name__ == "__main__": + main()