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

239
tools/build-content.mjs Normal file
View file

@ -0,0 +1,239 @@
#!/usr/bin/env node
/**
* Regenerate `content/` from the desktop C sources.
*
* The C app remains the authoring surface for now, so this is a one-way
* export: run it after changing lesson data in `desktop/` to refresh the JSON
* the web app reads. It is deliberately strict — a shape it does not recognise
* throws rather than emitting a half-empty lesson.
*/
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArray } from './lib/cparse.mjs';
import { readFileSync } from 'node:fs';
import { extractThemes } from './extract/themes.mjs';
import { extractI18n } from './extract/i18n.mjs';
import { extractNetworks } from './extract/networks.mjs';
import { extractDeutschUnit1 } from './extract/deutsch.mjs';
import { extractDeutschUnit2 } from './extract/deutsch-u2.mjs';
import { extractDeutschUnit3 } from './extract/deutsch-u3.mjs';
import { extractHardware } from './extract/hardware.mjs';
import { extractMluvnice } from './extract/mluvnice.mjs';
import { extractCetba } from './extract/cetba.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const DESKTOP = resolve(ROOT, 'desktop');
const CONTENT = resolve(ROOT, 'content');
const write = (relPath, data) => {
const file = resolve(CONTENT, relPath);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
return relPath;
};
/* ------------------------------------------------------------- subjects --- */
/** Mirrors `sub_keys` in subjects.c; only four subjects have content today. */
const SUBJECTS = [
{ id: 'deutsch', icon: 'flag-de', route: '/deutsch' },
{ id: 'networks', icon: 'wifi', route: '/networks' },
{ id: 'hardware', icon: 'chip', route: '/hardware' },
{ id: 'czech', icon: 'book', route: '/czech' },
{ id: 'civics', icon: null, route: null },
{ id: 'english', icon: null, route: null },
{ id: 'math', icon: null, route: null },
{ id: 'physics', icon: null, route: null },
{ id: 'science', icon: null, route: null },
{ id: 'tech-graphics', icon: null, route: null },
{ id: 'presentation', icon: null, route: null },
{ id: 'programming', icon: null, route: null },
{ id: 'software', icon: null, route: null },
];
function extractSubjects() {
const keys = parseArray(readFileSync(`${DESKTOP}/subjects.c`, 'utf8'), 'sub_keys').map(String);
if (keys.length !== SUBJECTS.length) {
throw new Error(`subjects.c has ${keys.length} keys but the extractor knows ${SUBJECTS.length}`);
}
return SUBJECTS.map((s, i) => ({ ...s, key: keys[i], unlocked: s.route !== null }));
}
/* ---------------------------------------------------------------- paths --- */
/** German unit titles from `unit_meta_init` in main.c. */
const DEUTSCH_TITLES = [
'Neue Freunde', 'Aus aller Welt', 'Bei uns zu Hause',
'Schule und Freizeit', 'Guten Appetit!', 'Mein Tagesablauf',
'Meine Freunde', 'Wir treffen uns in Salzburg',
'Mein Haus ist meine Burg', 'Urlaub in Österreich',
];
/**
* `HW_UNITS` bubbles are drawn but only `HW_LESSONS` of them are playable, so
* the trailing nodes stay locked placeholders as they do on the desktop map.
*/
const HW_UNITS = 10;
/** `cz_keys` in czech.c; "Literatura" has no content yet. */
const CZECH_NODES = [
{ number: 1, title: 'Literatura', route: null },
{ number: 2, title: 'Mluvnice', route: '/czech/mluvnice' },
{ number: 3, title: 'Maturitní četba', route: '/czech/reading' },
];
function buildPaths({ networkLessons, deutschLessons, hardwareLessons, cetbaLessons }) {
const done = new Set(deutschLessons.map((l) => l.number));
const deutsch = {
subject: 'deutsch',
units: DEUTSCH_TITLES.map((title, i) => ({
number: i + 1,
// Units 1-3 are playable in the desktop app; the rest are placeholders.
lesson: done.has(i + 1) ? `unit-${String(i + 1).padStart(2, '0')}` : null,
title: { cs: title, en: title },
unlocked: i < 3 && done.has(i + 1),
})),
};
const networks = {
subject: 'networks',
year: 1,
units: networkLessons.map((l) => ({
number: l.number,
lesson: l.id,
titleKey: l.titleKey,
unlocked: true,
})),
};
const byNumber = new Map(hardwareLessons.map((l) => [l.number, l]));
const hardware = {
subject: 'hardware',
units: Array.from({ length: HW_UNITS }, (_, i) => {
const lesson = byNumber.get(i + 1);
return {
number: i + 1,
lesson: lesson?.id ?? null,
titleKey: lesson?.titleKey ?? `hw_unit${i + 1}`,
unlocked: Boolean(lesson),
};
}),
};
const czech = {
subject: 'czech',
units: CZECH_NODES.map((node) => ({
number: node.number,
lesson: null,
route: node.route,
title: { cs: node.title, en: node.title },
unlocked: node.route !== null,
})),
};
const reading = {
subject: 'czech',
branch: 'reading',
units: cetbaLessons.map((l, i) => ({
number: i + 1,
lesson: l.id,
title: l.title,
unlocked: true,
})),
};
return [deutsch, networks, hardware, czech, reading];
}
/* ----------------------------------------------------------------- main --- */
function main() {
rmSync(resolve(CONTENT, 'lessons'), { recursive: true, force: true });
const written = [];
written.push(write('themes.json', extractThemes(DESKTOP)));
const i18n = extractI18n(DESKTOP);
written.push(write('i18n/ui.json', i18n.ui));
written.push(write('i18n/content.json', i18n.content));
const subjects = extractSubjects();
written.push(write('subjects.json', subjects));
const networkLessons = extractNetworks(DESKTOP);
for (const lesson of networkLessons) {
written.push(write(`lessons/networks/${lesson.id}.json`, lesson));
}
const deutschLessons = [
extractDeutschUnit1(DESKTOP),
extractDeutschUnit2(DESKTOP),
extractDeutschUnit3(DESKTOP),
];
for (const lesson of deutschLessons) {
written.push(write(`lessons/deutsch/${lesson.id}.json`, lesson));
}
const hardwareLessons = extractHardware(DESKTOP);
for (const lesson of hardwareLessons) {
written.push(write(`lessons/hardware/${lesson.id}.json`, lesson));
}
const czechLessons = [extractMluvnice(DESKTOP), ...extractCetba(DESKTOP)];
for (const lesson of czechLessons) {
written.push(write(`lessons/czech/${lesson.id}.json`, lesson));
}
const cetbaLessons = czechLessons.filter((l) => l.id !== 'mluvnice');
const paths = buildPaths({
networkLessons,
deutschLessons,
hardwareLessons,
cetbaLessons,
});
written.push(write('paths.json', paths));
written.push(write('manifest.json', {
subjects: subjects.map((s) => s.id),
lessons: {
deutsch: deutschLessons.map((l) => l.id),
networks: networkLessons.map((l) => l.id),
hardware: hardwareLessons.map((l) => l.id),
czech: czechLessons.map((l) => l.id),
},
}));
const stats = {
'network lessons': networkLessons.length,
'network slides': networkLessons.reduce((a, l) => a + l.slides.length, 0),
'network items': networkLessons.reduce(
(a, l) => a + l.tasks.reduce((b, t) => b + t.items.length, 0), 0),
'deutsch tasks': deutschLessons.reduce((a, l) => a + l.tasks.length, 0),
'deutsch items': deutschLessons.reduce(
(a, l) => a + l.tasks.reduce((b, t) => b + t.items.length, 0), 0),
'vocab words': deutschLessons.reduce(
(a, l) => a + (l.vocab ?? []).reduce((b, s) => b + s.items.length, 0), 0),
'hardware lessons': hardwareLessons.length,
'hardware slides': hardwareLessons.reduce((a, l) => a + l.slides.length, 0),
'hardware items': hardwareLessons.reduce(
(a, l) => a + l.tasks.reduce((b, t) => b + t.items.length, 0), 0),
'czech lessons': czechLessons.length,
'czech tasks': czechLessons.reduce((a, l) => a + l.tasks.length, 0),
'czech items': czechLessons.reduce(
(a, l) => a + l.tasks.reduce((b, t) => b + t.items.length, 0), 0),
'ui strings': Object.keys(i18n.ui.cs).length,
'content strings': Object.keys(i18n.content.cs).length,
themes: 10,
};
console.log(`wrote ${written.length} files to content/`);
for (const [k, v] of Object.entries(stats)) console.log(` ${k}: ${v}`);
}
main();

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`),
}));
}

354
tools/lib/cparse.mjs Normal file
View file

@ -0,0 +1,354 @@
/**
* Minimal reader for the C initializer syntax used by the desktop sources.
*
* It understands only what the lesson data actually uses: string literals with
* adjacent concatenation, char literals, integers, `NULL`, identifiers,
* designated initializers and nested braces. Anything else throws so a bad
* extraction fails loudly instead of silently producing empty lessons.
*/
const IDENT_START = /[A-Za-z_]/;
const IDENT_PART = /[A-Za-z0-9_]/;
/** Strip comments while preserving string/char literals verbatim. */
export function stripComments(src) {
let out = '';
let i = 0;
while (i < src.length) {
const c = src[i];
if (c === '"' || c === "'") {
const quote = c;
out += c;
i++;
while (i < src.length) {
if (src[i] === '\\') {
out += src[i] + (src[i + 1] ?? '');
i += 2;
continue;
}
out += src[i];
if (src[i] === quote) {
i++;
break;
}
i++;
}
continue;
}
if (c === '/' && src[i + 1] === '*') {
const end = src.indexOf('*/', i + 2);
i = end === -1 ? src.length : end + 2;
out += ' ';
continue;
}
if (c === '/' && src[i + 1] === '/') {
const end = src.indexOf('\n', i);
i = end === -1 ? src.length : end;
out += ' ';
continue;
}
out += c;
i++;
}
return out;
}
const ESCAPES = {
n: '\n',
t: '\t',
r: '\r',
'0': '\0',
'\\': '\\',
'"': '"',
"'": "'",
a: '\x07',
b: '\b',
f: '\f',
v: '\v',
};
class Reader {
constructor(src, label) {
this.src = src;
this.label = label;
this.i = 0;
}
fail(msg) {
const at = this.src.slice(Math.max(0, this.i - 60), this.i + 60);
throw new Error(`${this.label}: ${msg} near\n...${at}...`);
}
ws() {
while (this.i < this.src.length && /\s/.test(this.src[this.i])) this.i++;
}
peek() {
this.ws();
return this.src[this.i];
}
eat(ch) {
this.ws();
if (this.src[this.i] !== ch) this.fail(`expected '${ch}', got '${this.src[this.i]}'`);
this.i++;
}
tryEat(ch) {
this.ws();
if (this.src[this.i] === ch) {
this.i++;
return true;
}
return false;
}
/** One string literal, honouring C's adjacent-literal concatenation. */
readString() {
let value = '';
let first = true;
for (;;) {
this.ws();
if (this.src[this.i] !== '"') {
if (first) this.fail('expected string literal');
return value;
}
first = false;
this.i++;
while (this.i < this.src.length && this.src[this.i] !== '"') {
if (this.src[this.i] === '\\') {
const esc = this.src[this.i + 1];
if (esc in ESCAPES) {
value += ESCAPES[esc];
this.i += 2;
continue;
}
if (esc === 'u' || esc === 'x') {
const m = /^\\(u[0-9a-fA-F]{4}|x[0-9a-fA-F]+)/.exec(this.src.slice(this.i));
if (!m) this.fail('bad escape');
const hex = m[1].slice(1);
value += String.fromCodePoint(parseInt(hex, 16));
this.i += m[0].length;
continue;
}
value += esc;
this.i += 2;
continue;
}
value += this.src[this.i];
this.i++;
}
if (this.src[this.i] !== '"') this.fail('unterminated string');
this.i++;
}
}
readIdent() {
this.ws();
if (!IDENT_START.test(this.src[this.i] ?? '')) this.fail('expected identifier');
let start = this.i;
while (this.i < this.src.length && IDENT_PART.test(this.src[this.i])) this.i++;
return this.src.slice(start, this.i);
}
/**
* A single initializer value. Braces become arrays, or plain objects when the
* body uses designated initializers.
*/
readValue() {
this.ws();
const c = this.src[this.i];
if (c === undefined) this.fail('unexpected end of input');
if (c === '"') return this.readString();
if (c === '{') return this.readBraced();
// Cast prefix such as `(int)` or `(const char *)`: skip it and read on.
if (c === '(') {
const cast = /^\(\s*(?:const\s+|unsigned\s+|signed\s+)*[A-Za-z_]\w*\s*\**\s*\)/.exec(
this.src.slice(this.i)
);
if (cast) {
this.i += cast[0].length;
return this.readValue();
}
}
if (c === "'") {
this.i++;
let ch;
if (this.src[this.i] === '\\') {
const esc = this.src[this.i + 1];
ch = esc in ESCAPES ? ESCAPES[esc] : esc;
this.i += 2;
} else {
ch = this.src[this.i];
this.i++;
}
this.eat("'");
return ch;
}
// Numeric or simple arithmetic/negation, e.g. `-1`, `4`, `G_N_ELEMENTS(x)`.
const numMatch = /^[+-]?(?:0[xX][0-9a-fA-F]+|\d+)(?![\w.])/.exec(this.src.slice(this.i));
if (numMatch) {
this.i += numMatch[0].length;
return Number(numMatch[0]);
}
if (IDENT_START.test(c)) {
const name = this.readIdent();
if (name === 'NULL') return null;
if (name === 'TRUE') return true;
if (name === 'FALSE') return false;
// Macro call or bare constant: keep the source text for the caller.
if (this.peek() === '(') {
let depth = 0;
const start = this.i;
while (this.i < this.src.length) {
if (this.src[this.i] === '(') depth++;
else if (this.src[this.i] === ')') {
depth--;
if (depth === 0) {
this.i++;
break;
}
}
this.i++;
}
return { __c: name + this.src.slice(start, this.i) };
}
return { __c: name };
}
this.fail(`unexpected character '${c}'`);
}
readBraced() {
this.eat('{');
const list = [];
const obj = {};
let designated = false;
for (;;) {
if (this.tryEat('}')) break;
if (this.peek() === '.') {
this.i++;
const field = this.readIdent();
this.eat('=');
obj[field] = this.readValue();
designated = true;
} else {
list.push(this.readValue());
}
if (this.tryEat(',')) continue;
this.eat('}');
break;
}
return designated ? obj : list;
}
}
/**
* Find an array/struct definition by name and parse its initializer.
* Matches e.g. `static const ChoiceQ net2_qs[] = { ... };`
*/
export function parseArray(src, name, { label = name } = {}) {
const clean = stripComments(src);
const re = new RegExp(
`\\b${name}\\s*(?:\\[[^\\]]*\\]\\s*)+=\\s*(?=\\{)`,
'g'
);
const m = re.exec(clean);
if (!m) throw new Error(`${label}: array '${name}' not found`);
const reader = new Reader(clean, label);
reader.i = m.index + m[0].length;
const value = reader.readValue();
if (!Array.isArray(value)) throw new Error(`${label}: '${name}' is not a braced list`);
return value;
}
/** Parse a scalar struct definition, e.g. `static const ThemePalette nord_dark = {...};` */
export function parseStruct(src, name, { label = name } = {}) {
const clean = stripComments(src);
const re = new RegExp(`\\b${name}\\s*=\\s*(?=\\{)`, 'g');
const m = re.exec(clean);
if (!m) throw new Error(`${label}: struct '${name}' not found`);
const reader = new Reader(clean, label);
reader.i = m.index + m[0].length;
return reader.readValue();
}
/**
* Same as parseArray but scoped to the body of a single function, so the many
* identically-named `static const NetSlide slides[]` locals stay distinct.
*/
export function parseArrayInFunction(src, fnName, arrayName) {
const clean = stripComments(src);
const fnRe = new RegExp(`\\b${fnName}\\s*\\([^)]*\\)\\s*\\{`, 'g');
const m = fnRe.exec(clean);
if (!m) throw new Error(`function '${fnName}' not found`);
// Walk to the matching closing brace so we only search this body.
let depth = 0;
let i = m.index + m[0].length - 1;
const start = i;
for (; i < clean.length; i++) {
if (clean[i] === '"' || clean[i] === "'") {
const q = clean[i++];
while (i < clean.length && clean[i] !== q) i += clean[i] === '\\' ? 2 : 1;
continue;
}
if (clean[i] === '{') depth++;
else if (clean[i] === '}') {
depth--;
if (depth === 0) break;
}
}
const body = clean.slice(start, i + 1);
return parseArray(body, arrayName, { label: `${fnName}/${arrayName}` });
}
/** Parse a scalar string definition, e.g. `const char *g05_bank = "a, b, c";` */
export function parseString(src, name, { label = name } = {}) {
const clean = stripComments(src);
const re = new RegExp(`\\b${name}\\s*=\\s*(?=")`, 'g');
const m = re.exec(clean);
if (!m) throw new Error(`${label}: string '${name}' not found`);
const reader = new Reader(clean, label);
reader.i = m.index + m[0].length;
return reader.readString();
}
/** Resolve `#define NAME value` integer macros. */
export function parseDefines(src) {
const out = {};
const re = /^[ \t]*#[ \t]*define[ \t]+([A-Za-z_]\w*)[ \t]+([^\n/]+)/gm;
let m;
while ((m = re.exec(src))) {
const raw = m[2].trim();
if (/^-?\d+$/.test(raw)) out[m[1]] = Number(raw);
}
return out;
}
/** Trailing NULLs are array terminators in the C data, not content. */
export function trimNulls(list) {
const out = [...list];
while (out.length && (out.at(-1) === null || out.at(-1) === undefined)) out.pop();
return out;
}