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,139 @@
|
||||
// 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, ">").replace(/"/g, """);
|
||||
|
||||
const matHtml = (html) =>
|
||||
`<material><mattext texttype="text/html">${xmlEsc(html)}</mattext></material>`;
|
||||
const matPlain = (text) =>
|
||||
`<material><mattext texttype="text/plain">${xmlEsc(text)}</mattext></material>`;
|
||||
|
||||
const OUTCOMES =
|
||||
`<outcomes><decvar maxvalue="100" minvalue="0" varname="SCORE" vartype="Decimal"/></outcomes>`;
|
||||
|
||||
const metaField = (label, entry) =>
|
||||
`<qtimetadatafield><fieldlabel>${label}</fieldlabel><fieldentry>${xmlEsc(entry)}</fieldentry></qtimetadatafield>`;
|
||||
|
||||
// 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: `<itemfeedback ident="general_fb"><flow_mat>${matHtml(html)}</flow_mat></itemfeedback>`,
|
||||
cond: `<respcondition continue="Yes"><conditionvar><other/></conditionvar>` +
|
||||
`<displayfeedback feedbacktype="Response" linkrefid="general_fb"/></respcondition>`,
|
||||
};
|
||||
}
|
||||
|
||||
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 `<item ident="${xmlEsc(it.ident)}" title="${xmlEsc(it.title || "Question")}">
|
||||
<itemmetadata><qtimetadata>${meta}</qtimetadata></itemmetadata>
|
||||
<presentation>${matHtml(it.promptHtml || "")}${presentationInner}</presentation>
|
||||
<resprocessing>${OUTCOMES}${conditions}${fb.cond}</resprocessing>${fb.block}</item>`;
|
||||
}
|
||||
|
||||
function renderChoice(it, cardinality) {
|
||||
const labels = it.choices
|
||||
.map((c) => `<response_label ident="${c.ident}">${matHtml(c.html)}</response_label>`)
|
||||
.join("");
|
||||
const present =
|
||||
`<response_lid ident="response1" rcardinality="${cardinality}"><render_choice>${labels}</render_choice></response_lid>`;
|
||||
const correct = it.choices.filter((c) => c.correct);
|
||||
const pick = (correct[0] || it.choices[0]);
|
||||
const cond =
|
||||
`<respcondition continue="No"><conditionvar><varequal respident="response1">${pick.ident}</varequal></conditionvar>` +
|
||||
`<setvar action="Set" varname="SCORE">100</setvar></respcondition>`;
|
||||
const extraMeta = metaField("original_answer_ids", it.choices.map((c) => c.ident).join(","));
|
||||
return wrapItem(it, present, cond, extraMeta);
|
||||
}
|
||||
|
||||
function renderEssay(it) {
|
||||
const present =
|
||||
`<response_str ident="response1" rcardinality="Single"><render_fib><response_label ident="answer1" rshuffle="No"/></render_fib></response_str>`;
|
||||
const cond = `<respcondition continue="No"><conditionvar><other/></conditionvar></respcondition>`;
|
||||
return wrapItem(it, present, cond);
|
||||
}
|
||||
|
||||
function renderFillBlanks(it) {
|
||||
const present = it.blanks.map((b) => {
|
||||
const labels = b.answers
|
||||
.map((a) => `<response_label ident="${a.ident}">${matPlain(a.textPlain)}</response_label>`)
|
||||
.join("");
|
||||
return `<response_lid ident="${b.respIdent}">${matPlain(b.name)}<render_choice>${labels}</render_choice></response_lid>`;
|
||||
}).join("");
|
||||
const add = addValues(it.blanks.length);
|
||||
const conds = it.blanks.map((b, i) =>
|
||||
`<respcondition><conditionvar><varequal respident="${b.respIdent}">${b.answers[0].ident}</varequal></conditionvar>` +
|
||||
`<setvar varname="SCORE" action="Add">${add[i]}</setvar></respcondition>`
|
||||
).join("");
|
||||
return wrapItem(it, present, conds);
|
||||
}
|
||||
|
||||
function renderMatching(it) {
|
||||
const rights = it.rights
|
||||
.map((r) => `<response_label ident="${r.ident}">${matPlain(r.textPlain)}</response_label>`)
|
||||
.join("");
|
||||
const present = it.lefts.map((l) =>
|
||||
`<response_lid ident="${l.respIdent}" rcardinality="Single">${matPlain(l.textPlain)}` +
|
||||
`<render_choice>${rights}</render_choice></response_lid>`
|
||||
).join("");
|
||||
const add = addValues(it.lefts.length);
|
||||
const conds = it.lefts.map((l, i) =>
|
||||
`<respcondition><conditionvar><varequal respident="${l.respIdent}">${l.correctRightIdent}</varequal></conditionvar>` +
|
||||
`<setvar varname="SCORE" action="Add">${add[i]}</setvar></respcondition>`
|
||||
).join("");
|
||||
return wrapItem(it, present, conds);
|
||||
}
|
||||
|
||||
function renderTextOnly(it) {
|
||||
const meta = metaField("question_type", "text_only_question") + metaField("points_possible", "0");
|
||||
return `<item ident="${xmlEsc(it.ident)}" title="${xmlEsc(it.title || "Text")}">
|
||||
<itemmetadata><qtimetadata>${meta}</qtimetadata></itemmetadata>
|
||||
<presentation>${matHtml(it.promptHtml || "")}</presentation>
|
||||
<resprocessing>${OUTCOMES}<respcondition continue="No"><conditionvar><other/></conditionvar></respcondition></resprocessing></item>`;
|
||||
}
|
||||
|
||||
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 `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<questestinterop xmlns="http://www.imsglobal.org/xsd/ims_qtiasiv1p2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/ims_qtiasiv1p2 http://www.imsglobal.org/xsd/ims_qtiasiv1p2p1.xsd">
|
||||
<assessment ident="${xmlEsc(assessmentIdent)}" title="${xmlEsc(ir.quiz.title)}">
|
||||
<qtimetadata>
|
||||
<qtimetadatafield><fieldlabel>cc_maxattempts</fieldlabel><fieldentry>1</fieldentry></qtimetadatafield>
|
||||
</qtimetadata>
|
||||
<section ident="root_section">
|
||||
${items}
|
||||
</section>
|
||||
</assessment>
|
||||
</questestinterop>`;
|
||||
}
|
||||
|
||||
export { xmlEsc };
|
||||
Reference in New Issue
Block a user