// lib/canvas/qti.js — render the neutral Canvas IR (map.js) into a Canvas QTI 1.2 // assessment XML document. Structure mirrors Canvas's own QTI export / parser fixtures // (instructure/qti) so the package imports cleanly into Classic Quizzes (and, via the // "import as New Quizzes" checkbox, New Quizzes too). const xmlEsc = (s) => String(s ?? "") .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); const matHtml = (html) => `${xmlEsc(html)}`; const matPlain = (text) => `${xmlEsc(text)}`; const OUTCOMES = ``; const metaField = (label, entry) => `${label}${xmlEsc(entry)}`; // Even split of 100 points across n blanks/pairs, last absorbs the rounding remainder. function addValues(n) { const base = Math.floor((10000 / n)) / 100; const vals = Array(n).fill(base); vals[n - 1] = Math.round((100 - base * (n - 1)) * 100) / 100; return vals.map((v) => v.toFixed(2)); } function generalFeedback(it) { const html = it.feedback?.neutralHtml; if (!html) return { block: "", cond: "" }; return { block: `${matHtml(html)}`, cond: `` + ``, }; } function wrapItem(it, presentationInner, conditions, extraMeta = "") { const fb = generalFeedback(it); const meta = metaField("question_type", it.canvasType) + metaField("points_possible", String(it.points ?? 0)) + extraMeta; return ` ${meta} ${matHtml(it.promptHtml || "")}${presentationInner} ${OUTCOMES}${conditions}${fb.cond}${fb.block}`; } function renderChoice(it, cardinality) { const labels = it.choices .map((c) => `${matHtml(c.html)}`) .join(""); const present = `${labels}`; const correct = it.choices.filter((c) => c.correct); const pick = (correct[0] || it.choices[0]); const cond = `${pick.ident}` + `100`; const extraMeta = metaField("original_answer_ids", it.choices.map((c) => c.ident).join(",")); return wrapItem(it, present, cond, extraMeta); } function renderEssay(it) { const present = ``; const cond = ``; return wrapItem(it, present, cond); } function renderFillBlanks(it) { const present = it.blanks.map((b) => { const labels = b.answers .map((a) => `${matPlain(a.textPlain)}`) .join(""); return `${matPlain(b.name)}${labels}`; }).join(""); const add = addValues(it.blanks.length); const conds = it.blanks.map((b, i) => `${b.answers[0].ident}` + `${add[i]}` ).join(""); return wrapItem(it, present, conds); } function renderMatching(it) { const rights = it.rights .map((r) => `${matPlain(r.textPlain)}`) .join(""); const present = it.lefts.map((l) => `${matPlain(l.textPlain)}` + `${rights}` ).join(""); const add = addValues(it.lefts.length); const conds = it.lefts.map((l, i) => `${l.correctRightIdent}` + `${add[i]}` ).join(""); return wrapItem(it, present, conds); } function renderTextOnly(it) { const meta = metaField("question_type", "text_only_question") + metaField("points_possible", "0"); return ` ${meta} ${matHtml(it.promptHtml || "")} ${OUTCOMES}`; } function renderItem(it) { switch (it.canvasType) { case "multiple_choice_question": return renderChoice(it, "Single"); case "true_false_question": return renderChoice(it, "Single"); case "essay_question": return renderEssay(it); case "fill_in_multiple_blanks_question": return renderFillBlanks(it); case "matching_question": return renderMatching(it); case "text_only_question": return renderTextOnly(it); default: return ""; } } export function renderAssessmentXml(ir, assessmentIdent) { const items = ir.items.map(renderItem).filter(Boolean).join("\n"); return ` cc_maxattempts1
${items}
`; } export { xmlEsc };