Sync Canvas LMS export feature + refreshed Settings screenshot

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>
This commit is contained in:
2026-06-25 16:43:35 -04:00
co-authored by Claude Opus 4.8
parent 3c4b80a6d8
commit 4036ad5839
15 changed files with 1125 additions and 1 deletions
+189
View File
@@ -0,0 +1,189 @@
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// 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 };
}