// lib/canvas/map.js — turn one assignment into a neutral "Canvas IR" that both the // QTI .zip renderer (qti.js) and the future REST push (api.js) consume. This is where // every app question type is mapped to a Canvas question type, exactly once. // // Mapping decisions (see project memory canvas-export-feature): // multiple_choice -> multiple_choice_question // true_false -> true_false_question // essay -> essay_question (manual; rubric -> neutral feedback) // short_answer -> essay_question (open-ended; sample/key points -> feedback) // fill_blank -> fill_in_multiple_blanks_question (______ becomes [blankN] tokens) // matching -> matching_question // discussion -> skipped (no Canvas quiz equivalent) // caseStudy -> text_only_question (intro passage at the top) const plain = (s) => String(s ?? "").trim(); // Escape text so that, after Canvas decodes the XML layer, the result is valid HTML. const escHtmlText = (s) => String(s ?? "").replace(/&/g, "&").replace(//g, ">"); // Plain text -> HTML with paragraphs and line breaks (content escaped, tags raw). function paragraphsToHtml(text) { const t = plain(text); if (!t) return ""; return t .split(/\n{2,}/) .map((para) => "

" + para.split(/\n/).map(escHtmlText).join("
") + "

") .join(""); } function shortAnswerGuide(q) { const out = []; if (plain(q.sampleAnswer)) out.push("Sample answer: " + plain(q.sampleAnswer)); const kp = (q.keyPoints || []).map(plain).filter(Boolean); if (kp.length) out.push("Must include: " + kp.join("; ")); return out.join("\n"); } function essayGuide(q) { const out = []; if (plain(q.sampleResponse)) out.push("Sample response: " + plain(q.sampleResponse)); const rubric = (q.rubric || []).filter((r) => plain(r?.criterion)); if (rubric.length) { out.push("Rubric:"); for (const r of rubric) { out.push(`• ${plain(r.criterion)} (${Number(r.points) || 0} pts)` + (plain(r.description) ? " — " + plain(r.description) : "")); } } return out.join("\n"); } // Convert the app's ______ blanks into Canvas named tokens [blank1], [blank2], ... function buildBlanks(q, nextId) { let n = 0; const text = String(q.question || "").replace(/_{2,}/g, () => `[blank${++n}]`); if (n === 0) return null; const answers = q.answers || []; const list = []; for (let i = 0; i < n; i++) { const ans = plain(answers[i]); list.push({ name: `blank${i + 1}`, respIdent: `response_blank${i + 1}`, answers: [{ ident: nextId(), textPlain: ans || "" }], }); } return { list, promptHtml: paragraphsToHtml(text) }; } function buildMatching(q, nextId) { const pairs = (q.pairs || []).filter((p) => plain(p?.left) && plain(p?.right)); if (pairs.length < 2) return null; const rights = []; const identByText = new Map(); for (const p of pairs) { const text = plain(p.right); if (!identByText.has(text)) { const ident = nextId(); identByText.set(text, ident); rights.push({ ident, textPlain: text }); } } const lefts = pairs.map((p, i) => ({ respIdent: `question_${i + 1}`, textPlain: plain(p.left), correctRightIdent: identByText.get(plain(p.right)), })); return { lefts, rights }; } export function mapAssignmentToCanvas(assignment, opts = {}) { const includeExplanations = opts.includeExplanations !== false; let counter = 1000; const nextId = () => String(++counter); const items = []; const skipped = []; const caseStudy = plain(assignment?.caseStudy); if (caseStudy) { items.push({ ident: "intro_passage", title: "Read the following", canvasType: "text_only_question", points: 0, promptHtml: paragraphsToHtml(caseStudy), }); } (assignment?.questions || []).forEach((q, i) => { const ident = "q_" + (i + 1); const points = Number(q.points) || 0; const neutral = includeExplanations ? plain(q.explanation) : ""; const neutralHtml = (extra) => { const merged = [neutral, extra].filter(Boolean).join("\n\n"); return merged ? paragraphsToHtml(merged) : ""; }; const title = `Question ${i + 1}`; switch (q.type) { case "multiple_choice": { const choices = (q.options || []).map((opt, j) => ({ ident: nextId(), html: escHtmlText(plain(opt)), correct: j === q.correctIndex, })); if (choices.length < 2) { skipped.push({ type: q.type, question: q.question, reason: "needs 2+ options" }); break; } items.push({ ident, title, canvasType: "multiple_choice_question", points, promptHtml: paragraphsToHtml(q.question), choices, feedback: { neutralHtml: neutralHtml() } }); break; } case "true_false": { items.push({ ident, title, canvasType: "true_false_question", points, promptHtml: paragraphsToHtml(q.question), choices: [ { ident: nextId(), html: "True", correct: q.correctAnswer === true }, { ident: nextId(), html: "False", correct: q.correctAnswer !== true }, ], feedback: { neutralHtml: neutralHtml() } }); break; } case "essay": case "short_answer": { const guide = q.type === "short_answer" ? shortAnswerGuide(q) : essayGuide(q); items.push({ ident, title, canvasType: "essay_question", points, promptHtml: paragraphsToHtml(q.question), feedback: { neutralHtml: neutralHtml(guide) } }); break; } case "fill_blank": { const b = buildBlanks(q, nextId); if (!b) { skipped.push({ type: q.type, question: q.question, reason: "no blanks found" }); break; } items.push({ ident, title, canvasType: "fill_in_multiple_blanks_question", points, promptHtml: b.promptHtml, blanks: b.list, feedback: { neutralHtml: neutralHtml() } }); break; } case "matching": { const m = buildMatching(q, nextId); if (!m) { skipped.push({ type: q.type, question: q.question, reason: "needs 2+ pairs" }); break; } items.push({ ident, title, canvasType: "matching_question", points, promptHtml: paragraphsToHtml(q.question || "Match each item on the left with the correct item on the right."), lefts: m.lefts, rights: m.rights, feedback: { neutralHtml: neutralHtml() } }); break; } default: skipped.push({ type: q.type, question: q.question, reason: "not supported in Canvas quizzes" }); } }); const gradedCount = items.filter((it) => it.canvasType !== "text_only_question").length; const quiz = { title: plain(assignment?.title) || "Untitled quiz", description: paragraphsToHtml(plain(assignment?.instructions)), pointsPossible: items.reduce((s, it) => s + (it.points || 0), 0), quizType: opts.quizType || "assignment", timeLimit: opts.timeLimit ?? "", // minutes; "" = no limit allowedAttempts: opts.allowedAttempts ?? 1, // -1 = unlimited scoringPolicy: opts.scoringPolicy || "keep_highest", shuffleAnswers: !!opts.shuffleAnswers, showCorrectAnswers: opts.showCorrectAnswers !== false, oneQuestionAtATime: !!opts.oneQuestionAtATime, cantGoBack: !!opts.cantGoBack, dueAt: opts.dueAt || "", unlockAt: opts.unlockAt || "", lockAt: opts.lockAt || "", accessCode: opts.accessCode || "", }; return { quiz, items, skipped, gradedCount }; }