Brings the share build up to date with the main project: QTI .zip export and optional direct Canvas API push, plus the Canvas settings card. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
190 lines
7.3 KiB
JavaScript
190 lines
7.3 KiB
JavaScript
// 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, "<").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) => "<p>" + para.split(/\n/).map(escHtmlText).join("<br>") + "</p>")
|
|
.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 };
|
|
}
|