/** * Reads H5P content: the quizzes and interactive exercises on a board. * * Schulcloud has no quiz of its own — an exercise is an H5P element, and the * board API hands over nothing but a `contentId`. The player then shows one * question at a time, which makes a quiz look like something that has to be * stepped through or scraped. It is not: `GET /api/v3/h5p-editor/params/{id}` * returns the entire exercise as the JSON the player is fed, so one request * holds every question, every option and which of them is correct. * * What varies is the *shape* of that JSON, because it belongs to whichever H5P * library the teacher used. The common question types are modelled below; for * anything else the text is harvested generically and labelled as such, so an * exercise this parser does not know still arrives readable instead of empty. */ import type { SchulcloudClient } from './client.ts'; import { htmlToText } from './text.ts'; export interface H5pAnswer { text: string; /** Undefined when the library does not say — never guess a solution. */ correct?: boolean; tip?: string; } export interface H5pQuestion { /** The H5P library, e.g. `H5P.MultiChoice 1.16`. */ library: string; /** A short human name for the kind of task, for the reader. */ kind: string; text: string; answers: H5pAnswer[]; /** True when more than one option is meant to be ticked. */ multiple?: boolean; /** The task's own text where it is not the question: a cloze, a description. */ body?: string; } export interface H5pContent { contentId: string; title: string; /** The main library of the content itself, e.g. `H5P.QuestionSet`. */ library: string; intro?: string; /** Percentage needed to pass, when the content sets one. */ passPercentage?: number; questions: H5pQuestion[]; /** * Text from parts this parser does not model. Never silently dropped: a * teacher's exercise turning up as "0 questions" is the bug this avoids. */ unmodelled: string[]; } /** Fetches and parses one H5P content. Throws if it cannot be read or parsed. */ export async function readH5pContent(client: SchulcloudClient, contentId: string): Promise { return parseH5pParams(contentId, await client.getH5pParams(contentId)); } /** Everything in the content as one string, for the search index. */ export function h5pSearchText(content: H5pContent): string { const parts = [content.title, content.intro]; for (const question of content.questions) { parts.push(question.text, question.body, ...question.answers.map((answer) => answer.text)); } parts.push(...content.unmodelled); return parts.filter((part): part is string => Boolean(part?.trim())).join('\n'); } /** * Interprets a `params` payload. * * Throws on a payload that is not H5P at all rather than returning an empty * exercise — the difference between "this quiz has no questions" and "the * format changed" matters, and only one of them is worth reporting as content. */ export function parseH5pParams(contentId: string, payload: unknown): H5pContent { const root = asRecord(payload); const metadata = asRecord(root?.h5p); // `params.params` is the content itself; `params.metadata` beside it repeats // the H5P metadata. A single-question content has the same shape. const outer = asRecord(root?.params); const params = asRecord(outer?.params) ?? outer; const library = string(metadata?.mainLibrary) ?? string(root?.library) ?? 'unknown'; if (!params) { throw new Error(`H5P content ${contentId} carried no params — the payload shape has changed`); } const title = string(metadata?.title)?.trim() || 'H5P content'; const content: H5pContent = { contentId, title, library, questions: [], unmodelled: [] }; const introPage = asRecord(params.introPage); const intro = htmlToText(string(introPage?.introduction) ?? string(params.intro) ?? '').trim(); if (intro) content.intro = intro; if (typeof params.passPercentage === 'number') content.passPercentage = params.passPercentage; // A QuestionSet holds a list of sub-contents, each with its own library; any // other library *is* the single question. const questions = Array.isArray(params.questions) && params.questions.some((entry) => asRecord(entry)?.library) ? params.questions : undefined; if (questions) { for (const entry of questions) { const record = asRecord(entry); const sub = asRecord(record?.params); const subLibrary = string(record?.library) ?? 'unknown'; if (sub) content.questions.push(...parseQuestion(subLibrary, sub, content.unmodelled)); } } else { content.questions.push(...parseQuestion(library, params, content.unmodelled)); } return content; } /** * One sub-content as questions. * * Returns several for the libraries that bundle them (SingleChoiceSet, * Summary, Blanks) and none for one it cannot read, in which case its text goes * to `unmodelled`. */ function parseQuestion(library: string, params: Record, unmodelled: string[]): H5pQuestion[] { const name = library.split(/\s+/)[0] ?? library; const question = (text: string, answers: H5pAnswer[], extra: Partial = {}): H5pQuestion => ({ library, kind: KIND[name] ?? name.replace(/^H5P\./, ''), text, answers, ...extra, }); switch (name) { case 'H5P.MultiChoice': { const answers = (asArray(params.answers) ?? []).map((entry): H5pAnswer => { const answer = asRecord(entry); const tip = htmlToText(string(asRecord(answer?.tipsAndFeedback)?.tip) ?? '').trim(); return { text: htmlToText(string(answer?.text) ?? '').trim(), correct: answer?.correct === true, ...(tip ? { tip } : {}), }; }); // `singleAnswer` is what the player uses to choose radio buttons over // checkboxes, and it is the only honest way to say "tick one". const single = asRecord(params.behaviour)?.singleAnswer === true; return [ question(htmlToText(string(params.question) ?? '').trim(), answers, { multiple: !single && answers.filter((answer) => answer.correct).length !== 1, }), ]; } case 'H5P.TrueFalse': { const l10n = asRecord(params.l10n); const yes = string(l10n?.trueText) ?? 'Wahr'; const no = string(l10n?.falseText) ?? 'Falsch'; // `correct` is the string "true" or "false", not a boolean. const correct = string(params.correct); return [ question(htmlToText(string(params.question) ?? '').trim(), [ { text: yes, correct: correct === 'true' }, { text: no, correct: correct === 'false' }, ]), ]; } case 'H5P.Blanks': { const description = htmlToText(string(params.text) ?? '').trim(); return (asArray(params.questions) ?? []).flatMap((entry) => { const raw = string(entry); if (!raw) return []; const { text, answers } = parseCloze(raw); return [question(text, answers, { body: description || undefined })]; }); } case 'H5P.DragText': case 'H5P.MarkTheWords': { const raw = string(params.textField) ?? ''; const { text, answers } = parseCloze(raw); const description = htmlToText(string(params.taskDescription) ?? '').trim(); return [question(description || 'Aufgabe', answers, { body: text })]; } case 'H5P.SingleChoiceSet': { return (asArray(params.choices) ?? []).flatMap((entry) => { const choice = asRecord(entry); const texts = (asArray(choice?.answers) ?? []).map((answer) => htmlToText(string(answer) ?? '').trim()); // The first option is the correct one; the player shuffles them. const answers = texts.map((text, index): H5pAnswer => ({ text, correct: index === 0 })); return [question(htmlToText(string(choice?.question) ?? '').trim(), answers)]; }); } case 'H5P.Summary': { return (asArray(params.summaries) ?? []).flatMap((entry) => { const group = asRecord(entry); const texts = (asArray(group?.summary) ?? []).map((item) => htmlToText(string(item) ?? '').trim()); const answers = texts.map((text, index): H5pAnswer => ({ text, correct: index === 0 })); return [question(htmlToText(string(params.intro) ?? '').trim() || 'Welche Aussage stimmt?', answers)]; }); } case 'H5P.Column': { // A column stacks sub-contents; each carries its own library. return (asArray(params.content) ?? []).flatMap((entry) => { const inner = asRecord(asRecord(entry)?.content); const innerParams = asRecord(inner?.params); const innerLibrary = string(inner?.library) ?? 'unknown'; return innerParams ? parseQuestion(innerLibrary, innerParams, unmodelled) : []; }); } default: { const harvested = harvest(params); if (harvested.length > 0) unmodelled.push(`${name}: ${harvested.join(' | ')}`); return []; } } } /** Short names for the libraries worth naming; anything else keeps its own. */ const KIND: Record = { 'H5P.MultiChoice': 'multiple choice', 'H5P.TrueFalse': 'true/false', 'H5P.Blanks': 'fill in the blanks', 'H5P.DragText': 'drag the words', 'H5P.MarkTheWords': 'mark the words', 'H5P.SingleChoiceSet': 'single choice', 'H5P.Summary': 'pick the correct statement', }; /** * A cloze text: H5P marks the solutions inline as `*answer:tip*`, with * alternatives separated by slashes. */ export function parseCloze(raw: string): { text: string; answers: H5pAnswer[] } { const answers: H5pAnswer[] = []; const text = htmlToText( raw.replace(/\*([^*]+)\*/g, (_, body: string) => { const [solutions, tip] = body.split(':'); const alternatives = (solutions ?? '').split('/').map((part) => part.trim()).filter(Boolean); answers.push({ text: alternatives.join(' / '), correct: true, ...(tip?.trim() ? { tip: tip.trim() } : {}), }); return `____ (${answers.length})`; }), ).trim(); return { text, answers }; } /** * Every bit of task text in an unmodelled library. * * Deliberately blunt — it cannot know which field is the question — but it * skips the subtrees that hold button labels and display settings, which * otherwise drown the content in "Überprüfen" and "Wiederholen". */ const SKIP_KEYS = new Set([ 'UI', 'l10n', 'behaviour', 'overallFeedback', 'confirmCheck', 'confirmRetry', 'media', 'localization', 'a11y', 'accessibility', 'scoreBarLabel', 'texts', 'endGame', 'override', ]); function harvest(value: unknown, depth = 0, out: string[] = []): string[] { if (depth > 6 || out.length > 40) return out; if (typeof value === 'string') { const text = htmlToText(value).trim(); // Two characters of prose, not a colour code or a library version. if (text.length > 2 && /\p{L}{2}/u.test(text) && !out.includes(text)) out.push(text); return out; } if (Array.isArray(value)) { for (const item of value) harvest(item, depth + 1, out); return out; } const record = asRecord(value); if (record) { for (const [key, item] of Object.entries(record)) { if (SKIP_KEYS.has(key)) continue; harvest(item, depth + 1, out); } } return out; } function asRecord(value: unknown): Record | undefined { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : undefined; } function asArray(value: unknown): unknown[] | undefined { return Array.isArray(value) ? value : undefined; } function string(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; }