Add the Next.js web app and shared lesson content.

Move lessons out of the C sources into JSON so the web client can use the same material as the desktop app, and point release builds at desktop/.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
FBukovina 2026-09-17 16:06:15 +02:00
commit f7470d333c
113 changed files with 25817 additions and 9 deletions

114
tools/extract/cetba.mjs Normal file
View file

@ -0,0 +1,114 @@
import { readFileSync } from 'node:fs';
import { parseArray, parseArrayInFunction, trimNulls } from '../lib/cparse.mjs';
/**
* Maturita reading list ("Četba"): one lesson per book.
*
* Each book page shows four reference cards followed by two exercises, so the
* cards become the lesson's theory slides and the quiz and plot ordering
* become its two tasks.
*/
/**
* Lesson numbers continue after Mluvnice, so every Czech lesson has a distinct
* number for the statistics page; the reading path numbers its own nodes.
*/
const BOOKS = [
{
id: 'orwell-1984',
number: 2,
title: 'George Orwell – 1984',
page: 'build_cetba1984_page',
quiz: 'lit_qs',
plot: 'lit_plot_items',
plotMeaning: 'lit_plot_meaning',
cards: [
['about', 'O knize'],
['world', 'Svět a strana'],
['people', 'Postavy'],
['terms', 'Klíčové pojmy'],
],
},
{
id: 'fuks-spalovac-mrtvol',
number: 3,
title: 'Ladislav Fuks – Spalovač mrtvol',
page: 'build_cetba_fuks_page',
quiz: 'fuks_qs',
plot: 'fuks_plot_items',
plotMeaning: 'fuks_plot_meaning',
cards: [
['about', 'O knize'],
['world', 'Doba a svět'],
['people', 'Postavy'],
['terms', 'Klíčové motivy'],
],
},
];
/** `LitQ`: question, opts[4], correct, explanation. */
function litChoice(row, label) {
const [prompt, options, correct, expl] = row;
const list = trimNulls(options ?? []).map(String);
if (typeof correct !== 'number' || correct < 0 || correct >= list.length) {
throw new Error(`${label}: correct index ${correct} out of range for ${list.length} options`);
}
return {
kind: 'choice',
prompt: String(prompt),
options: list,
correct,
...(expl ? { hint: String(expl) } : {}),
};
}
export function extractCetba(desktopDir) {
const src = readFileSync(`${desktopDir}/cetba.c`, 'utf8');
return BOOKS.map((book) => {
const slides = book.cards.map(([arrayName, cardTitle]) => ({
kicker: book.title,
title: cardTitle,
tip: null,
lines: trimNulls(parseArrayInFunction(src, book.page, arrayName)).map(String),
}));
const plotMeanings = parseArray(src, book.plotMeaning).map(String);
return {
id: book.id,
subject: 'czech',
number: book.number,
title: { cs: book.title, en: book.title },
subtitle: {
cs: 'Projděte si přehled a pak si vyzkoušejte kvíz a řazení děje.',
en: 'Read the overview, then try the quiz and the plot ordering.',
},
slides,
tasks: [
{
number: 1,
title: 'Kvíz',
heading: 'Kvíz ke knize',
subtitleKey: 'lit_quiz_sub',
items: parseArray(src, book.quiz)
.map((row, i) => litChoice(row, `${book.id} q${i + 1}`)),
},
{
number: 2,
title: 'Děj',
heading: 'Seřaďte děj',
subtitleKey: 'lit_plot_sub',
items: parseArray(src, book.plot).map(([prompt, words], i) => ({
kind: 'assembly',
prompt: prompt ? String(prompt) : '',
words: trimNulls(words).map(String),
...(plotMeanings[i] ? { meaning: plotMeanings[i] } : {}),
})),
},
],
};
});
}

View file

@ -0,0 +1,196 @@
import { parseArray, trimNulls } from '../lib/cparse.mjs';
/** Shared conversions for the three Deutsch units. */
export const opt = (v) => (v === null || v === undefined || v === '' ? undefined : String(v));
/** Split the pipe-separated accepted-answer lists used by TypedQ and friends. */
export const alternatives = (s) =>
String(s ?? '').split('|').map((a) => a.trim()).filter(Boolean);
/** `VerbQ`/`SeqQ` `{before, after, answer}` becomes a single-gap fill sentence. */
export function verbFill(row, meaning, extra = {}) {
const [before, after, answer] = row;
return {
kind: 'fill',
segments: [before ?? '', after ?? ''],
answers: [String(answer)],
input: 'select',
...(meaning ? { meaning: String(meaning) } : {}),
...extra,
};
}
/**
* `U3Fill` alternates literal segments and answers: s0, a0, s1, a1, s2, a2, s3.
* Trailing NULLs mean the row uses fewer than three gaps.
*/
export function u3Fill(row, { gloss } = {}) {
const [num, ...rest] = row;
const mean = rest.pop();
const parts = rest;
const segments = [];
const answers = [];
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) segments.push(parts[i] ?? '');
else if (parts[i] !== null && parts[i] !== undefined) answers.push(String(parts[i]));
}
// Drop segments that only exist to pad unused gaps.
while (segments.length > answers.length + 1) segments.pop();
return {
kind: 'fill',
segments,
answers,
input: 'select',
...(opt(num) ? { label: String(num) } : {}),
...(opt(mean) ? { meaning: String(mean) } : {}),
...(gloss ? { glosses: [String(gloss)] } : {}),
};
}
/** `U3Let`: s0, a0, s1, a1, s2, a2, s3, a3, s4 — gaps hold missing letters. */
export function u3Letters(row) {
const [num, s0, a0, s1, a1, s2, a2, s3, a3, s4, mean] = row;
const rawSegments = [s0, s1, s2, s3, s4];
const rawAnswers = [a0, a1, a2, a3];
const answers = rawAnswers.filter((a) => a !== null && a !== undefined).map(String);
const segments = rawSegments.slice(0, answers.length + 1).map((s) => s ?? '');
return {
kind: 'fill',
segments,
answers,
input: 'text',
letters: true,
...(opt(num) ? { label: String(num) } : {}),
...(opt(mean) ? { meaning: String(mean) } : {}),
};
}
/**
* `Ex4Row`: seg[5], ans[4] (missing letters), czech, word[4], gmean[4], gaps.
* Each gap's gloss pairs the completed German word with its Czech meaning.
*/
export function ex4Letters(row) {
const [seg, ans, czech, word, gmean, gaps] = row;
const answers = ans.slice(0, gaps).map(String);
const segments = seg.slice(0, gaps + 1).map((s) => s ?? '');
const glosses = answers.map((_, i) => {
const w = opt(word?.[i]);
const m = opt(gmean?.[i]);
if (w && m) return `${w} — ${m}`;
return w ?? m ?? null;
});
return {
kind: 'fill',
segments,
answers,
input: 'text',
letters: true,
...(opt(czech) ? { meaning: String(czech) } : {}),
glosses,
};
}
/** `Ex2Row`: num, seg[4], ans[3], gaps — grouped under an `Ex2Item`'s translation. */
export function ex2Row(row, czech) {
const [num, seg, ans, gaps] = row;
return {
kind: 'fill',
segments: seg.slice(0, gaps + 1).map((s) => s ?? ''),
answers: ans.slice(0, gaps).map(String),
input: 'select',
...(opt(num) ? { label: String(num) } : {}),
...(opt(czech) ? { meaning: String(czech) } : {}),
};
}
/** `ChoiceQ`: prompt, options[5], n_options, correct. */
export function choiceItem(row, meaning) {
const [prompt, options, n, correct] = row;
const list = trimNulls(options ?? []).map(String);
const count = typeof n === 'number' ? n : list.length;
if (typeof correct !== 'number' || correct < 0 || correct >= count) {
throw new Error(`choice '${prompt}': correct index ${correct} out of range`);
}
return {
kind: 'choice',
prompt: String(prompt),
options: list.slice(0, count),
correct,
...(opt(meaning) ? { meaning: String(meaning) } : {}),
};
}
/** `TypedQ`: prompt, pipe-separated answers, Czech meaning. */
export function typedItem(row) {
const [prompt, answers, meaning] = row;
return {
kind: 'typed',
prompt: String(prompt),
answers: alternatives(answers),
...(opt(meaning) ? { meaning: String(meaning) } : {}),
};
}
/** `FreeQ`: question, Czech question, sample, Czech sample. */
export function freeItem(row) {
const [question, questionCs, sample, sampleCs] = row;
return {
kind: 'free',
question: String(question),
...(opt(questionCs) ? { questionCs: String(questionCs) } : {}),
sample: String(sample),
...(opt(sampleCs) ? { sampleCs: String(sampleCs) } : {}),
};
}
/** `AssemblyItem`: prompt, words[6], n. */
export function assemblyItem(row, meaning) {
const [prompt, words] = row;
return {
kind: 'assembly',
prompt: opt(prompt) ?? '',
words: trimNulls(words).map(String),
...(opt(meaning) ? { meaning: String(meaning) } : {}),
};
}
/** One `assign` exercise built from `AssignItem[]`, group labels and meanings. */
export function assignExercise(src, itemsName, groupsName, meaningName) {
const meanings = meaningName ? parseArray(src, meaningName) : [];
return {
kind: 'assign',
groups: parseArray(src, groupsName).map(String),
items: parseArray(src, itemsName).map(([emoji, label, group], i) => ({
emoji: emoji ?? null,
label: String(label),
group,
...(opt(meanings[i]) ? { meaning: String(meanings[i]) } : {}),
})),
};
}
/** Vocabulary trainer sections: `TransSection[]` referencing `TypedQ[]` arrays. */
export function vocabSections(src, sectionsName) {
return parseArray(src, sectionsName).map(([header, arrayRef]) => {
const name = arrayRef?.__c;
if (!name) throw new Error(`vocab section '${header}' has no data array`);
return {
header: String(header),
items: parseArray(src, name).map(([prompt, answers, meaning]) => ({
prompt: String(prompt),
answers: alternatives(answers),
...(opt(meaning) ? { meaning: String(meaning) } : {}),
})),
};
});
}

View file

@ -0,0 +1,159 @@
import { readFileSync } from 'node:fs';
import { parseArray, parseString } from '../lib/cparse.mjs';
import {
choiceItem,
freeItem,
opt,
typedItem,
verbFill,
vocabSections,
} from './deutsch-helpers.mjs';
/**
* Deutsch Unit 2 ("Familie und Freunde").
*
* Task order, titles and instruction keys come from the `u2exN` builders at the
* bottom of exercise_u2.c; each one names the `const` arrays holding its data.
*/
export function extractDeutschUnit2(desktopDir) {
const src = readFileSync(`${desktopDir}/exercise_u2.c`, 'utf8');
const tasks = [];
/** `build_verb_ex`: one pool feeding single-gap sentences. */
const verbTask = (number, title, subtitleKey, prefix, { gloss = false } = {}) => {
const meanings = parseArray(src, `${prefix}_mean`);
const glosses = gloss ? parseArray(src, `${prefix}_gloss`) : null;
return {
number,
title,
subtitleKey,
pool: parseArray(src, `${prefix}_pool`).map(String),
items: parseArray(src, `${prefix}_rows`).map((row, i) =>
verbFill(row, meanings[i], glosses && opt(glosses[i])
? { glosses: [String(glosses[i])] }
: {})),
};
};
const choiceTask = (number, title, subtitleKey, prefix) => {
const meanings = parseArray(src, `${prefix}_mean`);
return {
number,
title,
subtitleKey,
items: parseArray(src, `${prefix}_rows`).map((row, i) => choiceItem(row, meanings[i])),
};
};
const typedTask = (number, title, subtitleKey, rowsName, bankName) => ({
number,
title,
subtitleKey,
...(bankName ? { note: parseString(src, bankName) } : {}),
items: parseArray(src, rowsName).map(typedItem),
});
/* 1 — Verben konjugieren: two people, each a headed group over one pool. */
{
const pool = parseArray(src, 'g01_pool').map(String);
const groups = [
['Peter Fritsch:', 'g01_peter', 'g01_peter_mean'],
['Jana Nová und Pavol Korčák:', 'g01_jana', 'g01_jana_mean'],
];
const items = groups.flatMap(([heading, rowsName, meanName]) => {
const meanings = parseArray(src, meanName);
return parseArray(src, rowsName).map((row, i) =>
verbFill(row, meanings[i], i === 0 ? { heading } : {}));
});
tasks.push({
number: 1,
title: 'Verben konjugieren',
subtitleKey: 'sub_verben',
pool,
items,
});
}
tasks.push(choiceTask(2, 'aus oder in', 'sub_ausin', 'g02'));
tasks.push(choiceTask(3, 'Fragewörter', 'sub_wer', 'g03'));
tasks.push(verbTask(4, 'sprechen', 'sub_sprich', 'g04'));
tasks.push(typedTask(5, 'Nationalitäten', 'sub_nation', 'g05_rows', 'g05_bank'));
tasks.push(typedTask(6, 'Woher?', 'sub_woher', 'g06_rows'));
tasks.push(typedTask(7, 'Länder schreiben', 'sub_laender', 'g07_rows'));
tasks.push(verbTask(8, 'Verben einsetzen', 'sub_verb2', 'g08'));
/* 9 — Freie Antwort */
tasks.push({
number: 9,
title: 'Freie Antwort',
subtitleKey: 'sub_free',
items: parseArray(src, 'ex9_free_qs').map(freeItem),
});
tasks.push(verbTask(10, 'Euro', 'sub_euro', 'g10'));
tasks.push(typedTask(11, 'Wörter suchen', 'sub_wortsuchen', 'g11_rows'));
tasks.push(choiceTask(12, 'Was ist richtig?', 'sub_verb2', 'g12'));
/* 13 — Ordne zu: a Fragewort dropdown and an answer dropdown per row. */
{
const meanings = parseArray(src, 'g13_mean');
tasks.push({
number: 13,
title: 'Ordne zu',
subtitleKey: 'sub_ordne',
pool: parseArray(src, 'g13_fw_pool').map(String),
altPool: parseArray(src, 'g13_ans_pool').map(String),
items: parseArray(src, 'g13_rows').map(([mid, fw, ans], i) => ({
kind: 'fill',
segments: ['', ` ${mid} → `, ''],
answers: [String(fw), String(ans)],
input: 'select',
gapPool: ['main', 'alt'],
label: `${i + 1}.`,
...(opt(meanings[i]) ? { meaning: String(meanings[i]) } : {}),
})),
});
}
tasks.push(verbTask(14, 'Lückentext', 'sub_luecke', 'g14', { gloss: true }));
tasks.push(verbTask(15, 'Verbinde', 'sub_verbinde', 'g15', { gloss: true }));
tasks.push(verbTask(16, 'Zahlen', 'sub_zahlpaar', 'g16'));
/* 17 — Steckbrief */
tasks.push({
number: 17,
title: 'Steckbrief',
subtitleKey: 'sub_steckbrief',
items: parseArray(src, 's01_rows').map(([stem, sample]) => ({
kind: 'profile',
stem: String(stem),
sample: String(sample),
})),
});
/* 18 — Berufe: hangman, one round per profession. */
{
const words = parseArray(src, 'hm_words').map(String);
const tips = parseArray(src, 'hm_tips').map(String);
tasks.push({
number: 18,
title: 'Berufe',
subtitleKey: 'sub_berufe',
items: words.map((word, i) => ({ kind: 'hangman', word, hint: tips[i] })),
});
}
tasks.push(typedTask(19, 'Nationalität', 'sub_bistdu', 's03_rows'));
return {
id: 'unit-02',
subject: 'deutsch',
number: 2,
title: { cs: 'Aus aller Welt', en: 'Aus aller Welt' },
slides: [],
tasks,
vocab: vocabSections(src, 'u2_trans_sections'),
};
}

View file

@ -0,0 +1,174 @@
import { readFileSync } from 'node:fs';
import { parseArray, parseString } from '../lib/cparse.mjs';
import {
alternatives,
assemblyItem,
assignExercise,
ex2Row,
ex4Letters,
opt,
typedItem,
u3Fill,
u3Letters,
vocabSections,
} from './deutsch-helpers.mjs';
/**
* Deutsch Unit 3 ("Bei uns zu Hause").
*
* Most tasks go through `u3_build_drop` (dropdown gaps over a shared pool);
* the rest are hand-built pages whose data arrays are read here directly.
*/
export function extractDeutschUnit3(desktopDir) {
const src = readFileSync(`${desktopDir}/exercise_u3.c`, 'utf8');
const tasks = [];
/** `u3_build_drop`: `U3Fill` rows sharing one dropdown pool. */
const dropTask = (number, title, subtitleKey, prefix, { example, gloss } = {}) => {
const glosses = gloss ? parseArray(src, `${prefix}_gloss`) : null;
return {
number,
title,
subtitleKey,
...(example ? { example } : {}),
pool: parseArray(src, `${prefix}_pool`).map(String),
items: parseArray(src, `${prefix}_rows`).map((row, i) =>
u3Fill(row, { gloss: glosses ? opt(glosses[i]) : undefined })),
};
};
tasks.push(dropTask(1, 'Familienpaare', 'sub3_pair', 'ex1_u3', {
example: 'Beispiel: der Vater ↔ die Mutter',
}));
/* 2 — mein oder dein: rows grouped per item, each item has one translation. */
tasks.push({
number: 2,
title: 'mein oder dein',
subtitleKey: 'sub3_poss',
example:
'Beispiel: Ist das dein Vater? – Nein, das ist nicht mein Vater. ' +
'Das ist sein Onkel.',
pool: parseArray(src, 'ex2_u3_pool').map(String),
items: parseArray(src, 'ex2_u3_items').flatMap(([czech, rowsRef]) => {
const name = rowsRef?.__c;
if (!name) throw new Error(`ex2_u3_items row group for '${czech}' has no data array`);
return parseArray(src, name).map((row) => ex2Row(row, czech));
}),
});
tasks.push(dropTask(3, 'ein / kein', 'sub3_haustier', 'ex3_u3', {
example:
'Beispiel: Hast du eine Katze? – Nein, ich habe keine Katze. ' +
'Ich habe einen Hund.',
}));
/* 4 — Lückentext: missing letters inside words. */
tasks.push({
number: 4,
title: 'Lückentext',
subtitleKey: 'sub3_buchst',
items: parseArray(src, 'ex4_u3_rows').map(ex4Letters),
});
/* 5 — Marcos Familie */
tasks.push({
number: 5,
title: 'Marcos Familie',
subtitleKey: 'sub3_marco',
note: parseString(src, 'ex5_u3_bank'),
items: parseArray(src, 'ex5_u3_rows').map(typedItem),
});
/* 6 — Sortieren */
tasks.push({
number: 6,
title: 'Sortieren',
subtitleKey: 'sub3_sort',
items: [assignExercise(src, 'ex6_u3_items', 'ex6_u3_groups', 'ex6_u3_meaning')],
});
/* 7 — Possessivtabelle: nouns down the side, persons across the top. */
{
const persons = ['ich', 'du', 'er', 'sie', 'es', 'wir', 'ihr', 'sie / Sie'];
const nouns = ['der Garten', 'die Idee', 'das Fest', 'die Geschwister'];
const forms = [
['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'],
];
tasks.push({
number: 7,
title: 'Possessivtabelle',
subtitleKey: 'sub3_tabelle',
items: [{
kind: 'table',
columns: persons,
rows: nouns,
answers: forms.map((row) => row.map((value) => ({ value }))),
}],
});
}
tasks.push(dropTask(8, 'Akkusativ', 'sub3_akk', 'ex8_u3', {
example: 'Beispiel: die Inliner (ich) → Ich brauche meine Inliner.',
gloss: true,
}));
tasks.push(dropTask(9, 'kein / nicht', 'sub3_nicht', 'ex9_u3'));
/* 10 — Sätze bauen */
{
const meanings = parseArray(src, 'ex10_u3_meaning');
tasks.push({
number: 10,
title: 'Sätze bauen',
subtitleKey: 'sub3_satz',
items: parseArray(src, 'ex10_u3_items').map((row, i) => assemblyItem(row, meanings[i])),
});
}
/* 11 + 14 — `u3_build_kw`: free sentence checked for the required keywords. */
const keywordTask = (number, title, subtitleKey, qsName, example) => ({
number,
title,
subtitleKey,
example,
items: parseArray(src, qsName).map(([prompt, answers, mean, german]) => ({
kind: 'typed',
prompt: String(prompt),
answers: alternatives(answers),
substring: true,
...(opt(mean) ? { meaning: String(mean) } : {}),
...(opt(german) ? { model: String(german) } : {}),
})),
});
tasks.push(keywordTask(11, 'Was siehst du?', 'sub3_sehen', 'ex11_u3_qs',
'Beispiel: Ich sehe eine Burg. / Ich sehe ein Schloss.'));
tasks.push(dropTask(12, 'Wem gehört das?', 'sub3_wem', 'ex12_u3'));
tasks.push(dropTask(13, 'Es gibt …', 'sub3_gibt', 'ex13_u3'));
tasks.push(keywordTask(14, 'Beschreiben', 'sub3_saetze', 'ex14_u3_qs',
'Beispiel: Ich sehe einen Jungen. Er liest ein Buch.'));
/* 15 — Wochenende: missing letters again, but with no per-gap glosses. */
tasks.push({
number: 15,
title: 'Wochenende',
subtitleKey: 'sub3_wochen',
items: parseArray(src, 'ex15_u3_rows').map(u3Letters),
});
return {
id: 'unit-03',
subject: 'deutsch',
number: 3,
title: { cs: 'Bei uns zu Hause', en: 'Bei uns zu Hause' },
slides: [],
tasks,
vocab: vocabSections(src, 'u3_trans_sections'),
};
}

249
tools/extract/deutsch.mjs Normal file
View file

@ -0,0 +1,249 @@
import { readFileSync } from 'node:fs';
import { parseArray, trimNulls } from '../lib/cparse.mjs';
/**
* Deutsch Unit 1 ("Neue Freunde").
*
* The desktop app defines each exercise's data as a `const` array next to its
* `build_*` function; the task order and titles come from
* `build_exercise_page` in main.c and `u1_ex_names` in globals.c.
*/
const opt = (v) => (v === null || v === undefined || v === '' ? undefined : String(v));
/** Split the pipe-separated accepted-answer lists used by TypedQ. */
const alternatives = (s) => String(s).split('|').map((a) => a.trim()).filter(Boolean);
/** `{before, after, answer}` becomes a single-gap fill sentence. */
function verbFill(row, meaning, extra = {}) {
const [before, after, answer] = row;
return {
kind: 'fill',
segments: [before ?? '', after ?? ''],
answers: [String(answer)],
input: 'select',
...(meaning ? { meaning: String(meaning) } : {}),
...extra,
};
}
export function extractDeutschUnit1(desktopDir) {
const u1 = readFileSync(`${desktopDir}/exercise_u1.c`, 'utf8');
const common = readFileSync(`${desktopDir}/exercise_common.c`, 'utf8');
const tasks = [];
/* 1 — Dialog: three dialogues sharing one word pool, one gap per line. */
{
const meanings = parseArray(common, 'ex1_meaning');
const pool = parseArray(common, 'ex1_pool');
const dialogues = [
['Sich vorstellen', parseArray(common, 'ex1_d1')],
['Begrüßung', parseArray(common, 'ex1_d2')],
['Verabschiedung', parseArray(common, 'ex1_d3')],
];
let i = 0;
const items = dialogues.map(([title, rows]) => ({
kind: 'dialogue',
title,
rows: rows.map(([speaker, before, answer, after]) => ({
speaker: speaker ?? '',
before: before ?? '',
answer: String(answer),
after: after ?? '',
meaning: opt(meanings[i++]),
})),
}));
tasks.push({ number: 1, title: 'Dialog', subtitleKey: 'sub_dialog', pool, items });
}
/* 2 — Sätze bilden */
{
const meanings = parseArray(common, 'ex2_meaning');
tasks.push({
number: 2,
title: 'Sätze bilden',
subtitleKey: 'sub_assembly',
items: parseArray(common, 'ex2_items').map(([prompt, words], i) => ({
kind: 'assembly',
prompt: opt(prompt) ?? '',
words: trimNulls(words).map(String),
meaning: opt(meanings[i]),
})),
});
}
/* 3 + 9 — multiple choice */
const choiceTask = (number, title, subtitleKey, qsName, meanName) => {
const meanings = parseArray(u1, meanName);
return {
number,
title,
subtitleKey,
items: parseArray(u1, qsName).map(([prompt, options, n, correct], i) => ({
kind: 'choice',
prompt: String(prompt),
options: trimNulls(options).map(String).slice(0, n),
correct,
meaning: opt(meanings[i]),
})),
};
};
tasks.push(choiceTask(3, 'Was ist richtig?', 'sub_choice_num', 'ex3_questions', 'ex3_meaning'));
/* 4 — Freie Antwort */
tasks.push({
number: 4,
title: 'Freie Antwort',
subtitleKey: 'sub_free',
items: parseArray(u1, 'ex4_questions').map(([question, questionCs, sample, sampleCs]) => ({
kind: 'free',
question: String(question),
questionCs: opt(questionCs),
sample: String(sample),
sampleCs: opt(sampleCs),
})),
});
/* 5 — Zahlen: digits shown as the clue, pick the German word. */
{
const meanings = parseArray(u1, 'ex5_meaning');
tasks.push({
number: 5,
title: 'Zahlen',
subtitleKey: 'sub_zahlen',
pool: parseArray(u1, 'ex5_pool'),
items: parseArray(u1, 'ex5_data').map(([digits, answer], i) => ({
kind: 'fill',
segments: ['', ''],
answers: [String(answer)],
input: 'select',
clue: String(digits),
meaning: opt(meanings[i]),
})),
});
}
/* 6 — Wie viel?: the emoji is repeated `count` times as the clue. */
{
const meanings = parseArray(u1, 'ex6_meaning');
tasks.push({
number: 6,
title: 'Wie viel?',
subtitleKey: 'sub_wieviel',
pool: parseArray(u1, 'ex6_pool'),
items: parseArray(u1, 'ex6_data').map(([emoji, count, noun, answer], i) => ({
kind: 'fill',
segments: ['', ` ${noun}`],
answers: [String(answer)],
input: 'select',
emoji: Array.from({ length: count }, () => emoji).join(' '),
meaning: opt(meanings[i]),
})),
});
}
/* 7 — Zahlenreihe */
{
const meanings = parseArray(u1, 'ex7_meaning');
tasks.push({
number: 7,
title: 'Zahlenreihe',
subtitleKey: 'sub_reihe',
pool: parseArray(u1, 'ex7_pool'),
items: parseArray(u1, 'ex7_data').map((row, i) => verbFill(row, meanings[i])),
});
}
/* 8 — Verb einsetzen */
{
const meanings = parseArray(u1, 'ex8_meaning');
tasks.push({
number: 8,
title: 'Verb einsetzen',
subtitleKey: 'sub_verb',
pool: parseArray(u1, 'ex8_pool'),
items: parseArray(u1, 'ex8_data').map((row, i) => verbFill(row, meanings[i])),
});
}
tasks.push(choiceTask(9, 'Wer? Wie? Wo?', 'sub_wer', 'ex9_questions', 'ex9_meaning'));
/* 10 — Wörter trennen: split a run-together string into words. */
{
const meanings = parseArray(u1, 'ex10_meaning');
tasks.push({
number: 10,
title: 'Wörter trennen',
subtitleKey: 'sub_assembly',
items: parseArray(u1, 'ex10_items').map(([prompt, words], i) => ({
kind: 'assembly',
prompt: opt(prompt) ?? '',
words: trimNulls(words).map(String),
meaning: opt(meanings[i]),
})),
});
}
/* 11 + 13 — sort items into groups */
const assignTask = (number, title, subtitleKey, itemsName, groupsName, meanName) => {
const meanings = parseArray(u1, meanName);
return {
number,
title,
subtitleKey,
items: [{
kind: 'assign',
groups: parseArray(u1, groupsName).map(String),
items: parseArray(u1, itemsName).map(([emoji, label, group], i) => ({
emoji: emoji ?? null,
label: String(label),
group,
meaning: opt(meanings[i]),
})),
}],
};
};
tasks.push(assignTask(11, 'Grußformen', 'sub_gruss', 'ex11_items', 'ex11_groups', 'ex11_meaning'));
/* 12 — Was macht er/sie? */
{
const meanings = parseArray(u1, 'ex12_meaning');
tasks.push({
number: 12,
title: 'Was macht er/sie?',
subtitleKey: 'sub_bild',
pool: parseArray(u1, 'ex12_pool'),
items: parseArray(u1, 'ex12_data').map(([emoji, before, after, answer], i) =>
verbFill([before, after, answer], meanings[i], { emoji: String(emoji) })),
});
}
tasks.push(assignTask(13, 'Länder', 'sub_land', 'ex13_items', 'ex13_groups', 'ex13_meaning'));
/* Vocabulary trainer branch: one section per textbook page. */
const vocab = parseArray(u1, 'u1_trans_sections').map(([header, arrayRef]) => {
const name = arrayRef?.__c;
if (!name) throw new Error(`vocab section '${header}' has no data array`);
return {
header: String(header),
items: parseArray(u1, name).map(([prompt, answers, meaning]) => ({
prompt: String(prompt),
answers: alternatives(answers),
meaning: opt(meaning),
})),
};
});
return {
id: 'unit-01',
subject: 'deutsch',
number: 1,
title: { cs: 'Neue Freunde', en: 'Neue Freunde' },
slides: [],
tasks,
vocab,
};
}

View file

@ -0,0 +1,73 @@
import { readFileSync } from 'node:fs';
import { parseArrayInFunction, trimNulls } from '../lib/cparse.mjs';
/**
* Hardware ("Technické vybavení").
*
* Structurally identical to the network lessons: each `build_hw_unitN_page`
* holds a local `NetSlide slides[]` and each `build_hw_unitN_exercise_page`
* holds a local `ChoiceQ qs[]` with a parallel `hints[]`.
*/
const HW_LESSONS = 3;
function toSlide(row, label) {
if (!Array.isArray(row)) throw new Error(`${label}: slide is not a braced list`);
const [kicker, title, tip, lines] = row;
return {
kicker: kicker ?? '',
title: title ?? '',
tip: tip ?? null,
lines: trimNulls(lines ?? []).map(String),
};
}
function toChoice(row, hint, label) {
if (!Array.isArray(row)) throw new Error(`${label}: question is not a braced list`);
const [prompt, options, nOptions, correct] = row;
const list = trimNulls(options ?? []).map(String);
const n = typeof nOptions === 'number' ? nOptions : list.length;
if (typeof correct !== 'number' || correct < 0 || correct >= n) {
throw new Error(`${label}: correct index ${correct} out of range for ${n} options`);
}
return {
kind: 'choice',
prompt: String(prompt),
options: list.slice(0, n),
correct,
...(hint ? { hint: String(hint) } : {}),
};
}
export function extractHardware(desktopDir) {
const src = readFileSync(`${desktopDir}/hw.c`, 'utf8');
const lessons = [];
for (let n = 1; n <= HW_LESSONS; n++) {
const slides = parseArrayInFunction(src, `build_hw_unit${n}_page`, 'slides')
.map((row, i) => toSlide(row, `hw unit ${n} slide ${i + 1}`));
const exFn = `build_hw_unit${n}_exercise_page`;
const qs = parseArrayInFunction(src, exFn, 'qs');
const hints = parseArrayInFunction(src, exFn, 'hints');
lessons.push({
id: `unit-${String(n).padStart(2, '0')}`,
subject: 'hardware',
number: n,
titleKey: `hw_unit${n}`,
subtitleKey: `hw_unit${n}_sub`,
slides,
tasks: [{
number: 1,
title: 'Quiz',
subtitleKey: `hw_quiz${n}_head`,
items: qs.map((row, i) => toChoice(row, hints[i], `hw unit ${n} q${i + 1}`)),
}],
});
}
return lessons;
}

35
tools/extract/i18n.mjs Normal file
View file

@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs';
import { parseArray } from '../lib/cparse.mjs';
/**
* `tr_ui` holds UI chrome keyed by short identifiers; `tr_content` holds
* lesson text keyed by the Czech string itself (its `cs` column is usually
* NULL, meaning "the key already is the Czech text").
*/
function toTable(entries, { keyIsCzech }) {
const cs = {};
const en = {};
for (const row of entries) {
if (!Array.isArray(row)) continue;
const [key, csText, enText] = row;
if (key === null || key === undefined) continue; // terminator
cs[key] = csText ?? (keyIsCzech ? key : null);
if (enText !== null && enText !== undefined) en[key] = enText;
}
// A null Czech value in tr_ui would render as an empty label; drop it.
for (const k of Object.keys(cs)) if (cs[k] === null) delete cs[k];
return { cs, en };
}
export function extractI18n(desktopDir) {
const src = readFileSync(`${desktopDir}/i18n.c`, 'utf8');
return {
ui: toTable(parseArray(src, 'tr_ui'), { keyIsCzech: false }),
content: toTable(parseArray(src, 'tr_content'), { keyIsCzech: true }),
};
}

145
tools/extract/mluvnice.mjs Normal file
View file

@ -0,0 +1,145 @@
import { readFileSync } from 'node:fs';
import { parseArray, trimNulls } from '../lib/cparse.mjs';
/**
* Czech grammar ("Mluvnice"), 20 exercises in one lesson.
*
* Unlike the other subjects, mluvnice.c passes its instructions as literal
* Czech strings rather than i18n keys, so they are copied verbatim from the
* `build_mluvnice_exercise_page` switch below.
*/
/** One row per `case` in `build_mluvnice_exercise_page`, in the same order. */
const EXERCISES = [
['typed', 'Pravopis – i/y po obojetných souhláskách', 'Doplňte i/í nebo y/ý.', null],
['choice', 'Pravopis – velká/malá písmena', 'Vyberte správnou variantu.', null],
['typed', 'Slovní druhy', 'Určete slovní druh podtržených slov.',
'Do pole napište název slovního druhu.'],
['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.'],
['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ý“.'],
['reveal', 'Slovotvorba', 'Rozeberte slova na morfémy.',
'Určete předponu, kořen, příponu a koncovku.'],
['typed', 'Větné členy', 'Určete větný člen podtržených výrazů.', null],
['typed', 'Druhy vedlejších vět', 'Určete druh vedlejší věty.', null],
['typed', 'Synonyma', 'Ke každému slovu napište synonymum.', null],
['typed', 'Antonyma', 'Napište opak.', null],
['typed', 'Pravopis – s/z na začátku slova', 'Vyberte správnou předponu s-/z-.', null],
['reveal', 'Skladba – najděte chybu',
'Každá věta obsahuje jednu chybu. Najděte ji a opravte.', null],
['typed', 'Tvarosloví – správný tvar', 'Dejte slovo do správného tvaru.', null],
['choice', 'Výběr ze čtyř možností', 'Vyberte správnou odpověď.', null],
['reveal', 'Přímá a nepřímá řeč', 'Přepište přímou řeč na nepřímou.', null],
['reveal', 'Slovní zásoba – rozdíly', 'Vysvětlete rozdíl mezi těmito dvojicemi slov.', null],
['reveal', 'Interpunkce', 'Doplňte čárky tam, kde patří.', null],
['typed', 'Obrazná pojmenování',
'Určete, o jaký druh obrazného pojmenování jde.', null],
['typed', 'Stylistika – slohové útvary',
'Přiřaďte ukázku ke správnému slohovému útvaru.', null],
['choice', 'Souhrnné opakování', 'Vyberte správnou odpověď.', null],
];
/** Only exercise 14 ships per-question explanations. */
const CHOICE_EXPLANATIONS = { 14: 'e14_expl' };
/** `MluvItem`: prompt, shown, nblank, hint[5], answers[5]. */
function fieldsItem(row, label) {
const [prompt, shown, nblank, hints, answers] = row;
if (typeof nblank !== 'number' || nblank < 1) {
throw new Error(`${label}: expected at least one blank, got ${nblank}`);
}
return {
kind: 'fields',
prompt: String(prompt),
fields: Array.from({ length: nblank }, (_, i) => ({
label: String(hints[i] ?? ''),
answers: String(answers[i] ?? '').split('|').map((a) => a.trim()).filter(Boolean),
})),
...(shown ? { solution: String(shown) } : {}),
};
}
function choiceItem(row, expl, label) {
const [prompt, options, nOptions, correct] = row;
const list = trimNulls(options ?? []).map(String);
const n = typeof nOptions === 'number' ? nOptions : list.length;
if (typeof correct !== 'number' || correct < 0 || correct >= n) {
throw new Error(`${label}: correct index ${correct} out of range for ${n} options`);
}
return {
kind: 'choice',
prompt: String(prompt),
options: list.slice(0, n),
correct,
...(expl ? { hint: String(expl) } : {}),
};
}
export function extractMluvnice(desktopDir) {
const src = readFileSync(`${desktopDir}/mluvnice.c`, 'utf8');
const names = parseArray(src, 'mluv_names').map(String);
if (names.length !== EXERCISES.length) {
throw new Error(`mluv_names has ${names.length} entries but the extractor knows ${EXERCISES.length}`);
}
const tasks = EXERCISES.map(([type, heading, subtitle, note], index) => {
const number = index + 1;
const label = `mluvnice ex${number}`;
const base = {
number,
// Short label for the map bubble; the page shows the full heading.
title: names[index],
heading,
subtitleKey: null,
subtitle,
...(note ? { note } : {}),
};
if (type === 'choice') {
const explName = CHOICE_EXPLANATIONS[number];
const expls = explName ? parseArray(src, explName).map(String) : [];
return {
...base,
items: parseArray(src, `e${number}_qs`)
.map((row, i) => choiceItem(row, expls[i], `${label} q${i + 1}`)),
};
}
if (type === 'reveal') {
return {
...base,
items: parseArray(src, `e${number}_items`).map(([prompt, solution]) => ({
kind: 'reveal',
prompt: String(prompt),
solution: String(solution),
})),
};
}
return {
...base,
items: parseArray(src, `e${number}_items`)
.map((row, i) => fieldsItem(row, `${label} item ${i + 1}`)),
};
});
return {
id: 'mluvnice',
subject: 'czech',
number: 1,
title: { cs: 'Mluvnice', en: 'Czech grammar' },
subtitle: {
cs: 'Vyberte cvičení a dokončete je.',
en: 'Pick an exercise and complete it.',
},
slides: [],
tasks,
};
}

View file

@ -0,0 +1,97 @@
import { readFileSync } from 'node:fs';
import { parseArray, parseArrayInFunction, trimNulls } from '../lib/cparse.mjs';
const NET_LESSONS = 27;
/** `NetSlide` is positional: kicker, title, tip, line[8]. */
function toSlide(row, label) {
if (!Array.isArray(row)) throw new Error(`${label}: slide is not a braced list`);
const [kicker, title, tip, lines] = row;
return {
kicker: kicker ?? '',
title: title ?? '',
tip: tip ?? null,
lines: trimNulls(lines ?? []).map((l) => String(l)),
};
}
/** `ChoiceQ` is positional: prompt, options[5], n_options, correct. */
function toChoice(row, hint, label) {
if (!Array.isArray(row)) throw new Error(`${label}: question is not a braced list`);
const [prompt, options, nOptions, correct] = row;
const list = trimNulls(options ?? []).map((o) => String(o));
const n = typeof nOptions === 'number' ? nOptions : list.length;
if (typeof correct !== 'number' || correct < 0 || correct >= n) {
throw new Error(`${label}: correct index ${correct} out of range for ${n} options`);
}
const out = { kind: 'choice', prompt: String(prompt), options: list.slice(0, n), correct };
if (hint) out.hint = String(hint);
return out;
}
/** Unit 1 is the VLSM walkthrough: four scenarios plus a per-block answer key. */
function subnetTasks(src) {
const tasks = parseArray(src, 'net_tasks');
const answers = parseArray(src, 'net_ans');
return tasks.map(([scenario, solution], i) => {
const rows = answers[i] ?? [];
const blocks = rows
.map(([prefix, network, broadcast, firstHost, lastHost]) => ({
prefix, network, broadcast, firstHost, lastHost,
}))
// A prefix of -1 marks a scenario that has no valid allocation.
.filter((b) => typeof b.prefix === 'number' && b.prefix > 0);
return {
kind: 'subnet',
scenario: String(scenario),
solution: String(solution),
blocks: blocks.length ? blocks : null,
};
});
}
export function extractNetworks(desktopDir) {
const src = readFileSync(`${desktopDir}/net.c`, 'utf8');
const lessons = [];
for (let n = 1; n <= NET_LESSONS; n++) {
const slides = parseArrayInFunction(src, `build_net_unit${n}_page`, 'slides')
.map((row, i) => toSlide(row, `net unit ${n} slide ${i + 1}`));
let tasks;
if (n === 1) {
tasks = [{
number: 1,
title: 'VLSM',
subtitleKey: 'net_ex1_title',
items: subnetTasks(src),
}];
} else {
const qs = parseArray(src, `net${n}_qs`);
const hints = parseArray(src, `net${n}_hints`);
tasks = [{
number: 1,
title: 'Quiz',
subtitleKey: `net_quiz${n}_head`,
items: qs.map((row, i) => toChoice(row, hints[i], `net unit ${n} q${i + 1}`)),
}];
}
lessons.push({
id: `unit-${String(n).padStart(2, '0')}`,
subject: 'networks',
number: n,
// Titles live in the i18n tables; the web app resolves these keys.
titleKey: `net_unit${n}`,
subtitleKey: `net_unit${n}_sub`,
slides,
tasks,
});
}
return lessons;
}

48
tools/extract/themes.mjs Normal file
View file

@ -0,0 +1,48 @@
import { readFileSync } from 'node:fs';
import { parseStruct } from '../lib/cparse.mjs';
/** Field order of `ThemePalette` in maturita.h, as the C initializers are positional. */
const FIELDS = [
'crust', 'base', 'mantle', 'surface0', 'surface1', 'surface2',
'overlay', 'text', 'subtext', 'accent', 'accent2', 'accent3',
'success', 'success2', 'warning', 'error', 'onAccent',
'node', 'lockedBg', 'lockedBorder', 'rail',
'finishDark', 'finishLight', 'finishStroke',
];
/** slug, display name, and the C variable prefix used in theme.c. */
const THEMES = [
['catppuccin', 'Catppuccin', 'cat'],
['nord', 'Nord', 'nord'],
['dracula', 'Dracula', 'dra'],
['rose-pine', 'Rose Pine', 'rose'],
['ocean', 'Ocean', 'ocean'],
['gruvbox', 'Gruvbox', 'gruv'],
['solarized', 'Solarized', 'sol'],
['everforest', 'Everforest', 'ever'],
['monokai', 'Monokai', 'mono'],
['one-dark', 'One Dark', 'one'],
];
function toPalette(values, label) {
if (values.length !== FIELDS.length) {
throw new Error(`${label}: expected ${FIELDS.length} colors, got ${values.length}`);
}
const out = {};
values.forEach((v, i) => {
if (typeof v !== 'number') throw new Error(`${label}: field ${FIELDS[i]} is not a hex literal`);
out[FIELDS[i]] = '#' + v.toString(16).padStart(6, '0');
});
return out;
}
export function extractThemes(desktopDir) {
const src = readFileSync(`${desktopDir}/theme.c`, 'utf8');
return THEMES.map(([id, name, prefix]) => ({
id,
name,
dark: toPalette(parseStruct(src, `${prefix}_dark`), `${id} dark`),
light: toPalette(parseStruct(src, `${prefix}_light`), `${id} light`),
}));
}