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>
87 lines
3.2 KiB
JavaScript
87 lines
3.2 KiB
JavaScript
// 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;
|
|
}
|