#!/usr/bin/env python3 """Pull educational content out of the GTK C sources into content.json.""" from __future__ import annotations import json import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "src" # Default target is the Android asset; Xcode (iOS / native macOS) passes # `--out` and copies the file into the app bundle itself, so nothing # generated lives under ios/ or macos/. OUTS = [ ROOT / "android" / "app" / "src" / "main" / "assets" / "content.json", ] if "--out" in sys.argv: OUTS = [Path(a) for a in sys.argv[sys.argv.index("--out") + 1:] if a] if not OUTS: sys.exit("usage: extract-android-content.py [--out PATH ...]") OUT = OUTS[0] IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") def strip_comments(src: str) -> str: out = [] i, n = 0, len(src) while i < n: c = src[i] if c == '"' or c == "'": q = c out.append(c) i += 1 while i < n: ch = src[i] out.append(ch) if ch == "\\" and i + 1 < n: out.append(src[i + 1]) i += 2 continue i += 1 if ch == q: break continue if c == "/" and i + 1 < n and src[i + 1] == "/": i += 2 while i < n and src[i] != "\n": i += 1 continue if c == "/" and i + 1 < n and src[i + 1] == "*": i += 2 while i + 1 < n and not (src[i] == "*" and src[i + 1] == "/"): i += 1 i = min(n, i + 2) out.append(" ") continue out.append(c) i += 1 return "".join(out) def unescape(s: str) -> str: out = [] i = 0 while i < len(s): if s[i] == "\\" and i + 1 < len(s): n = s[i + 1] mapping = { "n": "\n", "t": "\t", "r": "\r", "\\": "\\", '"': '"', "'": "'", } if n in mapping: out.append(mapping[n]) i += 2 continue if n == "x": j = i + 2 hexdigits = "" while j < len(s) and s[j] in "0123456789abcdefABCDEF" and len(hexdigits) < 2: hexdigits += s[j] j += 1 out.append(chr(int(hexdigits, 16)) if hexdigits else "x") i = j continue out.append(s[i]) i += 1 return "".join(out) class Tok: def __init__(self, kind: str, value, pos: int): self.kind = kind self.value = value self.pos = pos def tokenize(src: str) -> list[Tok]: toks: list[Tok] = [] i, n = 0, len(src) while i < n: c = src[i] if c.isspace(): i += 1 continue if c == '"': i += 1 raw = [] while i < n: ch = src[i] if ch == "\\" and i + 1 < n: raw.append(ch) raw.append(src[i + 1]) i += 2 continue if ch == '"': i += 1 break raw.append(ch) i += 1 while i < n and src[i].isspace(): i += 1 while i < n and src[i] == '"': i += 1 while i < n: ch = src[i] if ch == "\\" and i + 1 < n: raw.append(ch) raw.append(src[i + 1]) i += 2 continue if ch == '"': i += 1 break raw.append(ch) i += 1 while i < n and src[i].isspace(): i += 1 toks.append(Tok("str", unescape("".join(raw)), i)) continue if c in "{}[],;=": toks.append(Tok(c, c, i)) i += 1 continue if c == "-" and i + 1 < n and src[i + 1].isdigit(): j = i + 1 while j < n and (src[j].isdigit() or src[j] == "."): j += 1 toks.append(Tok("num", int(float(src[i:j])), i)) i = j continue if c.isdigit(): j = i while j < n and (src[j].isdigit() or src[j] == "."): j += 1 text = src[i:j] toks.append(Tok("num", int(float(text)) if "." not in text else int(float(text)), i)) i = j continue if c == "'" and i + 2 < n: j = i + 1 raw = [] while j < n and src[j] != "'": if src[j] == "\\" and j + 1 < n: raw.append(src[j + 1]) j += 2 continue raw.append(src[j]) j += 1 j += 1 toks.append(Tok("num", ord(raw[0]) if raw else 0, i)) i = j continue m = IDENT.match(src, i) if m: name = m.group(0) i = m.end() if name == "NULL": toks.append(Tok("null", None, i)) elif name in ("TRUE", "FALSE"): toks.append(Tok("num", 1 if name == "TRUE" else 0, i)) else: toks.append(Tok("id", name, i)) continue i += 1 return toks def parse_value(toks: list[Tok], i: int): if i >= len(toks): return None, i t = toks[i] if t.kind == "str": return t.value, i + 1 if t.kind == "num": return t.value, i + 1 if t.kind == "null": return None, i + 1 if t.kind == "id": name = t.value i += 1 if i < len(toks) and toks[i].kind == "(": depth = 1 i += 1 while i < len(toks) and depth: if toks[i].kind == "(": depth += 1 elif toks[i].kind == ")": depth -= 1 i += 1 return None, i return {"$ref": name}, i if t.kind == "{": return parse_list(toks, i) return None, i + 1 def parse_list(toks: list[Tok], i: int): assert toks[i].kind == "{" i += 1 items = [] while i < len(toks) and toks[i].kind != "}": if toks[i].kind == ",": i += 1 continue val, i = parse_value(toks, i) items.append(val) if i < len(toks) and toks[i].kind == ",": i += 1 if i < len(toks) and toks[i].kind == "}": i += 1 return items, i ARRAY_RE = re.compile( r"(?:static\s+)?const\s+" r"(?:struct\s+)?(?P[A-Za-z_][A-Za-z0-9_]*)" r"(?:\s+\*|\s+const\s+\*|\s*)\s*" r"(?P[A-Za-z_][A-Za-z0-9_]*)" r"(?:\s*\[[^\]]*\])+\s*=\s*\{", re.M, ) CHARPTR_RE = re.compile( r"(?:static\s+)?const\s+char\s*\*\s*(?:const\s+)?" r"(?P[A-Za-z_][A-Za-z0-9_]*)" r"(?:\s*\[[^\]]*\])+\s*=\s*\{", re.M, ) STRING_ASSIGN_RE = re.compile( r"(?:static\s+)?const\s+char\s*\*\s*" r"(?P[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P\"(?:\\.|[^\"\\])*\"(?:\s*\"(?:\\.|[^\"\\])*\")*)\s*;", re.S, ) FUNC_RE = re.compile( r"^(?:static\s+)?(?:GtkWidget\s*\*|void|gboolean|int|char\s*\*)\s*" r"([A-Za-z_][A-Za-z0-9_]*)\s*\(", re.M, ) def find_arrays(src: str) -> dict[str, tuple[str, list]]: found: dict[str, tuple[str, list]] = {} funcs = [(m.start(), m.group(1)) for m in FUNC_RE.finditer(src)] def func_at(pos: int) -> str | None: current = None for start, name in funcs: if start < pos: current = name else: break if current is None: return None # Only treat as local if the array sits after the function's opening brace # and before the next top-level function. Good enough for this codebase. return current def add(typ: str, name: str, pos_brace: int): toks = tokenize(src[pos_brace:]) if not toks or toks[0].kind != "{": return val, _ = parse_list(toks, 0) fn = func_at(pos_brace) key = f"{fn}.{name}" if fn and name in { "slides", "qs", "about", "world", "people", "terms", "persons", "nouns", "ans", } else name # Prefer the first global, overwrite locals with prefixed names. if key in found and not key.startswith(fn or "\0"): return found[key] = (typ, val) for m in ARRAY_RE.finditer(src): add(m.group("type"), m.group("name"), m.end() - 1) for m in CHARPTR_RE.finditer(src): add("char*", m.group("name"), m.end() - 1) for m in STRING_ASSIGN_RE.finditer(src): toks = tokenize(m.group("body")) text = "".join(t.value for t in toks if t.kind == "str") found[m.group("name")] = ("char", [text]) return found def as_list(v): return v if isinstance(v, list) else [] def as_str(v) -> str | None: if v is None: return None if isinstance(v, str): return v return None def resolve(arrays: dict, val, depth=0): if depth > 8: return val if isinstance(val, dict) and "$ref" in val: name = val["$ref"] if name in arrays: return resolve(arrays, arrays[name][1], depth + 1) return None if isinstance(val, list): return [resolve(arrays, x, depth + 1) for x in val] return val def choice_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 4: continue opts = [o for o in as_list(row[1]) if isinstance(o, str)] correct = row[3] if isinstance(row[3], int) else 0 out.append({ "prompt": as_str(row[0]) or "", "options": opts, "correct": correct, }) return out def typed_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 2: continue out.append({ "prompt": as_str(row[0]) or "", "answers": as_str(row[1]) or "", "meaning": as_str(row[2]) if len(row) > 2 else None, }) return out def assembly_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 2: continue words = [w for w in as_list(row[1]) if isinstance(w, str)] n = row[2] if len(row) > 2 and isinstance(row[2], int) else len(words) out.append({ "prompt": as_str(row[0]), "words": words[:n], }) return out def str_list(rows) -> list[str]: return [x for x in as_list(rows) if isinstance(x, str)] def verb_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 3: continue out.append({ "before": as_str(row[0]) or "", "after": as_str(row[1]) or "", "answer": as_str(row[2]) or "", }) return out def verb_clue_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 4: continue out.append({ "emoji": as_str(row[0]) or "", "before": as_str(row[1]) or "", "after": as_str(row[2]) or "", "answer": as_str(row[3]) or "", }) return out def assign_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 3: continue out.append({ "emoji": as_str(row[0]), "label": as_str(row[1]) or "", "group": row[2] if isinstance(row[2], int) else 0, }) return out def free_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 4: continue out.append({ "question": as_str(row[0]) or "", "qCs": as_str(row[1]) or "", "sample": as_str(row[2]) or "", "aCs": as_str(row[3]) or "", }) return out def profile_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 2: continue out.append({ "stem": as_str(row[0]) or "", "sample": as_str(row[1]) or "", }) return out def slides_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 4: continue lines = [x for x in as_list(row[3]) if isinstance(x, str)] out.append({ "kicker": as_str(row[0]) or "", "title": as_str(row[1]) or "", "tip": as_str(row[2]), "lines": lines, }) return out def fill_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 9: continue segs = [as_str(row[1]), as_str(row[3]), as_str(row[5]), as_str(row[7])] ans = [as_str(row[2]), as_str(row[4]), as_str(row[6])] out.append({ "num": as_str(row[0]), "segs": [s or "" for s in segs], "answers": [a for a in ans if a], "mean": as_str(row[8]), }) return out def let_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 11: continue segs = [as_str(row[i]) or "" for i in (1, 3, 5, 7, 9)] ans = [as_str(row[i]) for i in (2, 4, 6, 8)] out.append({ "num": as_str(row[0]), "segs": segs, "answers": [a for a in ans if a], "mean": as_str(row[10]), }) return out def ex4row_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 6: continue segs = [s for s in as_list(row[0]) if s is None or isinstance(s, str)] ans = [a for a in as_list(row[1]) if isinstance(a, str)] words = [w for w in as_list(row[3]) if isinstance(w, str)] if len(row) > 3 else [] gmean = [g for g in as_list(row[4]) if isinstance(g, str)] if len(row) > 4 else [] gaps = row[5] if len(row) > 5 and isinstance(row[5], int) else len(ans) out.append({ "segs": [s or "" for s in segs], "answers": ans[:gaps], "czech": as_str(row[2]) if len(row) > 2 else None, "words": words[:gaps], "gmean": gmean[:gaps], }) return out def kw_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 2: continue out.append({ "prompt": as_str(row[0]) or "", "answers": as_str(row[1]) or "", "mean": as_str(row[2]) if len(row) > 2 else None, "german": as_str(row[3]) if len(row) > 3 else None, }) return out def mluv_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 5: continue nblank = row[2] if isinstance(row[2], int) else 1 out.append({ "prompt": as_str(row[0]) or "", "shown": as_str(row[1]) or "", "hints": [h for h in as_list(row[3]) if isinstance(h, str)][:nblank], "answers": [a for a in as_list(row[4]) if isinstance(a, str)][:nblank], }) return out def reveal_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 2: continue out.append({ "prompt": as_str(row[0]) or "", "solution": as_str(row[1]) or "", }) return out def lit_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 4: continue opts = [o for o in as_list(row[1]) if isinstance(o, str)] out.append({ "prompt": as_str(row[0]) or "", "options": opts, "correct": row[2] if isinstance(row[2], int) else 0, "expl": as_str(row[3]), }) return out def trans_sections(rows, arrays) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 2: continue data = resolve(arrays, row[1]) out.append({ "header": as_str(row[0]) or "", "rows": typed_items(data), }) return out def dialogs(arrays) -> list[dict]: out = [] for key in ("ex1_d1", "ex1_d2", "ex1_d3"): if key not in arrays: continue rows = [] for row in as_list(arrays[key][1]): row = as_list(row) if len(row) < 4: continue rows.append({ "speaker": as_str(row[0]) or "", "before": as_str(row[1]) or "", "answer": as_str(row[2]) or "", "after": as_str(row[3]) or "", }) titles = {"ex1_d1": "Sich vorstellen", "ex1_d2": "Begrüßung", "ex1_d3": "Verabschiedung"} out.append({"name": titles[key], "rows": rows}) return out def ordne_items(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 3: continue out.append({ "mid": as_str(row[0]) or "", "fw": as_str(row[1]) or "", "ans": as_str(row[2]) or "", }) return out def ex2_items(arrays) -> list[dict]: out = [] if "ex2_u3_items" not in arrays: return out for row in as_list(arrays["ex2_u3_items"][1]): row = as_list(row) if len(row) < 2: continue raw_rows = resolve(arrays, row[1]) parsed = [] for r in as_list(raw_rows): r = as_list(r) if len(r) < 4: continue segs = [s or "" for s in as_list(r[1]) if s is None or isinstance(s, str)] ans = [a for a in as_list(r[2]) if isinstance(a, str)] gaps = r[3] if isinstance(r[3], int) else len(ans) parsed.append({ "num": as_str(r[0]), "segs": segs, "answers": ans[:gaps], }) out.append({"czech": as_str(row[0]) or "", "rows": parsed}) return out def net_ans(rows) -> list[list[dict]]: out = [] for block in as_list(rows): block = as_list(block) inner = [] for row in block: row = as_list(row) if len(row) < 5: continue inner.append({ "pfx": row[0] if isinstance(row[0], int) else -1, "net": as_str(row[1]), "bcast": as_str(row[2]), "lo": as_str(row[3]), "hi": as_str(row[4]), }) out.append(inner) return out def net_tasks(rows) -> list[dict]: out = [] for row in as_list(rows): row = as_list(row) if len(row) < 2: continue out.append({"prompt": as_str(row[0]) or "", "solution": as_str(row[1]) or ""}) return out def parse_changelog(path: Path) -> list[dict]: if not path.is_file(): return [] entries: list[dict] = [] current = None for raw in path.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#"): continue if line.startswith("---"): current = {"date": line[3:].strip(), "cs": [], "en": []} entries.append(current) continue if current is None: continue if line.startswith("cs:"): current["cs"].append(line[3:].strip()) elif line.startswith("en:"): current["en"].append(line[3:].strip()) return [e for e in entries if e["cs"] or e["en"]] def parse_i18n(src: str) -> dict: arrays = find_arrays(src) out = {} for name in ("tr_ui", "tr_content"): if name not in arrays: continue for row in as_list(arrays[name][1]): row = as_list(row) if not row or not as_str(row[0]): continue key = as_str(row[0]) cs = as_str(row[1]) if len(row) > 1 else None en = as_str(row[2]) if len(row) > 2 else None if key: out[key] = {"cs": cs, "en": en} return out def combo(title, sub, pool, rows, meanings=None, kind="combo", **extra): spec = { "type": kind, "title": title, "sub": sub, "pool": pool or [], "rows": rows, "meanings": meanings or [], } spec.update(extra) return spec def main() -> int: arrays: dict[str, tuple[str, list]] = {} i18n = {} for path in sorted(SRC.glob("*.c")): text = strip_comments(path.read_text(encoding="utf-8")) arrays.update(find_arrays(text)) if path.name == "i18n.c": i18n = parse_i18n(text) def A(name): if name not in arrays: return [] return resolve(arrays, arrays[name][1]) german_units = [ { "id": 0, "title": "Neue Freunde", "page": "unit1", "tag": "u1", "sub": "unit1_sub", "unlocked": True, "names": [ "Dialog", "Sätze bilden", "Was ist richtig?", "Freie Antwort", "Zahlen", "Wie viel?", "Zahlenreihe", "Verb einsetzen", "Wer? Wie? Wo?", "Wörter trennen", "Grußformen", "Was macht er/sie?", "Länder", ], "branch": "u1vocab", "exercises": { "1": combo("Dialog", "sub_dialog", str_list(A("ex1_pool")), [], str_list(A("ex1_meaning")), "dialog", dialogues=dialogs(arrays)), "2": { "type": "assembly", "title": "Sätze bilden", "sub": "sub_assembly", "items": assembly_items(A("ex2_items")), "meanings": str_list(A("ex2_meaning")), }, "3": { "type": "choice", "title": "Was ist richtig?", "sub": "sub_choice_num", "questions": choice_items(A("ex3_questions")), "meanings": str_list(A("ex3_meaning")), }, "4": { "type": "free", "title": "Freie Antwort", "sub": "sub_free", "tip": "tip_ss", "questions": free_items(A("ex4_questions")), }, "5": combo("Zahlen", "sub_zahlen", str_list(A("ex5_pool")), [{"digits": as_list(r)[0], "answer": as_list(r)[1]} for r in as_list(A("ex5_data")) if len(as_list(r)) >= 2], str_list(A("ex5_meaning")), "number"), "6": combo("Wie viel?", "sub_wieviel", str_list(A("ex6_pool")), [{"emoji": as_list(r)[0], "count": as_list(r)[1], "noun": as_list(r)[2], "answer": as_list(r)[3]} for r in as_list(A("ex6_data")) if len(as_list(r)) >= 4], str_list(A("ex6_meaning")), "count"), "7": combo("Zahlenreihe", "sub_reihe", str_list(A("ex7_pool")), [{"before": as_list(r)[0], "after": as_list(r)[1], "answer": as_list(r)[2]} for r in as_list(A("ex7_data")) if len(as_list(r)) >= 3], str_list(A("ex7_meaning")), "seq"), "8": combo("Verb einsetzen", "sub_verb", str_list(A("ex8_pool")), verb_items(A("ex8_data")), str_list(A("ex8_meaning")), "verb"), "9": { "type": "choice", "title": "Wer? Wie? Wo?", "sub": "sub_wer", "questions": choice_items(A("ex9_questions")), "meanings": str_list(A("ex9_meaning")), }, "10": { "type": "assembly", "title": "Wörter trennen", "sub": "sub_assembly", "items": assembly_items(A("ex10_items")), "meanings": str_list(A("ex10_meaning")), }, "11": { "type": "assign", "title": "Grußformen", "sub": "sub_gruss", "items": assign_items(A("ex11_items")), "groups": str_list(A("ex11_groups")), "meanings": str_list(A("ex11_meaning")), }, "12": combo("Was macht er/sie?", "sub_bild", str_list(A("ex12_pool")), verb_clue_items(A("ex12_data")), str_list(A("ex12_meaning")), "verbclue"), "13": { "type": "assign", "title": "Länder", "sub": "sub_land", "items": assign_items(A("ex13_items")), "groups": str_list(A("ex13_groups")), "meanings": str_list(A("ex13_meaning")), }, }, "vocab": { "type": "vocab", "title": "Vokabeltraining", "sub": "sub_translate", "sections": trans_sections(A("u1_trans_sections"), arrays), }, }, { "id": 1, "title": "Aus aller Welt", "page": "unit2", "tag": "u2", "sub": "unit2_sub", "unlocked": True, "names": [ "Verben konjugieren", "aus oder in", "Fragewörter", "sprechen", "Nationalitäten", "Woher?", "Länder schreiben", "Verben einsetzen", "Freie Antwort", "Euro", "Wörter suchen", "Was ist richtig?", "Ordne zu", "Lückentext", "Verbinde", "Zahlen", "Steckbrief", "Berufe", "Nationalität", ], "branch": "u2vocab", "exercises": { "1": combo("Verben konjugieren", "sub_verben", str_list(A("g01_pool")), [], [], "verb_sections", sections=[ {"title": "Peter Fritsch:", "rows": verb_items(A("g01_peter")), "meanings": str_list(A("g01_peter_mean"))}, {"title": "Jana Nová und Pavol Korčák:", "rows": verb_items(A("g01_jana")), "meanings": str_list(A("g01_jana_mean"))}, ], transUpfront=True, revealGerman=True), "2": { "type": "choice", "title": "aus oder in", "sub": "sub_ausin", "questions": choice_items(A("g02_rows")), "meanings": str_list(A("g02_mean")), }, "3": { "type": "choice", "title": "Fragewörter", "sub": "sub_wer", "questions": choice_items(A("g03_rows")), "meanings": str_list(A("g03_mean")), }, "4": combo("sprechen", "sub_sprich", str_list(A("g04_pool")), verb_items(A("g04_rows")), str_list(A("g04_mean")), "verb"), "5": { "type": "typed", "title": "Nationalitäten", "sub": "sub_nation", "rows": typed_items(A("g05_rows")), "bank": as_str(A("g05_bank")[0]) if A("g05_bank") else (as_str(arrays["g05_bank"][1][0]) if "g05_bank" in arrays and arrays["g05_bank"][1] and isinstance(arrays["g05_bank"][1][0], str) else None), "answersNote": False, }, "6": { "type": "typed", "title": "Woher?", "sub": "sub_woher", "rows": typed_items(A("g06_rows")), }, "7": { "type": "typed", "title": "Länder schreiben", "sub": "sub_laender", "rows": typed_items(A("g07_rows")), }, "8": combo("Verben einsetzen", "sub_verb2", str_list(A("g08_pool")), verb_items(A("g08_rows")), str_list(A("g08_mean")), "verb"), "9": { "type": "free", "title": "Freie Antwort", "sub": "sub_free", "tip": "tip_ss", "questions": free_items(A("ex9_free_qs")), }, "10": combo("Euro", "sub_euro", str_list(A("g10_pool")), verb_items(A("g10_rows")), str_list(A("g10_mean")), "verb", preMeaning=True), "11": { "type": "typed", "title": "Wörter suchen", "sub": "sub_wortsuchen", "rows": typed_items(A("g11_rows")), }, "12": { "type": "choice", "title": "Was ist richtig?", "sub": "sub_verb2", "questions": choice_items(A("g12_rows")), "meanings": str_list(A("g12_mean")), }, "13": combo("Ordne zu", "sub_ordne", [], ordne_items(A("g13_rows")), str_list(A("g13_mean")), "ordne", fwPool=str_list(A("g13_fw_pool")), ansPool=str_list(A("g13_ans_pool"))), "14": combo("Lückentext", "sub_luecke", str_list(A("g14_pool")), verb_items(A("g14_rows")), str_list(A("g14_mean")), "verb", preMeaning=True, gloss=str_list(A("g14_gloss"))), "15": combo("Verbinde", "sub_verbinde", str_list(A("g15_pool")), verb_items(A("g15_rows")), str_list(A("g15_mean")), "verb", preMeaning=True, gloss=str_list(A("g15_gloss"))), "16": combo("Zahlen", "sub_zahlpaar", str_list(A("g16_pool")), verb_items(A("g16_rows")), str_list(A("g16_mean")), "verb"), "17": { "type": "profile", "title": "Steckbrief", "sub": "sub_steckbrief", "questions": profile_items(A("s01_rows")), }, "18": { "type": "hangman", "title": "Berufe", "sub": "sub_berufe", "words": str_list(A("hm_words")), "tips": str_list(A("hm_tips")), "letters": str_list(A("hm_letters")), }, "19": { "type": "typed", "title": "Nationalität", "sub": "sub_bistdu", "rows": typed_items(A("s03_rows")), "answersNote": True, }, }, "vocab": { "type": "vocab", "title": "Vokabeltraining", "sub": "sub_translate", "sections": trans_sections(A("u2_trans_sections"), arrays), }, }, { "id": 2, "title": "Bei uns zu Hause", "page": "unit3", "tag": "u3", "sub": "unit3_sub", "unlocked": True, "names": [ "Familienpaare", "mein oder dein", "ein / kein", "Lückentext", "Marcos Familie", "Sortieren", "Possessivtabelle", "Akkusativ", "kein / nicht", "Sätze bauen", "Was siehst du?", "Wem gehört das?", "Es gibt …", "Beschreiben", "Wochenende", ], "branch": "u3vocab", "exercises": { "1": combo("Familienpaare", "sub3_pair", str_list(A("ex1_u3_pool")), fill_items(A("ex1_u3_rows")), [], "fill", sample="Beispiel: der Vater ↔ die Mutter", showGerman=False), "2": { "type": "ex2", "title": "mein oder dein", "sub": "sub3_poss", "pool": str_list(A("ex2_u3_pool")), "items": ex2_items(arrays), }, "3": combo("ein / kein", "sub3_haustier", str_list(A("ex3_u3_pool")), fill_items(A("ex3_u3_rows")), [], "fill", sample="Beispiel: Hast du eine Katze? – Nein, ich habe keine Katze. Ich habe einen Hund.", showGerman=True), "4": { "type": "letters_gap", "title": "Lückentext", "sub": "sub3_buchst", "rows": ex4row_items(A("ex4_u3_rows")), }, "5": { "type": "typed", "title": "Marcos Familie", "sub": "sub3_marco", "rows": typed_items(A("ex5_u3_rows")), "bank": None, }, "6": { "type": "assign", "title": "Sortieren", "sub": "sub3_sort", "items": assign_items(A("ex6_u3_items")), "groups": str_list(A("ex6_u3_groups")), "meanings": str_list(A("ex6_u3_meaning")), }, "7": { "type": "table", "title": "Possessivtabelle", "sub": "sub3_tabelle", "persons": [ "ich", "du", "er", "sie", "es", "wir", "ihr", "sie / Sie", ], "nouns": ["der Garten", "die Idee", "das Fest", "die Geschwister"], "answers": [ ["mein", "dein", "sein", "ihr", "sein", "unser", "euer", "ihr"], ["meine", "deine", "seine", "ihre", "seine", "unsere", "eure", "ihre"], ["mein", "dein", "sein", "ihr", "sein", "unser", "euer", "ihr"], ["meine", "deine", "seine", "ihre", "seine", "unsere", "eure", "ihre"], ], }, "8": combo("Akkusativ", "sub3_akk", str_list(A("ex8_u3_pool")), fill_items(A("ex8_u3_rows")), str_list(A("ex8_u3_gloss")), "fill", showGerman=True), "9": combo("kein / nicht", "sub3_nicht", str_list(A("ex9_u3_pool")), fill_items(A("ex9_u3_rows")), [], "fill", showGerman=True), "10": { "type": "assembly", "title": "Sätze bauen", "sub": "sub3_satz", "items": assembly_items(A("ex10_u3_items")), "meanings": str_list(A("ex10_u3_meaning")), }, "11": { "type": "kw", "title": "Was siehst du?", "sub": "sub3_sehen", "sample": "Beispiel: Ich sehe eine Burg. / Ich sehe ein Schloss.", "rows": kw_items(A("ex11_u3_qs")), }, "12": combo("Wem gehört das?", "sub3_wem", str_list(A("ex12_u3_pool")), fill_items(A("ex12_u3_rows")), [], "fill", showGerman=True), "13": combo("Es gibt …", "sub3_gibt", str_list(A("ex13_u3_pool")), fill_items(A("ex13_u3_rows")), [], "fill", showGerman=True), "14": { "type": "kw", "title": "Beschreiben", "sub": "sub3_saetze", "sample": "Beispiel: Ich sehe einen Jungen. Er liest ein Buch.", "rows": kw_items(A("ex14_u3_qs")), }, "15": { "type": "letters", "title": "Wochenende", "sub": "sub3_wochen", "rows": let_items(A("ex15_u3_rows")), }, }, "vocab": { "type": "vocab", "title": "Vokabeltraining", "sub": "sub_translate", "sections": trans_sections(A("u3_trans_sections"), arrays), }, }, ] locked_german = [ "Schule und Freizeit", "Guten Appetit!", "Mein Tagesablauf", "Meine Freunde", "Wir treffen uns in Salzburg", "Mein Haus ist meine Burg", "Urlaub in Österreich", ] for i, title in enumerate(locked_german, start=3): german_units.append({ "id": i, "title": title, "page": None, "tag": None, "sub": None, "unlocked": False, "names": [], "exercises": {}, }) # g05_bank is a single string, not an array of strings if "g05_bank" in arrays: raw = arrays["g05_bank"][1] bank = None if isinstance(raw, list) and raw and isinstance(raw[0], str) and len(raw) == 1: bank = raw[0] elif isinstance(raw, str): bank = raw if bank: german_units[1]["exercises"]["5"]["bank"] = bank if "ex5_u3_bank" in arrays: raw = arrays["ex5_u3_bank"][1] if isinstance(raw, list) and raw and isinstance(raw[0], str): german_units[2]["exercises"]["5"]["bank"] = raw[0] if len(raw) == 1 else " ".join( x for x in raw if isinstance(x, str) ) net_lessons = [] for i in range(1, 28): slides = slides_items(A(f"build_net_unit{i}_page.slides")) qs = choice_items(A(f"net{i}_qs")) if i > 1 else [] net_lessons.append({ "id": i, "titleKey": f"net_unit{i}", "subKey": f"net_unit{i}_sub", "exTitleKey": f"net_ex{i}_title" if i > 1 else "net_ex_title", "quizHeadKey": f"net_quiz{i}_head" if i > 1 else None, "slides": slides, "quiz": qs, }) hw_lessons = [] for i in range(1, 4): hw_lessons.append({ "id": i, "titleKey": f"hw_unit{i}", "subKey": f"hw_unit{i}_sub", "exTitleKey": f"hw_ex{i}_title", "quizHeadKey": f"hw_quiz{i}_head", "slides": slides_items(A(f"build_hw_unit{i}_page.slides")), "quiz": choice_items(A(f"build_hw_unit{i}_exercise_page.qs")), }) mluv_meta = [ (1, "typed", "Pravopis – i/y po obojetných souhláskách", "Doplňte i/í nebo y/ý.", None, "e1_items"), (2, "choice", "Pravopis – velká/malá písmena", "Vyberte správnou variantu.", None, "e2_qs"), (3, "typed", "Slovní druhy", "Určete slovní druh podtržených slov.", "Do pole napište název slovního druhu.", "e3_items"), (4, "typed", "Mluvnické kategorie – podstatná jména", "Určete rod, číslo a pád podtržených slov.", "Vyplňte tři pole: rod, číslo a pád.", "e4_items"), (5, "typed", "Mluvnické kategorie – slovesa", "Určete osobu, číslo, čas a způsob u těchto sloves.", "U rozkazovacího a podmiňovacího způsobu napište do pole čas „neurčitý“.", "e5_items"), (6, "reveal", "Slovotvorba", "Rozeberte slova na morfémy.", "Určete předponu, kořen, příponu a koncovku.", "e6_items"), (7, "typed", "Větné členy", "Určete větný člen podtržených výrazů.", None, "e7_items"), (8, "typed", "Druhy vedlejších vět", "Určete druh vedlejší věty.", None, "e8_items"), (9, "typed", "Synonyma", "Ke každému slovu napište synonymum.", None, "e9_items"), (10, "typed", "Antonyma", "Napište opak.", None, "e10_items"), (11, "typed", "Pravopis – s/z na začátku slova", "Vyberte správnou předponu s-/z-.", None, "e11_items"), (12, "reveal", "Skladba – najděte chybu", "Každá věta obsahuje jednu chybu. Najděte ji a opravte.", None, "e12_items"), (13, "typed", "Tvarosloví – správný tvar", "Dejte slovo do správného tvaru.", None, "e13_items"), (14, "choice", "Výběr ze čtyř možností", "Vyberte správnou odpověď.", None, "e14_qs"), (15, "reveal", "Přímá a nepřímá řeč", "Přepište přímou řeč na nepřímou.", None, "e15_items"), (16, "reveal", "Slovní zásoba – rozdíly", "Vysvětlete rozdíl mezi těmito dvojicemi slov.", None, "e16_items"), (17, "reveal", "Interpunkce", "Doplňte čárky tam, kde patří.", None, "e17_items"), (18, "typed", "Obrazná pojmenování", "Určete, o jaký druh obrazného pojmenování jde.", None, "e18_items"), (19, "typed", "Stylistika – slohové útvary", "Přiřaďte ukázku ke správnému slohovému útvaru.", None, "e19_items"), (20, "choice", "Souhrnné opakování", "Vyberte správnou odpověď.", None, "e20_qs"), ] mluv_names = [ "i/y", "Velká písmena", "Slovní druhy", "Podst. jména", "Slovesa", "Slovotvorba", "Větné členy", "Vedlejší věty", "Synonyma", "Antonyma", "s/z", "Najdi chybu", "Tvary", "Test A–D", "Přímá řeč", "Rozdíly", "Interpunkce", "Obrazná pojmen.", "Slohové útvary", "Opakování", ] mluvnice = [] for n, kind, title, sub, note, key in mluv_meta: item = {"id": n, "name": mluv_names[n - 1], "type": kind, "title": title, "sub": sub, "note": note} if kind == "typed": item["items"] = mluv_items(A(key)) elif kind == "reveal": item["items"] = reveal_items(A(key)) else: item["questions"] = choice_items(A(key)) expl_key = key.replace("_qs", "_expl") if expl_key in arrays: item["expls"] = str_list(A(expl_key)) mluvnice.append(item) def note_lines(fn, name): return str_list(A(f"{fn}.{name}")) catalog_path = ROOT / "data" / "cetba-catalog.json" catalog = json.loads(catalog_path.read_text(encoding="utf-8")) full_books = { "1984": { "subKey": "cetba1984_sub", "page": "cetba1984", "quizTitle": "lit_quiz_title", "quizSub": "lit_quiz_sub", "plotTitle": "lit_plot_title", "plotSub": "lit_plot_sub", "notes": [ {"title": "O knize", "icon": "book", "lines": note_lines("build_cetba1984_page", "about")}, {"title": "Svět a strana", "icon": "globe", "lines": note_lines("build_cetba1984_page", "world")}, {"title": "Postavy", "icon": "people", "lines": note_lines("build_cetba1984_page", "people")}, {"title": "Klíčové pojmy", "icon": "bulb", "lines": note_lines("build_cetba1984_page", "terms")}, ], "quiz": lit_items(A("lit_qs")), "plot": assembly_items(A("lit_plot_items")), "plotMeaning": str_list(A("lit_plot_meaning")), }, "fuks": { "subKey": "cetbaFuks_sub", "page": "cetbaFuks", "quizTitle": "lit_fuks_quiz_title", "quizSub": "lit_fuks_quiz_sub", "plotTitle": "lit_fuks_plot_title", "plotSub": "lit_fuks_plot_sub", "notes": [ {"title": "O knize", "icon": "book", "lines": note_lines("build_cetba_fuks_page", "about")}, {"title": "Doba a svět", "icon": "globe", "lines": note_lines("build_cetba_fuks_page", "world")}, {"title": "Postavy", "icon": "people", "lines": note_lines("build_cetba_fuks_page", "people")}, {"title": "Klíčové motivy", "icon": "bulb", "lines": note_lines("build_cetba_fuks_page", "terms")}, ], "quiz": lit_items(A("fuks_qs")), "plot": assembly_items(A("fuks_plot_items")), "plotMeaning": str_list(A("fuks_plot_meaning")), }, } books = [] for entry in catalog: bid = entry["id"] display = f"{entry['author']} – {entry['title']}" page = entry.get("page") or f"cetba_{bid.replace('-', '_')}" if bid in full_books: book = {"id": bid, "title": display, **full_books[bid]} book["genre"] = entry.get("genre", "") books.append(book) continue about = [f"Autor: {entry['author']}", f"Žánr: {entry['genre']}"] if entry.get("translator"): about.append(f"Překlad: {entry['translator']}") about.append("Ze školního seznamu maturitní četby.") books.append({ "id": bid, "title": display, "subKey": "cetba_entry_sub", "page": page, "genre": entry["genre"], "quizTitle": "", "quizSub": "", "plotTitle": "", "plotSub": "", "notes": [ {"title": "O knize", "icon": "book", "lines": about}, ], "quiz": [], "plot": [], "plotMeaning": [], }) # GTK catalog include for the reading-list path map inc_lines = [ "/* Generated from data/cetba-catalog.json – do not edit by hand. */", f"#define BOOK_NODES {len(catalog)}", "", "typedef struct {", " const char *title;", " const char *target;", " const char *author;", " const char *work;", " const char *genre;", " const char *translator;", " int full;", "} BookDef;", "", "static const BookDef book_defs[BOOK_NODES] = {", ] for entry in catalog: bid = entry["id"] display = f"{entry['author']} – {entry['title']}" page = entry.get("page") or f"cetba_{bid.replace('-', '_')}" if entry.get("full"): target = entry.get("page") or page full = 1 else: target = "cetbastub" full = 0 transl = entry.get("translator") or "" def c_str(s: str) -> str: return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' transl_c = "NULL" if not transl else c_str(transl) inc_lines.append( f" {{{c_str(display)}, {c_str(target)}, {c_str(entry['author'])}, " f"{c_str(entry['title'])}, {c_str(entry['genre'])}, {transl_c}, {full}}}," ) inc_lines.append("};") inc_lines.append("") (SRC / "cetba_catalog.inc").write_text("\n".join(inc_lines) + "\n", encoding="utf-8") subjects = [ {"key": "Deutsch", "open": True, "target": "roadmap", "icon": "de"}, {"key": "Správa počítačových sítí", "open": True, "target": "netyears", "icon": "wifi"}, {"key": "Technické vybavení", "open": True, "target": "hwmap", "icon": "chip"}, {"key": "Český jazyk a literatura", "open": True, "target": "czechmap", "icon": "cz"}, {"key": "Občanská nauka", "open": False, "target": None, "icon": "lock"}, {"key": "English", "open": False, "target": None, "icon": "lock"}, {"key": "Matematika", "open": False, "target": None, "icon": "lock"}, {"key": "Fyzika", "open": False, "target": None, "icon": "lock"}, {"key": "Základy Přírodopisných věd", "open": False, "target": None, "icon": "lock"}, {"key": "Technická grafika", "open": False, "target": None, "icon": "lock"}, {"key": "Prezentační grafika", "open": False, "target": None, "icon": "lock"}, {"key": "Programování", "open": False, "target": None, "icon": "lock"}, {"key": "Programové vybavení", "open": False, "target": None, "icon": "lock"}, ] payload = { "i18n": i18n, "subjects": subjects, "german": german_units, "net": { "lessons": net_lessons, "tasks": net_tasks(A("net_tasks")), "answers": net_ans(A("net_ans")), }, "hw": hw_lessons, "mluvnice": mluvnice, "books": books, "changelog": parse_changelog(ROOT / "data" / "changelog.txt"), } # Fix single-string banks stored as char* "arrays" for key in ("g05_bank", "ex5_u3_bank"): if key not in arrays: continue raw = arrays[key][1] if isinstance(raw, list) and len(raw) == 1 and isinstance(raw[0], str) and " " in raw[0]: if key == "g05_bank": german_units[1]["exercises"]["5"]["bank"] = raw[0] else: german_units[2]["exercises"]["5"]["bank"] = raw[0] text = json.dumps(payload, ensure_ascii=False, indent=2) for dest in OUTS: dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(text, encoding="utf-8") def count(path, pred): node = payload for p in path: node = node[p] return sum(1 for x in node if pred(x)) print("wrote " + ", ".join(str(dest) for dest in OUTS)) print(f" i18n keys: {len(i18n)}") print(f" german unlocked exercises: " f"{sum(len(u.get('exercises', {})) for u in german_units if u['unlocked'])}") print(f" net lessons with slides: {sum(1 for L in net_lessons if L['slides'])}") print(f" net quizzes: {sum(1 for L in net_lessons if L['quiz'])}") print(f" hw lessons: {sum(1 for L in hw_lessons if L['slides'])}") print(f" mluvnice: {len(mluvnice)}") print(f" books quiz items: {[len(b['quiz']) for b in books]}") print(f" changelog entries: {len(payload.get('changelog', []))}") missing = [] for u in german_units: if not u["unlocked"]: continue for k, ex in u["exercises"].items(): t = ex["type"] if t == "choice" and not ex.get("questions"): missing.append(f"{u['tag']}e{k} choice") if t == "typed" and not ex.get("rows"): missing.append(f"{u['tag']}e{k} typed") if t == "assembly" and not ex.get("items"): missing.append(f"{u['tag']}e{k} assembly") if t == "vocab" and not ex.get("sections"): missing.append(f"{u['tag']} vocab") if t == "fill" and not ex.get("rows"): missing.append(f"{u['tag']}e{k} fill") if t == "hangman" and not ex.get("words"): missing.append(f"{u['tag']}e{k} hangman") if u.get("vocab") and not u["vocab"].get("sections"): missing.append(f"{u['tag']} vocab sections") for L in net_lessons: if not L["slides"]: missing.append(f"net{L['id']} slides") if L["id"] > 1 and not L["quiz"]: missing.append(f"net{L['id']} quiz") for L in hw_lessons: if not L["slides"]: missing.append(f"hw{L['id']} slides") if not L["quiz"]: missing.append(f"hw{L['id']} quiz") for m in mluvnice: if m["type"] in ("typed", "reveal") and not m.get("items"): missing.append(f"mluv{m['id']}") if m["type"] == "choice" and not m.get("questions"): missing.append(f"mluv{m['id']}") if missing: print("MISSING:") for m in missing: print(" ", m) return 1 return 0 if __name__ == "__main__": sys.exit(main())