mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 23:24:34 +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
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) } : {}),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue