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,86 @@
|
||||
// lib/canvas/api.js — render the neutral Canvas IR (map.js) into Canvas *Classic Quizzes*
|
||||
// REST payloads. Same IR the .zip path uses, so question mapping lives in exactly one place.
|
||||
// Used by the server route app/api/canvas/route.js (browser→Canvas is blocked by CORS).
|
||||
|
||||
export function normalizeBaseUrl(url) {
|
||||
let u = String(url || "").trim();
|
||||
if (!u) return "";
|
||||
if (!/^https?:\/\//i.test(u)) u = "https://" + u;
|
||||
return u.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
// Strip tags + decode the few entities we emit, for the plain answer_text fallback.
|
||||
function stripTags(html) {
|
||||
return String(html || "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// IR.quiz -> body for POST /api/v1/courses/:id/quizzes
|
||||
export function quizPayload(ir) {
|
||||
const q = ir.quiz;
|
||||
const quiz = {
|
||||
title: q.title,
|
||||
description: q.description || "",
|
||||
quiz_type: q.quizType,
|
||||
shuffle_answers: !!q.shuffleAnswers,
|
||||
allowed_attempts: q.allowedAttempts,
|
||||
scoring_policy: q.scoringPolicy,
|
||||
show_correct_answers: q.showCorrectAnswers !== false,
|
||||
one_question_at_a_time: !!q.oneQuestionAtATime,
|
||||
cant_go_back: !!(q.oneQuestionAtATime && q.cantGoBack),
|
||||
published: false, // import unpublished so the teacher reviews before students see it
|
||||
};
|
||||
if (q.timeLimit !== "" && q.timeLimit != null) quiz.time_limit = q.timeLimit;
|
||||
if (q.accessCode) quiz.access_code = q.accessCode;
|
||||
if (q.dueAt) quiz.due_at = q.dueAt;
|
||||
if (q.unlockAt) quiz.unlock_at = q.unlockAt;
|
||||
if (q.lockAt) quiz.lock_at = q.lockAt;
|
||||
return { quiz };
|
||||
}
|
||||
|
||||
// IR.items -> array of bodies for POST /api/v1/courses/:id/quizzes/:qid/questions
|
||||
export function questionPayloads(ir) {
|
||||
return ir.items.map((it, i) => ({ question: questionBody(it, i + 1) }));
|
||||
}
|
||||
|
||||
function questionBody(it, position) {
|
||||
const body = {
|
||||
question_name: it.title || `Question ${position}`,
|
||||
question_text: it.promptHtml || "",
|
||||
question_type: it.canvasType,
|
||||
points_possible: it.points || 0,
|
||||
position,
|
||||
};
|
||||
if (it.feedback?.neutralHtml) body.neutral_comments_html = it.feedback.neutralHtml;
|
||||
|
||||
switch (it.canvasType) {
|
||||
case "multiple_choice_question":
|
||||
case "true_false_question":
|
||||
body.answers = it.choices.map((c) => ({
|
||||
answer_html: c.html, answer_text: stripTags(c.html), answer_weight: c.correct ? 100 : 0,
|
||||
}));
|
||||
break;
|
||||
case "fill_in_multiple_blanks_question":
|
||||
body.answers = it.blanks.flatMap((b) =>
|
||||
b.answers.map((a) => ({ answer_text: a.textPlain, answer_weight: 100, blank_id: b.name })));
|
||||
break;
|
||||
case "matching_question": {
|
||||
const rightText = (ident) => (it.rights.find((r) => r.ident === ident)?.textPlain) || "";
|
||||
body.answers = it.lefts.map((l) => ({
|
||||
answer_match_left: l.textPlain,
|
||||
answer_match_right: rightText(l.correctRightIdent),
|
||||
answer_weight: 100,
|
||||
}));
|
||||
const used = new Set(it.lefts.map((l) => l.correctRightIdent));
|
||||
const distractors = it.rights.filter((r) => !used.has(r.ident)).map((r) => r.textPlain);
|
||||
if (distractors.length) body.matching_answer_incorrect_matches = distractors.join("\n");
|
||||
break;
|
||||
}
|
||||
case "essay_question":
|
||||
case "text_only_question":
|
||||
default:
|
||||
body.answers = [];
|
||||
}
|
||||
return body;
|
||||
}
|
||||
Reference in New Issue
Block a user