mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
- content/ + scripts/build-content.py produce content.json for every app - stable lesson IDs with one-time progress migration (Swift, Kotlin) - shared answer-check tests (tests/answers.json) for Kotlin and Swift - android/desktop: Compose app with updater, speech and GTK progress import - CI packages AppImage and Windows zip with ProGuard, runs the tests Co-authored-by: Cursor <cursoragent@cursor.com>
1127 lines
41 KiB
Python
1127 lines
41 KiB
Python
#!/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
|
||
|
||
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
|
||
for n in range(i, len(lines)):
|
||
ln = lines[n].rstrip()
|
||
no = n + 1
|
||
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 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"),
|
||
]
|
||
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, 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())
|