// 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; }