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>
196 lines
6 KiB
JavaScript
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) } : {}),
|
|
})),
|
|
};
|
|
});
|
|
}
|