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:
@@ -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, "&").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 };
|
||||
}
|
||||
Reference in New Issue
Block a user