mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
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>
145 lines
5.3 KiB
JavaScript
145 lines
5.3 KiB
JavaScript
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,
|
||
};
|
||
}
|