Graduately/tools/extract/mluvnice.mjs
FBukovina f7470d333c 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>
2026-09-17 16:06:15 +02:00

145 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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