Graduately/tools/extract/deutsch-helpers.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

196 lines
6 KiB
JavaScript

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