Graduately/scripts/build-content.py
pepaskopec-blip 264f4a73d8 Open software as four years from Windows and Office to policy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 19:25:48 +02:00

1195 lines
43 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Build content.json from the Markdown files in content/.
The format is described in content/README.md. Every platform reads the
generated JSON; nothing under content/ is read at run time.
"""
from __future__ import annotations
import json
import re
import sys
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
def _force_utf8_stdio() -> None:
"""Windows defaults stdout to cp1252, which cannot print Czech logs."""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None:
continue
try:
reconfigure(encoding="utf-8", errors="replace")
except (OSError, ValueError):
pass
_force_utf8_stdio()
ROOT = Path(__file__).resolve().parents[1]
CONTENT = ROOT / "content"
OUTS = [ROOT / "android" / "app" / "src" / "main" / "assets" / "content.json"]
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class ContentError(Exception):
pass
ERRORS: list[str] = []
WARNINGS: list[str] = []
def rel(path: Path) -> str:
try:
return str(path.relative_to(ROOT))
except ValueError:
return str(path)
def fail(path: Path, line: int | None, msg: str) -> None:
where = f"{rel(path)}:{line}" if line else rel(path)
ERRORS.append(f"{where}: {msg}")
def warn(path: Path, line: int | None, msg: str) -> None:
where = f"{rel(path)}:{line}" if line else rel(path)
WARNINGS.append(f"{where}: {msg}")
# ---------------------------------------------------------------------------
# Markdown subset
# ---------------------------------------------------------------------------
@dataclass
class Node:
kind: str # h1 h2 h3 bullet num quote answer table para
text: str
line: int
checked: bool = False
rows: list[list[str]] = field(default_factory=list)
lines: list[str] = field(default_factory=list)
@dataclass
class Doc:
path: Path
meta: dict[str, str]
meta_lines: dict[str, int]
nodes: list[Node]
BULLET = re.compile(r"^- (\[([ xX])\] )?(.*)$")
NUMBERED = re.compile(r"^(\d+)\. (.*)$")
TABLE_SEP = re.compile(r"^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$")
def parse_doc(path: Path) -> Doc:
raw = path.read_text(encoding="utf-8").replace("\r\n", "\n")
lines = raw.split("\n")
meta: dict[str, str] = {}
meta_lines: dict[str, int] = {}
i = 0
if lines and lines[0].strip() == "---":
i = 1
while i < len(lines) and lines[i].strip() != "---":
ln = lines[i]
if ln.strip() and not ln.lstrip().startswith("#"):
key, sep, value = ln.partition(":")
if not sep:
fail(path, i + 1, f"v hlavičce chybí dvojtečka: {ln!r}")
else:
meta[key.strip()] = value.strip()
meta_lines[key.strip()] = i + 1
i += 1
if i >= len(lines):
fail(path, 1, "hlavička začíná --- , ale nikde nekončí")
i += 1
nodes: list[Node] = []
para: Node | None = None
table: Node | None = None
in_comment = False
indexes = iter(range(i, len(lines)))
for n in indexes:
ln = lines[n].rstrip()
no = n + 1
if ln.startswith("```"):
para = table = None
body: list[str] = []
for n2 in indexes:
raw_line = lines[n2]
if raw_line.rstrip().startswith("```"):
break
body.append(raw_line)
nodes.append(Node("code", "\n".join(body).strip("\n"), no))
continue
if in_comment:
if "-->" in ln:
in_comment = False
continue
if ln.lstrip().startswith("<!--"):
if "-->" not in ln:
in_comment = True
para = table = None
continue
if not ln.strip():
para = table = None
continue
if ln.startswith("|"):
para = None
if table is None:
table = Node("table", "", no)
nodes.append(table)
if not TABLE_SEP.match(ln):
table.rows.append(split_row(ln))
continue
table = None
if ln.startswith("\\"):
text = ln[1:]
if para is None:
para = Node("para", "", no)
nodes.append(para)
para.lines.append(text)
continue
m = re.match(r"^(#{1,3}) (.*)$", ln) or re.match(r"^(#{1,3})$", ln)
if m:
para = None
nodes.append(Node(f"h{len(m.group(1))}", (m.group(2) if m.lastindex and m.lastindex > 1 else "").strip(), no))
continue
m = BULLET.match(ln)
if m:
para = None
nodes.append(Node("bullet", m.group(3), no, checked=bool(m.group(2)) and m.group(2).lower() == "x"))
continue
m = NUMBERED.match(ln)
if m:
para = None
nodes.append(Node("num", m.group(2), no))
continue
if ln.startswith("> ") or ln == ">":
para = None
nodes.append(Node("quote", ln[2:], no))
continue
if ln.startswith("= ") or ln == "=":
para = None
nodes.append(Node("answer", ln[2:], no))
continue
if para is None:
para = Node("para", "", no)
nodes.append(para)
para.lines.append(ln)
return Doc(path, meta, meta_lines, nodes)
def split_row(line: str) -> list[str]:
s = line.strip()
if s.startswith("|"):
s = s[1:]
if s.endswith("|") and not s.endswith("\\|"):
s = s[:-1]
cells: list[str] = []
cur: list[str] = []
i = 0
while i < len(s):
c = s[i]
if c == "\\" and i + 1 < len(s):
cur.append(s[i:i + 2])
i += 2
continue
if c == "|":
cells.append("".join(cur).strip())
cur = []
else:
cur.append(c)
i += 1
cells.append("".join(cur).strip())
return cells
def unesc(s: str) -> str:
out = []
i = 0
while i < len(s):
if s[i] == "\\" and i + 1 < len(s):
out.append("\n" if s[i + 1] == "n" else s[i + 1])
i += 2
continue
out.append(s[i])
i += 1
return "".join(out)
def ungap(s: str) -> tuple[list[str], list[str]]:
"""Split 'a [x] b [y] c' into segments ['a ', ' b ', ' c'] and answers ['x', 'y']."""
segs: list[str] = []
answers: list[str] = []
cur: list[str] = []
in_gap = False
i = 0
while i < len(s):
c = s[i]
if c == "\\" and i + 1 < len(s):
cur.append("\n" if s[i + 1] == "n" else s[i + 1])
i += 2
continue
if c == "[" and not in_gap:
segs.append("".join(cur))
cur = []
in_gap = True
elif c == "]" and in_gap:
answers.append("".join(cur))
cur = []
in_gap = False
else:
cur.append(c)
i += 1
if in_gap:
raise ContentError(f"neuzavřená mezera [ ... ] v {s!r}")
segs.append("".join(cur))
return segs, answers
def norm(s: str) -> str:
s = unicodedata.normalize("NFKD", s.replace("ß", "ss"))
s = "".join(c for c in s if not unicodedata.combining(c))
return re.sub(r"\s+", " ", s).strip().lower()
def slugify(s: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", norm(s)).strip("-")
# ---------------------------------------------------------------------------
# Structure helpers
# ---------------------------------------------------------------------------
@dataclass
class Part:
title: str
line: int
nodes: list[Node]
def items(self) -> list["Part"]:
"""Split on ## headings. Nodes before the first ## are dropped."""
out: list[Part] = []
for nd in self.nodes:
if nd.kind == "h2":
out.append(Part(nd.text, nd.line, []))
elif out:
out[-1].nodes.append(nd)
return out
def head(self) -> list[Node]:
out = []
for nd in self.nodes:
if nd.kind == "h2":
break
out.append(nd)
return out
def subparts(self) -> list["Part"]:
out: list[Part] = []
for nd in self.nodes:
if nd.kind == "h3":
out.append(Part(nd.text, nd.line, []))
elif out:
out[-1].nodes.append(nd)
return out
def parts(doc: Doc) -> list[Part]:
out: list[Part] = []
for nd in doc.nodes:
if nd.kind == "h1":
out.append(Part(nd.text, nd.line, []))
elif out:
out[-1].nodes.append(nd)
elif nd.kind != "h1":
fail(doc.path, nd.line, "text před první sekcí (# Název) se nepoužije")
return out
def part(doc: Doc, *names: str) -> Part | None:
wanted = {norm(n) for n in names}
for p in parts(doc):
if norm(p.title) in wanted:
return p
return None
def text_of(nodes: list[Node]) -> str | None:
paras = ["\n".join(nd.lines) for nd in nodes if nd.kind == "para"]
return "\n\n".join(paras) if paras else None
def bullets(nodes: list[Node]) -> list[str]:
return [nd.text for nd in nodes if nd.kind == "bullet"]
def numbered(nodes: list[Node]) -> list[str]:
return [nd.text for nd in nodes if nd.kind == "num"]
def quote(nodes: list[Node]) -> str | None:
q = [nd.text for nd in nodes if nd.kind == "quote"]
return "\n".join(q) if q else None
def answer(nodes: list[Node]) -> str | None:
a = [nd.text for nd in nodes if nd.kind == "answer"]
return "\n".join(a) if a else None
def table(doc: Doc, nodes: list[Node], cols: int, where: str) -> list[list[str]]:
tables = [nd for nd in nodes if nd.kind == "table"]
if not tables:
return []
t = tables[0]
rows = t.rows[1:]
for k, r in enumerate(rows):
if len(r) != cols:
fail(doc.path, t.line + k + 2,
f"{where}: řádek tabulky má {len(r)} sloupců, čekám {cols} "
"(svislítko uvnitř textu se píše jako \\|)")
return [r + [""] * (cols - len(r)) for r in rows]
def flag(doc: Doc, key: str) -> bool | None:
if key not in doc.meta:
return None
v = norm(doc.meta[key])
if v in ("ano", "true", "yes", "1"):
return True
if v in ("ne", "false", "no", "0"):
return False
fail(doc.path, doc.meta_lines.get(key), f"{key}: čekám ano nebo ne, ne {doc.meta[key]!r}")
return None
def need(doc: Doc, key: str) -> str:
v = doc.meta.get(key)
if v is None:
fail(doc.path, None, f"v hlavičce chybí {key}:")
return ""
return v
def opt_int(s: str) -> int | None:
s = s.strip()
return int(s) if re.fullmatch(r"-?\d+", s) else None
# ---------------------------------------------------------------------------
# Shared readers
# ---------------------------------------------------------------------------
def read_quiz(doc: Doc, p: Part | None, expl_key: str | None = "expl") -> list[dict]:
if p is None:
return []
out = []
for it in p.items():
opts = [nd for nd in it.nodes if nd.kind == "bullet"]
if len(opts) < 2:
fail(doc.path, it.line, f"otázka {it.title!r} má méně než dvě možnosti")
right = [k for k, nd in enumerate(opts) if nd.checked]
if len(right) != 1:
fail(doc.path, it.line, f"otázka {it.title!r} musí mít právě jednu správnou možnost (- [x] …)")
q = {"prompt": it.title, "options": [nd.text for nd in opts],
"correct": right[0] if right else 0}
ex = quote(it.nodes)
if ex is not None and expl_key:
q[expl_key] = ex
out.append(q)
return out
def read_slides(doc: Doc, p: Part | None) -> list[dict]:
if p is None:
return []
items = p.items()
out = []
for k, it in enumerate(items):
label = None
for nd in it.nodes:
if nd.kind == "para":
m = re.fullmatch(r"\*(.+)\*", nd.lines[0])
if m:
label = m.group(1)
break
if label is None:
fail(doc.path, it.line, f"snímek {it.title!r}: pod nadpisem chybí téma (*Téma*)")
label = ""
out.append({
"kicker": f"{k + 1} / {len(items)} • {label}",
"title": it.title,
"tip": quote(it.nodes),
"lines": bullets(it.nodes),
})
return out
def read_typed(doc: Doc, p: Part | None, meaning_key: str = "meaning") -> list[dict]:
if p is None:
return []
out = []
for it in p.items():
a = answer(it.nodes)
if a is None:
fail(doc.path, it.line, f"úloha {it.title!r} nemá odpověď (= …)")
out.append({"prompt": it.title, "answers": a or "", meaning_key: quote(it.nodes)})
return out
# ---------------------------------------------------------------------------
# i18n
# ---------------------------------------------------------------------------
class I18n:
def __init__(self) -> None:
self.map: dict[str, dict] = {}
self.origin: dict[str, str] = {}
def put(self, key: str, cs: str | None, en: str | None, src: Path) -> str:
if key in self.map and self.origin[key] != rel(src):
prev = self.map[key]
if (prev["cs"], prev["en"]) != (cs, en):
warn(src, None, f"klíč {key!r} už definuje {self.origin[key]}")
self.map[key] = {"cs": cs, "en": en}
self.origin[key] = rel(src)
return key
def text(self, cs: str, en: str | None, src: Path) -> None:
"""Translation of a content string: the Czech text itself is the key."""
if cs in self.map:
prev = self.map[cs]
if prev["en"] and en and prev["en"] != en:
warn(src, None, f"text {cs[:40]!r} má jiný překlad v {self.origin[cs]}")
if prev["en"] and not en:
return
self.map[cs] = {"cs": cs, "en": en}
self.origin[cs] = rel(src)
I18N = I18n()
def en_doc(path: Path) -> Doc | None:
p = path.with_name(path.stem + ".en.md")
return parse_doc(p) if p.exists() else None
def load_translations(doc: Doc | None) -> None:
if doc is None:
return
p = part(doc, "Překlady", "Translations")
if p is None:
return
for r in table(doc, p.nodes, 2, "Překlady"):
cs, en = unesc(r[0]), unesc(r[1])
if cs:
I18N.text(cs, en or None, doc.path)
def keyed(key: str, doc: Doc, en: Doc | None, field_name: str, required: bool = True) -> str | None:
cs = doc.meta.get(field_name)
if cs is None:
if required:
fail(doc.path, None, f"v hlavičce chybí {field_name}:")
return None
e = en.meta.get(field_name) if en else None
return I18N.put(key, cs or None, e or None, doc.path)
# ---------------------------------------------------------------------------
# Progress keys
# ---------------------------------------------------------------------------
PROGRESS: dict[str, str] = {}
LEGACY: dict[str, str] = {}
IDS: dict[str, Path] = {}
def lesson_id(doc: Doc, address: str) -> str:
lid = need(doc, "id")
if lid in IDS:
fail(doc.path, doc.meta_lines.get("id"), f"id {lid!r} už má {rel(IDS[lid])}")
IDS[lid] = doc.path
PROGRESS[address] = lid
old = doc.meta.get("puvodni")
if old:
for o in old.split(","):
LEGACY[o.strip()] = lid
return lid
# ---------------------------------------------------------------------------
# Lesson kinds
# ---------------------------------------------------------------------------
def by_order(p: Path) -> tuple[int, str]:
m = re.match(r"^(\d+)-", p.name)
return (int(m.group(1)) if m else 1 << 30, p.name)
def lesson_files(directory: Path) -> list[Path]:
if not directory.is_dir():
fail(directory, None, "složka chybí")
return []
files = sorted((p for p in directory.glob("*.md")
if not p.name.endswith(".en.md") and not p.name.startswith("_")), key=by_order)
for p in files:
if not re.match(r"^\d+-", p.name):
fail(p, None, "název souboru musí začínat pořadím, např. 03-nazev.md")
return files
def code_of(p: Part | None) -> str:
if p is None:
return ""
for nd in p.nodes:
if nd.kind == "code":
return nd.text
return ""
def code_lessons(directory: Path, prefix: str) -> list[dict]:
"""Lessons with an explanation and a C# program the student can edit and run."""
out = []
for i, path in enumerate(lesson_files(directory), start=1):
doc, en = parse_doc(path), en_doc(path)
load_translations(en)
lid = lesson_id(doc, f"{prefix}.{i}")
code = code_of(part(doc, "Kód", "Code"))
if not code.strip():
fail(path, None, "lekce nemá ukázkový kód v sekci # Kód")
out.append({
"id": i,
"key": lid,
"titleKey": keyed(f"{prefix}_unit{i}", doc, en, "nazev"),
"subKey": keyed(f"{prefix}_unit{i}_sub", doc, en, "popis"),
"slides": read_slides(doc, part(doc, "Výklad")),
"code": code,
})
return out
def std_lessons(directory: Path, prefix: str) -> list[dict]:
"""Lessons with slides and a choice quiz (IT, civics, literature, sciences, physics)."""
out = []
for i, path in enumerate(lesson_files(directory), start=1):
doc, en = parse_doc(path), en_doc(path)
load_translations(en)
lid = lesson_id(doc, f"{prefix}.{i}")
ex_key = "net_ex_title" if prefix == "net" and i == 1 else f"{prefix}_ex{i}_title"
lesson = {
"id": i,
"key": lid,
"titleKey": keyed(f"{prefix}_unit{i}", doc, en, "nazev"),
"subKey": keyed(f"{prefix}_unit{i}_sub", doc, en, "popis"),
"exTitleKey": keyed(ex_key, doc, en, "nazev_cviceni"),
"quizHeadKey": keyed(f"{prefix}_quiz{i}_head", doc, en, "nadpis_kvizu", required=False),
"slides": read_slides(doc, part(doc, "Výklad")),
"quiz": read_quiz(doc, part(doc, "Kvíz")),
}
out.append(lesson)
if prefix == "net" and i == 1:
NET_TASKS.extend(read_net_tasks(doc, part(doc, "Úlohy na podsítě")))
return out
NET_TASKS: list[tuple[dict, list[dict]]] = []
def read_net_tasks(doc: Doc, p: Part | None) -> list[tuple[dict, list[dict]]]:
if p is None:
return []
out = []
for it in p.items():
head = []
for nd in it.nodes:
if nd.kind == "h3":
break
head.append(nd)
sub = {norm(s.title): s for s in it.subparts()}
sol = sub.get(norm("Řešení"))
chk = sub.get(norm("Kontrola"))
rows = []
for r in table(doc, chk.nodes if chk else [], 5, "Kontrola"):
pfx = opt_int(r[0])
rows.append({"pfx": pfx if pfx is not None else -1, "net": r[1] or None,
"bcast": r[2] or None, "lo": r[3] or None, "hi": r[4] or None})
out.append(({"prompt": text_of(head) or "",
"solution": text_of(sol.nodes) if sol else ""}, rows))
return out
def math_lessons(directory: Path, prefix: str) -> list[dict]:
out = []
for i, path in enumerate(lesson_files(directory), start=1):
doc, en = parse_doc(path), en_doc(path)
load_translations(en)
lid = lesson_id(doc, f"{prefix}.{i}")
problems = []
for it in (part(doc, "Příklady").items() if part(doc, "Příklady") else []):
a = answer(it.nodes)
if a is None:
fail(doc.path, it.line, f"příklad {it.title!r} nemá výsledek (= …)")
problems.append({"prompt": it.title, "answer": a or "", "hint": quote(it.nodes) or ""})
out.append({
"id": i,
"key": lid,
"titleKey": keyed(f"{prefix}_unit{i}", doc, en, "nazev"),
"subKey": keyed(f"{prefix}_unit{i}_sub", doc, en, "popis"),
"exTitleKey": keyed(f"{prefix}_ex{i}_title", doc, en, "nazev_cviceni"),
"quizHeadKey": keyed(f"{prefix}_quiz{i}_head", doc, en, "nadpis_kvizu"),
"slides": read_slides(doc, part(doc, "Výklad")),
"problems": problems,
})
return out
def skill_lessons(directory: Path, tag: str, year: int) -> list[dict]:
"""English and German years: slides, reading, listening, gaps, writing, quiz."""
out = []
for i, path in enumerate(lesson_files(directory), start=1):
doc, en = parse_doc(path), en_doc(path)
load_translations(en)
lid = lesson_id(doc, f"{tag}.{year}.{i}")
read = part(doc, "Čtení")
listen = part(doc, "Poslech")
write = part(doc, "Psaní")
writing = None
if write is not None:
sub = {norm(s.title): s for s in write.items()}
model = sub.get(norm("Vzorová odpověď"))
keys = sub.get(norm("Musí obsahovat"))
words = sub.get(norm("Minimum slov"))
n = opt_int(text_of(words.nodes) or "") if words else None
writing = {
"prompt": text_of(write.head()) or "",
"model": text_of(model.nodes) if model else "",
"keys": "|".join(bullets(keys.nodes)) if keys else "",
"minWords": n if n is not None else 20,
}
out.append({
"id": i,
"key": lid,
"titleKey": keyed(f"{tag}_y{year}_u{i}", doc, en, "nazev"),
"subKey": keyed(f"{tag}_y{year}_u{i}_sub", doc, en, "popis"),
"slides": read_slides(doc, part(doc, "Výklad")),
"reading": text_of(read.head()) if read else None,
"readQuiz": read_quiz(doc, read),
"listening": text_of(listen.head()) if listen else None,
"listenQuiz": read_quiz(doc, listen),
"gaps": read_typed(doc, part(doc, "Doplňování")),
"writing": writing,
"quiz": read_quiz(doc, part(doc, "Kvíz")),
})
return out
MLUV_TYPES = {"doplnovani": "typed", "vyber": "choice", "reseni": "reveal"}
def mluvnice(directory: Path) -> list[dict]:
out = []
for n, path in enumerate(lesson_files(directory), start=1):
doc, en = parse_doc(path), en_doc(path)
load_translations(en)
lid = lesson_id(doc, f"mluv.{n}")
kind = MLUV_TYPES.get(norm(need(doc, "typ")))
if kind is None:
fail(path, doc.meta_lines.get("typ"), f"typ musí být jeden z: {', '.join(MLUV_TYPES)}")
kind = "typed"
item = {"id": n, "key": lid, "name": need(doc, "zkratka"), "type": kind,
"title": need(doc, "nazev"), "sub": need(doc, "popis"),
"note": doc.meta.get("poznamka") or None}
p = part(doc, "Úlohy")
its = p.items() if p else []
if kind == "typed":
rows = []
for it in its:
hints, answers = [], []
for b in bullets(it.nodes):
h, sep, a = b.partition(" = ")
if not sep:
fail(path, it.line, f"mezera {b!r} musí mít tvar „nápověda = odpověď“")
hints.append(h)
answers.append(a)
rows.append({"prompt": it.title, "shown": quote(it.nodes) or "",
"hints": hints, "answers": answers})
item["items"] = rows
elif kind == "reveal":
item["items"] = [{"prompt": it.title, "solution": answer(it.nodes) or ""} for it in its]
else:
qs = read_quiz(doc, p, expl_key=None)
item["questions"] = qs
expls = [quote(it.nodes) for it in its]
if any(e is not None for e in expls):
item["expls"] = [e or "" for e in expls]
out.append(item)
return out
BOOK_ICONS = ["book", "globe", "people", "bulb"]
def books(directory: Path) -> list[dict]:
out = []
for path in lesson_files(directory):
doc, en = parse_doc(path), en_doc(path)
load_translations(en)
bid = need(doc, "id")
if bid in IDS:
fail(path, doc.meta_lines.get("id"), f"id {bid!r} už má {rel(IDS[bid])}")
IDS[bid] = path
PROGRESS[f"book.{bid}.quiz"] = f"{bid}.kviz"
PROGRESS[f"book.{bid}.plot"] = f"{bid}.dej"
LEGACY[f"book.{bid}.quiz"] = f"{bid}.kviz"
LEGACY[f"book.{bid}.plot"] = f"{bid}.dej"
author, title = need(doc, "autor"), need(doc, "nazev")
notes, quiz, plot, plot_meaning = [], [], [], []
for p in parts(doc):
t = norm(p.title)
if t == norm("Kvíz"):
quiz = read_quiz(doc, p)
elif t == norm("Děj"):
for it in p.items():
plot.append({"prompt": it.title, "words": numbered(it.nodes)})
m = quote(it.nodes)
if m is not None:
plot_meaning.append(m)
else:
icon = BOOK_ICONS[len(notes)] if len(notes) < len(BOOK_ICONS) else "book"
notes.append({"title": p.title, "icon": icon, "lines": bullets(p.nodes)})
def label(field_name: str, default: str) -> str:
if field_name in doc.meta:
return keyed(f"book_{slugify(bid)}_{field_name}", doc, en, field_name) or default
return default
out.append({
"id": bid,
"title": f"{author} – {title}",
"subKey": label("popis", "cetba1984_sub" if quiz else "cetba_entry_sub"),
"page": doc.meta.get("stranka") or f"cetba_{bid.replace('-', '_')}",
"genre": need(doc, "zanr"),
"quizTitle": label("kviz_nadpis", "book_quiz_heading") if quiz else "",
"quizSub": label("kviz_popis", "lit_quiz_sub") if quiz else "",
"plotTitle": label("dej_nadpis", "lit_plot_title") if plot else "",
"plotSub": label("dej_popis", "lit_plot_sub") if plot else "",
"notes": notes,
"quiz": quiz,
"plot": plot,
"plotMeaning": plot_meaning,
})
return out
# ---------------------------------------------------------------------------
# German textbook
# ---------------------------------------------------------------------------
def gap_rows(doc: Doc, rows: list[list[str]], col: int, where: str) -> list[tuple[str, str, str]]:
out = []
for r in rows:
try:
segs, ans = ungap(r[col])
except ContentError as e:
fail(doc.path, None, f"{where}: {e}")
segs, ans = [r[col]], [""]
if len(ans) != 1:
fail(doc.path, None, f"{where}: věta {r[col]!r} musí mít právě jednu mezeru [ … ]")
ans = (ans + [""])[:1]
segs = (segs + ["", ""])[:2]
out.append((segs[0], ans[0], segs[1]))
return out
def seg_rows(doc: Doc, text: str, pad: int, where: str) -> tuple[list[str], list[str]]:
try:
segs, ans = ungap(text)
except ContentError as e:
fail(doc.path, None, f"{where}: {e}")
return [text], []
while len(segs) < pad:
segs.append("")
return segs, ans
def blist(doc: Doc, name: str) -> list[str]:
p = part(doc, name)
return bullets(p.nodes) if p else []
def rows_of(doc: Doc, name: str, cols: int) -> list[list[str]]:
p = part(doc, name)
return table(doc, p.nodes, cols, name) if p else []
def nullable(s: str) -> str | None:
return unesc(s) if s != "" else None
def german_exercise(doc: Doc, kind: str) -> dict:
ex: dict = {"type": kind}
pool = lambda: blist(doc, "Nabídka")
meanings = lambda: blist(doc, "Překlad")
if kind == "dialog":
dialogues = []
p = part(doc, "Rozhovory")
for it in (p.items() if p else []):
rows = []
for r in table(doc, it.nodes, 2, it.title):
(b, a, af), = gap_rows(doc, [r], 1, it.title)
rows.append({"speaker": unesc(r[0]), "before": b, "answer": a, "after": af})
dialogues.append({"name": it.title, "rows": rows})
ex.update(pool=pool(), rows=[], meanings=meanings(), dialogues=dialogues)
elif kind == "assembly":
items = [{"prompt": None, "words": b.split(" / ")} for b in blist(doc, "Věty")]
items += [{"prompt": nullable(r[0]), "words": unesc(r[1]).split(" / ")}
for r in rows_of(doc, "Úlohy", 2)]
ex.update(items=items, meanings=meanings())
elif kind == "choice":
ex.update(questions=read_quiz(doc, part(doc, "Otázky"), expl_key=None), meanings=meanings())
elif kind == "free":
ex["questions"] = [{"question": unesc(r[0]), "qCs": unesc(r[1]), "sample": unesc(r[2]),
"aCs": unesc(r[3])} for r in rows_of(doc, "Otázky", 4)]
elif kind == "number":
ex.update(pool=pool(), rows=[{"digits": unesc(r[0]), "answer": unesc(r[1])}
for r in rows_of(doc, "Úlohy", 2)], meanings=meanings())
elif kind == "count":
ex.update(pool=pool(), rows=[{"emoji": unesc(r[0]), "count": opt_int(r[1]) or 0,
"noun": unesc(r[2]), "answer": unesc(r[3])}
for r in rows_of(doc, "Úlohy", 4)], meanings=meanings())
elif kind in ("seq", "verb"):
rows = [{"before": b, "after": af, "answer": a}
for b, a, af in gap_rows(doc, rows_of(doc, "Úlohy", 1), 0, "Úlohy")]
ex.update(pool=pool(), rows=rows, meanings=meanings())
if part(doc, "Glosář") is not None:
ex["gloss"] = blist(doc, "Glosář")
elif kind == "verb_sections":
sections = []
p = part(doc, "Oddíly")
for it in (p.items() if p else []):
rows = [{"before": b, "after": af, "answer": a}
for b, a, af in gap_rows(doc, table(doc, it.nodes, 1, it.title), 0, it.title)]
sections.append({"title": it.title, "rows": rows, "meanings": bullets(it.nodes)})
ex.update(pool=pool(), rows=[], meanings=[], sections=sections)
elif kind == "assign":
groups = blist(doc, "Skupiny")
items = []
for r in rows_of(doc, "Slova", 3):
g = unesc(r[2])
if g not in groups:
fail(doc.path, None, f"skupina {g!r} není v seznamu # Skupiny")
items.append({"emoji": nullable(r[0]), "label": unesc(r[1]),
"group": groups.index(g) if g in groups else 0})
ex.update(items=items, groups=groups, meanings=meanings())
elif kind == "verbclue":
rs = rows_of(doc, "Úlohy", 2)
rows = [{"emoji": unesc(r[0]), "before": b, "after": af, "answer": a}
for r, (b, a, af) in zip(rs, gap_rows(doc, rs, 1, "Úlohy"))]
ex.update(pool=pool(), rows=rows, meanings=meanings())
elif kind == "typed":
ex["rows"] = [{"prompt": unesc(r[0]), "answers": unesc(r[1]), "meaning": nullable(r[2])}
for r in rows_of(doc, "Úlohy", 3)]
p = part(doc, "Nabídka")
if p is not None:
ex["bank"] = text_of(p.nodes)
elif kind == "profile":
ex["questions"] = [{"stem": unesc(r[0]), "sample": unesc(r[1])}
for r in rows_of(doc, "Otázky", 2)]
elif kind == "hangman":
rs = rows_of(doc, "Slova", 2)
letters = text_of(part(doc, "Písmena").nodes) if part(doc, "Písmena") else ""
ex.update(words=[unesc(r[0]) for r in rs], tips=[unesc(r[1]) for r in rs],
letters=(letters or "").split())
elif kind == "ordne":
ex.update(pool=[], rows=[{"mid": unesc(r[1]), "fw": unesc(r[0]), "ans": unesc(r[2])}
for r in rows_of(doc, "Úlohy", 3)],
meanings=meanings(), fwPool=blist(doc, "Tázací slova"),
ansPool=blist(doc, "Odpovědi"))
elif kind in ("fill", "letters"):
pad = 4 if kind == "fill" else 5
rows = []
for r in rows_of(doc, "Úlohy", 3):
segs, ans = seg_rows(doc, r[1], pad, "Úlohy")
rows.append({"num": nullable(r[0]), "segs": segs, "answers": ans, "mean": nullable(r[2])})
ex["rows"] = rows
if kind == "fill":
ex.update(pool=pool(), meanings=meanings())
elif kind == "ex2":
items = []
p = part(doc, "Úlohy")
for it in (p.items() if p else []):
rows = []
for r in table(doc, it.nodes, 2, it.title):
segs, ans = seg_rows(doc, r[1], 0, it.title)
rows.append({"num": nullable(r[0]), "segs": segs, "answers": ans})
items.append({"czech": it.title, "rows": rows})
ex.update(pool=pool(), items=items)
elif kind == "letters_gap":
rows = []
p = part(doc, "Věty")
for it in (p.items() if p else []):
segs, ans = seg_rows(doc, it.title, 0, "Věty")
words, gmean = [], []
for b in bullets(it.nodes):
w, _, m = b.partition(" = ")
words.append(w)
gmean.append(m)
rows.append({"segs": segs, "answers": ans, "czech": quote(it.nodes),
"words": words, "gmean": gmean})
ex["rows"] = rows
elif kind == "table":
p = part(doc, "Tabulka")
t = [nd for nd in (p.nodes if p else []) if nd.kind == "table"]
rows = t[0].rows if t else [[""]]
ex.update(persons=[unesc(c) for c in rows[0][1:]],
nouns=[unesc(r[0]) for r in rows[1:]],
answers=[[unesc(c) for c in r[1:]] for r in rows[1:]])
elif kind == "kw":
ex["rows"] = [{"prompt": unesc(r[0]), "answers": unesc(r[1]), "mean": nullable(r[2]),
"german": nullable(r[3])} for r in rows_of(doc, "Úlohy", 4)]
elif kind == "vocab":
sections = []
p = part(doc, "Slovíčka")
for it in (p.items() if p else []):
sections.append({"header": it.title, "rows": [
{"prompt": unesc(r[0]), "answers": unesc(r[1]), "meaning": nullable(r[2])}
for r in table(doc, it.nodes, 3, it.title)]})
ex["sections"] = sections
else:
fail(doc.path, doc.meta_lines.get("typ"), f"neznámý typ cvičení {kind!r}")
for fm, js in (("ukazka", "sample"),):
if fm in doc.meta:
ex[js] = doc.meta[fm]
for fm, js in (("vyznam_predem", "preMeaning"), ("preklad_predem", "transUpfront"),
("odhalit_nemcinu", "revealGerman"), ("ukazat_nemcinu", "showGerman"),
("poznamka_k_odpovedim", "answersNote")):
v = flag(doc, fm)
if v is not None:
ex[js] = v
return ex
def german_textbook(directory: Path) -> list[dict]:
units = []
unit_dirs = sorted((p for p in directory.iterdir() if p.is_dir()), key=by_order) \
if directory.is_dir() else []
for u, udir in enumerate(unit_dirs):
info_path = udir / "_lekce.md"
if not info_path.exists():
fail(udir, None, "chybí _lekce.md")
continue
info, info_en = parse_doc(info_path), en_doc(info_path)
load_translations(info_en)
n = u + 1
title = need(info, "nazev")
if flag(info, "zamceno"):
units.append({"id": u, "title": title, "page": None, "tag": None, "sub": None,
"unlocked": False, "names": [], "exercises": {}})
continue
exercises: dict[str, dict] = {}
names: list[str] = []
vocab = None
files = sorted((p for p in udir.glob("*.md")
if not p.name.endswith(".en.md") and not p.name.startswith("_")), key=by_order)
k = 0
for path in files:
doc, en = parse_doc(path), en_doc(path)
load_translations(en)
kind = need(doc, "typ")
if kind == "vocab":
lesson_id(doc, f"g.{u}.vocab")
vocab = german_exercise(doc, kind)
vocab = {"type": "vocab", "title": need(doc, "nazev"),
"sub": keyed(f"u{n}vocab_sub", doc, en, "popis"),
"sections": vocab["sections"]}
continue
if not re.match(r"^\d+-", path.name):
fail(path, None, "název souboru musí začínat pořadím, např. 03-nazev.md")
k += 1
lesson_id(doc, f"g.{u}.{k}")
ex = german_exercise(doc, kind)
ex["title"] = need(doc, "nazev")
ex["sub"] = keyed(f"u{n}e{k}_sub", doc, en, "popis")
if "tip" in doc.meta:
ex["tip"] = keyed(f"u{n}e{k}_tip", doc, en, "tip")
exercises[str(k)] = ex
names.append(ex["title"])
unit = {"id": u, "title": title, "page": f"unit{n}", "tag": f"u{n}",
"sub": keyed(f"unit{n}_sub", info, info_en, "popis"), "unlocked": True,
"names": names, "branch": f"u{n}vocab", "exercises": exercises}
if vocab is not None:
unit["vocab"] = vocab
units.append(unit)
return units
# ---------------------------------------------------------------------------
# App-level files
# ---------------------------------------------------------------------------
def ui_strings(path: Path) -> None:
doc = parse_doc(path)
for t in (nd for nd in doc.nodes if nd.kind == "table"):
for r in table(doc, [t], 3, "Texty"):
key = unesc(r[0])
if not key:
continue
if key in I18N.map:
warn(path, None, f"klíč {key!r} je v textech dvakrát")
I18N.put(key, nullable(r[1]), nullable(r[2]), path)
def subjects(path: Path) -> list[dict]:
doc = parse_doc(path)
out = []
p = part(doc, "Předměty")
for r in table(doc, p.nodes if p else [], 6, "Předměty"):
key = unesc(r[0])
if r[1] or r[2]:
I18N.put(key, nullable(r[1]), nullable(r[2]), path)
out.append({"key": key, "open": flag_cell(r[5]), "target": nullable(r[4]),
"icon": unesc(r[3])})
return out
def flag_cell(s: str) -> bool:
return norm(s) in ("ano", "true", "yes", "1")
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"]]
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
STD = [
("pocitacove-site/2-rocnik", "net2"), ("pocitacove-site/3-rocnik", "net3"),
("pocitacove-site/4-rocnik", "net4"),
("technicke-vybaveni/1-rocnik", "hw"), ("technicke-vybaveni/2-rocnik", "hw2"),
("technicke-vybaveni/3-rocnik", "hw3"), ("technicke-vybaveni/4-rocnik", "hw4"),
("obcanska-nauka/1-rocnik", "on"), ("obcanska-nauka/2-rocnik", "on2"),
("obcanska-nauka/3-rocnik", "on3"), ("obcanska-nauka/4-rocnik", "on4"),
("cestina/literatura/1-rocnik", "lit"), ("cestina/literatura/2-rocnik", "lit2"),
("cestina/literatura/3-rocnik", "lit3"), ("cestina/literatura/4-rocnik", "lit4"),
("prirodni-vedy/chemie", "chem"), ("prirodni-vedy/biologie", "bio"),
("fyzika/1-rocnik", "fyz"), ("fyzika/2-rocnik", "fyz2"),
("fyzika/3-rocnik", "fyz3"), ("fyzika/4-rocnik", "fyz4"),
("technicka-grafika/1-rocnik", "tg"), ("technicka-grafika/2-rocnik", "tg2"),
("technicka-grafika/3-rocnik", "tg3"), ("technicka-grafika/4-rocnik", "tg4"),
("prezentacni-grafika/1-rocnik", "pg"), ("prezentacni-grafika/2-rocnik", "pg2"),
("prezentacni-grafika/3-rocnik", "pg3"), ("prezentacni-grafika/4-rocnik", "pg4"),
("programove-vybaveni/1-rocnik", "sw"), ("programove-vybaveni/2-rocnik", "sw2"),
("programove-vybaveni/3-rocnik", "sw3"), ("programove-vybaveni/4-rocnik", "sw4"),
]
CODE = [
("programovani/1-rocnik", "cs"), ("programovani/2-rocnik", "cs2"),
("programovani/3-rocnik", "cs3"), ("programovani/4-rocnik", "cs4"),
]
MATH = [
("matematika/0-opakovani", "mat0"), ("matematika/1-rocnik", "mat"),
("matematika/2-rocnik", "mat2"), ("matematika/3-rocnik", "mat3"),
("matematika/4-rocnik", "mat4"),
]
SKILLS = [("anglictina", "en"), ("nemcina", "de")]
def build(content: Path = CONTENT) -> dict:
ui_strings(content / "aplikace" / "texty.md")
payload: dict = {"i18n": None}
payload["subjects"] = subjects(content / "aplikace" / "predmety.md")
payload["german"] = german_textbook(content / "nemcina" / "ucebnice")
net = std_lessons(content / "pocitacove-site" / "1-rocnik", "net")
payload["net"] = {
"lessons": net,
"tasks": [t for t, _ in NET_TASKS],
"answers": [a for _, a in NET_TASKS],
}
for d, key in STD:
payload[key] = std_lessons(content / d, key)
for d, key in MATH:
payload[key] = math_lessons(content / d, key)
for d, key in CODE:
payload[key] = code_lessons(content / d, key)
for d, tag in SKILLS:
for year in range(1, 5):
payload[tag if year == 1 else f"{tag}{year}"] = skill_lessons(
content / d / f"{year}-rocnik", tag, year)
payload["mluvnice"] = mluvnice(content / "cestina" / "mluvnice")
payload["books"] = books(content / "cestina" / "cetba")
payload["changelog"] = parse_changelog(content / "aplikace" / "novinky.txt")
payload["progress"] = {"keys": PROGRESS, "legacy": LEGACY}
payload["i18n"] = I18N.map
check(payload)
return payload
def check(payload: dict) -> None:
for sec, lessons in payload.items():
if not isinstance(lessons, list):
continue
for L in lessons:
if not isinstance(L, dict):
continue
for q in L.get("quiz") or []:
if isinstance(q, dict) and q.get("options") and not 0 <= q["correct"] < len(q["options"]):
ERRORS.append(f"{sec} {L.get('id')}: správná odpověď mimo možnosti")
def main() -> int:
outs = OUTS
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: build-content.py [--out PATH ...]")
payload = build()
for w in WARNINGS:
print(f"upozornění: {w}", file=sys.stderr)
if ERRORS:
for e in ERRORS:
print(f"chyba: {e}", file=sys.stderr)
print(f"{len(ERRORS)} chyb, content.json nevznikl", file=sys.stderr)
return 1
text = json.dumps(payload, ensure_ascii=False, indent=1)
for dest in outs:
dest.parent.mkdir(parents=True, exist_ok=True)
if not dest.exists() or dest.read_text(encoding="utf-8") != text:
dest.write_text(text, encoding="utf-8")
print(f"content.json: {len(IDS)} lekcí, {len(payload['i18n'])} textů → "
+ ", ".join(rel(d) for d in outs))
return 0
if __name__ == "__main__":
sys.exit(main())