mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
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:
parent
4708c69d9c
commit
f7470d333c
113 changed files with 25817 additions and 9 deletions
114
tools/extract/cetba.mjs
Normal file
114
tools/extract/cetba.mjs
Normal 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] } : {}),
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
196
tools/extract/deutsch-helpers.mjs
Normal file
196
tools/extract/deutsch-helpers.mjs
Normal 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) } : {}),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
159
tools/extract/deutsch-u2.mjs
Normal file
159
tools/extract/deutsch-u2.mjs
Normal 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'),
|
||||
};
|
||||
}
|
||||
174
tools/extract/deutsch-u3.mjs
Normal file
174
tools/extract/deutsch-u3.mjs
Normal 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
249
tools/extract/deutsch.mjs
Normal 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,
|
||||
};
|
||||
}
|
||||
73
tools/extract/hardware.mjs
Normal file
73
tools/extract/hardware.mjs
Normal 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
35
tools/extract/i18n.mjs
Normal 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
145
tools/extract/mluvnice.mjs
Normal 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,
|
||||
};
|
||||
}
|
||||
97
tools/extract/networks.mjs
Normal file
97
tools/extract/networks.mjs
Normal 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
48
tools/extract/themes.mjs
Normal 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`),
|
||||
}));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue