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:
2026-06-25 16:43:35 -04:00
co-authored by Claude Opus 4.8
parent 3c4b80a6d8
commit 4036ad5839
15 changed files with 1125 additions and 1 deletions
+17
View File
@@ -88,6 +88,23 @@ Open **<http://localhost:3000>**. This variant uses host networking, so the cont
--- ---
## Export to Canvas (LMS)
Turn any assignment into a real Canvas quiz. In the editor, choose **Export → Canvas (LMS)**. Title, description, points, and every question are filled in automatically and mapped to the right Canvas question type (multiple choice, true/false, essay, fill-in-the-blank, matching). Optional quiz settings — type, time limit, attempts, dates, shuffle, access code — are right there in the dialog with sensible defaults.
![Canvas export dialog](docs/screenshots/canvas-export-dialog.png)
Two ways to get it into Canvas:
- **Download a `.zip`** (works on every Canvas, no login) — then in Canvas: **Settings → Import Course Content → QTI .zip file**.
- **Send it directly** into a course — add your Canvas web address and a personal access token once under **Settings → Canvas (LMS) integration**, and the dialog gains a course picker that creates the quiz for you (as an unpublished draft you review first).
![Canvas integration settings](docs/screenshots/canvas-settings.png)
> Short-answer and essay questions import as manually-graded Canvas questions. Discussion-style prompts have no Canvas quiz equivalent, so they're left out of the quiz (you'll see a note in the dialog). A case-study passage becomes an intro block at the top.
---
## Where is my data? ## Where is my data?
Everything — assignments, settings, API keys, your school logo — lives in a single JSON file inside the `assignment-data` Docker volume. It survives restarts, rebuilds, and image upgrades. Everything — assignments, settings, API keys, your school logo — lives in a single JSON file inside the `assignment-data` Docker volume. It survives restarts, rebuilds, and image upgrades.
+90
View File
@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { mergeSettings, getSettings } from "@/lib/store";
import { mapAssignmentToCanvas } from "@/lib/canvas/map";
import { normalizeBaseUrl, quizPayload, questionPayloads } from "@/lib/canvas/api";
export const dynamic = "force-dynamic";
// Browser→Canvas is blocked by CORS, so all Canvas REST calls go through this server route.
// Token + base URL come from the saved local settings (data/db.json), like the AI keys.
function creds(settings) {
const c = settings?.canvas || {};
const baseUrl = normalizeBaseUrl(c.baseUrl);
const token = String(c.token || "").trim();
if (!baseUrl) throw new Error("Add your Canvas web address in Settings first.");
if (!token) throw new Error("Add your Canvas access token in Settings first.");
return { baseUrl, token };
}
async function canvas(baseUrl, token, path, init = {}) {
let res;
try {
res = await fetch(baseUrl + path, {
...init,
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init.headers || {}) },
});
} catch {
throw new Error("Couldn't reach Canvas — check the web address and your network.");
}
const text = await res.text();
let data;
try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
if (!res.ok) {
if (res.status === 401) throw new Error("Canvas rejected the access token (401). Check or regenerate it.");
const msg = data?.errors?.[0]?.message || data?.message ||
(Array.isArray(data?.errors) ? JSON.stringify(data.errors) : "") || `Canvas returned ${res.status}.`;
throw new Error(typeof msg === "string" ? msg : `Canvas error ${res.status}.`);
}
return data;
}
export async function POST(request) {
try {
const body = await request.json();
// Accept settings from the client (test-before-save) or fall back to what's saved.
const settings = mergeSettings(body.settings || getSettings());
const { baseUrl, token } = creds(settings);
const action = body.action;
if (action === "test") {
const me = await canvas(baseUrl, token, "/api/v1/users/self");
return NextResponse.json({ ok: true, message: `Connected as ${me.name || me.short_name || "Canvas user"}` });
}
if (action === "courses") {
const list = await canvas(baseUrl, token,
"/api/v1/courses?enrollment_type=teacher&enrollment_state=active&per_page=100");
const courses = (Array.isArray(list) ? list : [])
.filter((c) => c?.id && c?.name && !c.access_restricted_by_date)
.map((c) => ({ id: c.id, name: c.name }));
return NextResponse.json({ courses });
}
if (action === "push") {
const { assignment, opts, courseId } = body;
if (!courseId) throw new Error("Pick a course first.");
const ir = mapAssignmentToCanvas(assignment, opts || {});
if (ir.gradedCount === 0) throw new Error("This assignment has no Canvas-compatible questions.");
const quiz = await canvas(baseUrl, token, `/api/v1/courses/${courseId}/quizzes`, {
method: "POST", body: JSON.stringify(quizPayload(ir)),
});
const quizId = quiz.id;
let added = 0;
for (const q of questionPayloads(ir)) {
await canvas(baseUrl, token, `/api/v1/courses/${courseId}/quizzes/${quizId}/questions`, {
method: "POST", body: JSON.stringify(q),
});
added++;
}
const url = quiz.html_url || `${baseUrl}/courses/${courseId}/quizzes/${quizId}`;
return NextResponse.json({ ok: true, url, quizId, added });
}
throw new Error("Unknown action.");
} catch (e) {
return NextResponse.json({ error: String(e.message || e) }, { status: 400 });
}
}
+16 -1
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import QuestionCard from "@/components/QuestionCard"; import QuestionCard from "@/components/QuestionCard";
import CanvasExportDialog from "@/components/CanvasExportDialog";
import { QUESTION_TYPES, blankQuestion, totalPoints } from "@/lib/schema"; import { QUESTION_TYPES, blankQuestion, totalPoints } from "@/lib/schema";
import { exportTxt, exportDoc, exportClipboard, exportPrint } from "@/lib/exporter"; import { exportTxt, exportDoc, exportClipboard, exportPrint } from "@/lib/exporter";
@@ -19,6 +20,7 @@ export default function EditorPage() {
const [verifying, setVerifying] = useState(false); const [verifying, setVerifying] = useState(false);
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const [canvasOpen, setCanvasOpen] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [profile, setProfile] = useState({}); const [profile, setProfile] = useState({});
const toastTimer = useRef(null); const toastTimer = useRef(null);
@@ -272,10 +274,14 @@ export default function EditorPage() {
<button className="btn btn-sm" onClick={() => doExport("copy", true)}>Copy</button> <button className="btn btn-sm" onClick={() => doExport("copy", true)}>Copy</button>
</div> </div>
<div className="field-label">Complete packet student + answer key</div> <div className="field-label">Complete packet student + answer key</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}>
<button className="btn btn-sm" onClick={() => doExport("print", "packet")}>Print / PDF</button> <button className="btn btn-sm" onClick={() => doExport("print", "packet")}>Print / PDF</button>
<button className="btn btn-sm" onClick={() => doExport("doc", "packet")}>Word</button> <button className="btn btn-sm" onClick={() => doExport("doc", "packet")}>Word</button>
</div> </div>
<div className="field-label">Canvas (LMS)</div>
<button className="btn btn-sm" onClick={() => { setExportOpen(false); setCanvasOpen(true); }}>
Set up &amp; download .zip
</button>
</div> </div>
)} )}
</div> </div>
@@ -350,6 +356,15 @@ export default function EditorPage() {
</span> </span>
</div> </div>
{canvasOpen && (
<CanvasExportDialog
assignment={a}
onClose={() => setCanvasOpen(false)}
onDone={() => { setCanvasOpen(false); showToast("Downloaded Canvas .zip"); }}
onError={(msg) => { setCanvasOpen(false); setError(msg); }}
/>
)}
{toast && <div className="toast">{toast}</div>} {toast && <div className="toast">{toast}</div>}
</div> </div>
); );
+63
View File
@@ -22,6 +22,7 @@ export default function SettingsPage() {
const [modelsBusy, setModelsBusy] = useState(""); const [modelsBusy, setModelsBusy] = useState("");
const [modelsErr, setModelsErr] = useState({}); // provider -> error const [modelsErr, setModelsErr] = useState({}); // provider -> error
const [test, setTest] = useState({}); // provider -> {busy, ok, message} const [test, setTest] = useState({}); // provider -> {busy, ok, message}
const [canvasTest, setCanvasTest] = useState(null); // {busy, ok, message}
const [autoInfo, setAutoInfo] = useState(null); // resolved auto limits for the active model const [autoInfo, setAutoInfo] = useState(null); // resolved auto limits for the active model
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [toast, setToast] = useState(""); const [toast, setToast] = useState("");
@@ -78,6 +79,27 @@ export default function SettingsPage() {
setS((cur) => ({ ...cur, profile: { ...(cur.profile || {}), [field]: value } })); setS((cur) => ({ ...cur, profile: { ...(cur.profile || {}), [field]: value } }));
} }
function setCanvas(field, value) {
setCanvasTest(null);
setS((cur) => ({ ...cur, canvas: { ...(cur.canvas || {}), [field]: value } }));
}
async function testCanvas() {
setCanvasTest({ busy: true });
try {
const res = await fetch("/api/canvas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "test", settings: s }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Test failed.");
setCanvasTest({ ok: true, message: data.message });
} catch (e) {
setCanvasTest({ ok: false, message: String(e.message || e) });
}
}
// Downscale the logo client-side (max 512px) so it stays a small data URL in db.json // Downscale the logo client-side (max 512px) so it stays a small data URL in db.json
// while staying crisp at the printed header size. // while staying crisp at the printed header size.
function onLogoFile(e) { function onLogoFile(e) {
@@ -308,6 +330,47 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
<div className="card">
<h2>Canvas (LMS) integration</h2>
<p className="field-hint" style={{ marginTop: 4 }}>
Optional. Lets you push a finished assignment straight into a Canvas course from the editor.
You can always skip this and use the downloadable Canvas <code>.zip</code> instead.
</p>
<label className="field" style={{ marginTop: 14 }}>
<span className="field-label">Canvas web address</span>
<input
type="text" value={s.canvas?.baseUrl || ""}
onChange={(e) => setCanvas("baseUrl", e.target.value)}
placeholder="https://yourschool.instructure.com"
autoComplete="off"
/>
<span className="field-hint">The address you use to log into Canvas e.g. <code>https://yourschool.instructure.com</code>.</span>
</label>
<label className="field">
<span className="field-label">Access token</span>
<input
type="password" value={s.canvas?.token || ""}
onChange={(e) => setCanvas("token", e.target.value)}
placeholder="Paste your Canvas access token"
autoComplete="off"
/>
<span className="field-hint">
In Canvas: <b>Account Settings Approved Integrations + New Access Token</b>. Stored only on this computer.
Some schools restrict tokens if yours does, use the <code>.zip</code> export instead.
</span>
</label>
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
<button className="btn" onClick={testCanvas} disabled={canvasTest?.busy || !s.canvas?.baseUrl || !s.canvas?.token}>
{canvasTest?.busy ? <><span className="spinner" /> Testing</> : "Test connection"}
</button>
{canvasTest && !canvasTest.busy && (
<span className="small" style={canvasTest.ok ? { color: "var(--board)", fontWeight: 600 } : { fontWeight: 600 }}>
{canvasTest.ok ? "✓ " : "✕ "}{canvasTest.message}
</span>
)}
</div>
</div>
<div className="card"> <div className="card">
<h2>Generation defaults</h2> <h2>Generation defaults</h2>
<label className="check" style={{ marginTop: 14 }}> <label className="check" style={{ marginTop: 14 }}>
+315
View File
@@ -0,0 +1,315 @@
"use client";
// components/CanvasExportDialog.jsx — configure and download a Canvas QTI .zip for one
// assignment. Auto-fills everything derivable; exposes the optional Canvas quiz settings
// a teacher may want to set. Pure client-side (no upload) — see lib/canvas/export.js.
import { useEffect, useMemo, useState } from "react";
import { mapAssignmentToCanvas } from "@/lib/canvas/map";
import { exportCanvasZip } from "@/lib/canvas/export";
const QUIZ_TYPES = [
{ id: "assignment", label: "Graded quiz" },
{ id: "practice_quiz", label: "Practice quiz (ungraded)" },
{ id: "graded_survey", label: "Graded survey" },
{ id: "survey", label: "Ungraded survey" },
];
const TYPE_LABEL = {
multiple_choice_question: "Multiple choice",
true_false_question: "True / False",
essay_question: "Essay (manually graded)",
fill_in_multiple_blanks_question: "Fill in the blank",
matching_question: "Matching",
text_only_question: "Intro text (case study)",
};
// datetime-local "2026-06-25T14:30" -> ISO-8601, or "" if blank/invalid.
function toIso(v) {
if (!v) return "";
const d = new Date(v);
return Number.isNaN(d.getTime()) ? "" : d.toISOString();
}
export default function CanvasExportDialog({ assignment, onClose, onDone, onError }) {
const [form, setForm] = useState({
quizType: "assignment",
timeLimit: "",
allowedAttempts: "1",
scoringPolicy: "keep_highest",
shuffleAnswers: false,
showCorrectAnswers: true,
oneQuestionAtATime: false,
cantGoBack: false,
dueAt: "",
unlockAt: "",
lockAt: "",
accessCode: "",
includeExplanations: true,
});
const [busy, setBusy] = useState(false);
// Direct Canvas push (only offered when credentials are saved in Settings).
const [canvasReady, setCanvasReady] = useState(null); // null = unknown/loading
const [courses, setCourses] = useState(null); // null = not loaded yet
const [courseId, setCourseId] = useState("");
const [coursesErr, setCoursesErr] = useState("");
const [loadingCourses, setLoadingCourses] = useState(false);
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(null); // { url, added }
const [sendErr, setSendErr] = useState("");
useEffect(() => {
fetch("/api/settings")
.then((r) => r.json())
.then((s) => setCanvasReady(!!(s?.canvas?.baseUrl && s?.canvas?.token)))
.catch(() => setCanvasReady(false));
}, []);
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const opts = useMemo(() => ({
quizType: form.quizType,
timeLimit: form.timeLimit === "" ? "" : Math.max(0, parseInt(form.timeLimit, 10) || 0),
allowedAttempts: parseInt(form.allowedAttempts, 10),
scoringPolicy: form.scoringPolicy,
shuffleAnswers: form.shuffleAnswers,
showCorrectAnswers: form.showCorrectAnswers,
oneQuestionAtATime: form.oneQuestionAtATime,
cantGoBack: form.oneQuestionAtATime && form.cantGoBack,
dueAt: toIso(form.dueAt),
unlockAt: toIso(form.unlockAt),
lockAt: toIso(form.lockAt),
accessCode: form.accessCode.trim(),
includeExplanations: form.includeExplanations,
}), [form]);
// Preview what will be in the package (counts + anything left out).
const ir = useMemo(() => mapAssignmentToCanvas(assignment, opts), [assignment, opts]);
const breakdown = useMemo(() => {
const counts = {};
for (const it of ir.items) counts[it.canvasType] = (counts[it.canvasType] || 0) + 1;
return counts;
}, [ir]);
const skippedDiscussion = ir.skipped.filter((s) => s.type === "discussion").length;
const skippedOther = ir.skipped.length - skippedDiscussion;
const nothingToExport = ir.gradedCount === 0;
function doExport() {
setBusy(true);
try {
exportCanvasZip(assignment, opts);
onDone?.();
} catch (e) {
onError?.(String(e?.message || e));
} finally {
setBusy(false);
}
}
async function loadCourses() {
setLoadingCourses(true);
setCoursesErr("");
try {
const res = await fetch("/api/canvas", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "courses" }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Could not load courses.");
setCourses(data.courses || []);
if (data.courses?.length) setCourseId(String(data.courses[0].id));
} catch (e) {
setCoursesErr(String(e.message || e));
} finally {
setLoadingCourses(false);
}
}
async function sendToCanvas() {
setSending(true);
setSendErr("");
try {
const res = await fetch("/api/canvas", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "push", assignment, opts, courseId: Number(courseId) }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Send failed.");
setSent({ url: data.url, added: data.added });
} catch (e) {
setSendErr(String(e.message || e));
} finally {
setSending(false);
}
}
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 100, background: "rgba(20,30,26,0.45)",
display: "flex", alignItems: "flex-start", justifyContent: "center",
padding: "40px 16px", overflowY: "auto",
}}
>
<div
onClick={(e) => e.stopPropagation()}
className="card"
style={{ width: 560, maxWidth: "100%", padding: 24, animation: "fade-in-up 0.18s ease" }}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
<div style={{ flex: 1 }}>
<h2 style={{ fontFamily: "var(--font-display)", fontSize: "1.4rem", margin: 0 }}>Export to Canvas</h2>
<p className="muted small" style={{ margin: "4px 0 0" }}>
Downloads a QTI <code>.zip</code>. In Canvas: <b>Settings Import Course Content QTI .zip file</b>.
</p>
</div>
<button className="btn btn-sm" onClick={onClose} aria-label="Close"></button>
</div>
{/* Summary of what will be exported */}
<div className="alert alert-info" style={{ marginTop: 16 }}>
{nothingToExport ? (
<b>Nothing to export this assignment has no Canvas-compatible questions.</b>
) : (
<>
<b>{ir.gradedCount} question{ir.gradedCount === 1 ? "" : "s"}</b> one Canvas quiz
{" "}({ir.quiz.pointsPossible} pts).
<div className="small" style={{ marginTop: 6 }}>
{Object.entries(breakdown).map(([t, n]) => (
<span key={t} style={{ marginRight: 12, whiteSpace: "nowrap" }}>{TYPE_LABEL[t] || t}: {n}</span>
))}
</div>
</>
)}
</div>
{skippedDiscussion > 0 && (
<div className="alert alert-warn">
{skippedDiscussion} discussion question{skippedDiscussion === 1 ? "" : "s"} left out Canvas quizzes have no discussion type.
</div>
)}
{skippedOther > 0 && (
<div className="alert alert-warn">
{skippedOther} question{skippedOther === 1 ? "" : "s"} couldn&rsquo;t be converted and were left out.
</div>
)}
{/* Optional Canvas settings — auto-filled with sensible defaults */}
<div className="field-label" style={{ marginTop: 8 }}>Quiz settings (optional change in Canvas later too)</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Quiz type</span>
<select value={form.quizType} onChange={(e) => set("quizType", e.target.value)}>
{QUIZ_TYPES.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</label>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Time limit (minutes)</span>
<input type="number" min="0" value={form.timeLimit} placeholder="No limit"
onChange={(e) => set("timeLimit", e.target.value)} />
</label>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Allowed attempts</span>
<select value={form.allowedAttempts} onChange={(e) => set("allowedAttempts", e.target.value)}>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="-1">Unlimited</option>
</select>
</label>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Keep which score</span>
<select value={form.scoringPolicy} onChange={(e) => set("scoringPolicy", e.target.value)}
disabled={form.allowedAttempts === "1"}>
<option value="keep_highest">Highest</option>
<option value="keep_latest">Latest</option>
</select>
</label>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Available from</span>
<input type="datetime-local" value={form.unlockAt} onChange={(e) => set("unlockAt", e.target.value)} />
</label>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Due</span>
<input type="datetime-local" value={form.dueAt} onChange={(e) => set("dueAt", e.target.value)} />
</label>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Until (locks)</span>
<input type="datetime-local" value={form.lockAt} onChange={(e) => set("lockAt", e.target.value)} />
</label>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Access code</span>
<input type="text" value={form.accessCode} placeholder="None"
onChange={(e) => set("accessCode", e.target.value)} />
</label>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 14 }}>
<Toggle checked={form.shuffleAnswers} onChange={(v) => set("shuffleAnswers", v)} label="Shuffle answer order" />
<Toggle checked={form.showCorrectAnswers} onChange={(v) => set("showCorrectAnswers", v)} label="Show correct answers after submission" />
<Toggle checked={form.oneQuestionAtATime} onChange={(v) => set("oneQuestionAtATime", v)} label="One question at a time" />
{form.oneQuestionAtATime && (
<Toggle checked={form.cantGoBack} onChange={(v) => set("cantGoBack", v)} label="Lock questions after answering" indent />
)}
<Toggle checked={form.includeExplanations} onChange={(v) => set("includeExplanations", v)} label="Include explanations as student feedback" />
</div>
{/* Optional: push straight into a Canvas course (needs credentials in Settings) */}
{!nothingToExport && canvasReady && (
<div style={{ marginTop: 18, paddingTop: 16, borderTop: "1px solid var(--line)" }}>
<div className="field-label">Or send directly to a Canvas course</div>
{sent ? (
<div className="alert alert-info" style={{ marginTop: 8 }}>
Created a quiz with <b>{sent.added}</b> question{sent.added === 1 ? "" : "s"} (unpublished).{" "}
<a href={sent.url} target="_blank" rel="noreferrer">Open in Canvas </a>
</div>
) : courses === null ? (
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 8 }}>
<button className="btn btn-sm" onClick={loadCourses} disabled={loadingCourses}>
{loadingCourses ? <><span className="spinner" /> Loading</> : "Load my courses"}
</button>
{coursesErr && <span className="small redpen" style={{ fontWeight: 600 }}>{coursesErr}</span>}
</div>
) : courses.length === 0 ? (
<p className="small muted" style={{ marginTop: 8 }}>No teachable courses found on this Canvas account.</p>
) : (
<div style={{ display: "flex", gap: 8, alignItems: "center", marginTop: 8, flexWrap: "wrap" }}>
<select value={courseId} onChange={(e) => setCourseId(e.target.value)} style={{ flex: 1, minWidth: 200 }}>
{courses.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<button className="btn btn-primary btn-sm" onClick={sendToCanvas} disabled={sending || !courseId}>
{sending ? <><span className="spinner" /> Sending</> : "Send to Canvas"}
</button>
</div>
)}
{sendErr && <div className="alert alert-warn" style={{ marginTop: 10 }}>{sendErr}</div>}
</div>
)}
{!nothingToExport && canvasReady === false && (
<p className="small muted" style={{ marginTop: 16, paddingTop: 14, borderTop: "1px solid var(--line)" }}>
Want one-click publishing? Add your Canvas address &amp; access token in <b>Settings Canvas (LMS) integration</b>.
</p>
)}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10, marginTop: 22 }}>
<button className="btn" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={doExport} disabled={busy || nothingToExport}>
{busy ? <><span className="spinner" /> Building</> : "Download .zip"}
</button>
</div>
</div>
</div>
);
}
function Toggle({ checked, onChange, label, indent }) {
return (
<label style={{ display: "flex", alignItems: "center", gap: 9, cursor: "pointer", marginLeft: indent ? 22 : 0 }}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span style={{ fontSize: "0.9rem" }}>{label}</span>
</label>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

After

Width:  |  Height:  |  Size: 655 KiB

+86
View File
@@ -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(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/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;
}
+49
View File
@@ -0,0 +1,49 @@
// lib/canvas/export.js — assemble a Canvas QTI .zip from an assignment and download it.
// Client-side only (like lib/exporter.js): no data leaves the browser.
import { mapAssignmentToCanvas } from "./map";
import { renderAssessmentXml } from "./qti";
import { renderManifest, renderAssessmentMeta } from "./manifest";
import { storeZip } from "./zip";
// A QTI identifier must be a valid NCName-ish token (starts with a letter, no spaces).
function slugIdent(seed) {
const clean = String(seed || "").replace(/[^a-zA-Z0-9]/g, "");
return "g" + (clean || "quiz");
}
function safeName(title) {
const base = String(title || "quiz")
.replace(/[^\w\- ]+/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "quiz";
return `${base}-canvas.zip`;
}
// Build the package in memory. Returns { bytes: Uint8Array, ir, files } — pure, testable.
export function buildCanvasPackage(assignment, opts = {}) {
const ir = mapAssignmentToCanvas(assignment, opts);
const assessmentIdent = slugIdent(assignment?.id || assignment?.title);
const manifestIdent = "m" + assessmentIdent;
const files = [
{ name: "imsmanifest.xml", data: renderManifest(assessmentIdent, manifestIdent) },
{ name: `${assessmentIdent}/${assessmentIdent}.xml`, data: renderAssessmentXml(ir, assessmentIdent) },
{ name: `${assessmentIdent}/assessment_meta.xml`, data: renderAssessmentMeta(ir, assessmentIdent) },
];
return { bytes: storeZip(files), ir, files };
}
function download(filename, blob) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 800);
}
// Build and trigger the browser download. Returns the IR (so callers can show a summary).
export function exportCanvasZip(assignment, opts = {}) {
const { bytes, ir } = buildCanvasPackage(assignment, opts);
// Copy into a fresh ArrayBuffer so Blob gets a clean, correctly-sized buffer.
const blob = new Blob([bytes.slice()], { type: "application/zip" });
download(safeName(assignment?.title), blob);
return ir;
}
+55
View File
@@ -0,0 +1,55 @@
// lib/canvas/manifest.js — the two wrapper files that make a folder of QTI XML into a
// Canvas-importable package: imsmanifest.xml (IMS Content Packaging) and the Canvas
// proprietary assessment_meta.xml (quiz-level settings).
import { xmlEsc } from "./qti";
// Canvas quiz-level settings. `title` is the only thing Canvas truly needs; everything
// else has a sensible Canvas default if omitted. We emit the fields the user can set.
export function renderAssessmentMeta(ir, assessmentIdent) {
const q = ir.quiz;
const lines = [];
const tag = (name, val) => lines.push(`<${name}>${xmlEsc(String(val))}</${name}>`);
const bool = (name, val) => tag(name, val ? "true" : "false");
const opt = (name, val) => { if (val !== "" && val != null) tag(name, val); };
tag("title", q.title);
tag("description", q.description || "");
tag("quiz_type", q.quizType);
tag("points_possible", q.pointsPossible);
opt("time_limit", q.timeLimit);
tag("allowed_attempts", q.allowedAttempts);
tag("scoring_policy", q.scoringPolicy);
bool("shuffle_answers", q.shuffleAnswers);
bool("show_correct_answers", q.showCorrectAnswers);
bool("one_question_at_a_time", q.oneQuestionAtATime);
bool("cant_go_back", q.cantGoBack);
opt("access_code", q.accessCode);
opt("due_at", q.dueAt);
opt("unlock_at", q.unlockAt);
opt("lock_at", q.lockAt);
return `<?xml version="1.0" encoding="UTF-8"?>
<quiz identifier="${xmlEsc(assessmentIdent)}" xmlns="http://canvas.instructure.com/xsd/cccv1p0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd">
${lines.join("\n")}
</quiz>`;
}
export function renderManifest(assessmentIdent, manifestIdent) {
const assessmentHref = `${assessmentIdent}/${assessmentIdent}.xml`;
const metaHref = `${assessmentIdent}/assessment_meta.xml`;
const depIdent = `${assessmentIdent}_dependency`;
return `<?xml version="1.0" encoding="UTF-8"?>
<manifest identifier="${xmlEsc(manifestIdent)}" xmlns="http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1" xmlns:lom="http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1 http://www.imsglobal.org/profile/cc/ccv1p1/ccv1p1_imscp_v1p2_v1p0.xsd">
<metadata><schema>IMS Content</schema><schemaversion>1.1.3</schemaversion></metadata>
<organizations/>
<resources>
<resource identifier="${xmlEsc(assessmentIdent)}" type="imsqti_xmlv1p2/imscc_xmlv1p1/assessment" href="${xmlEsc(assessmentHref)}">
<file href="${xmlEsc(assessmentHref)}"/>
<dependency identifierref="${xmlEsc(depIdent)}"/>
</resource>
<resource identifier="${xmlEsc(depIdent)}" type="associatedcontent/imscc_xmlv1p1/learning-application-resource" href="${xmlEsc(metaHref)}">
<file href="${xmlEsc(metaHref)}"/>
</resource>
</resources>
</manifest>`;
}
+189
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// 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 };
}
+139
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 };
+99
View File
@@ -0,0 +1,99 @@
// lib/canvas/zip.js — minimal, zero-dependency ZIP writer (STORE method, no compression).
// Canvas accepts uncompressed QTI packages, so we skip DEFLATE entirely and avoid any
// dependency. Output is a standard .zip as a Uint8Array. Deterministic (fixed timestamps).
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
// CRC-32 (IEEE 802.3). crc32 of "123456789" === 0xCBF43926 (unit-test anchor).
export function crc32(bytes) {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
const enc = new TextEncoder();
const toBytes = (data) => (typeof data === "string" ? enc.encode(data) : data);
// files: [{ name: string, data: string|Uint8Array }] -> Uint8Array (.zip bytes)
export function storeZip(files) {
const DOS_TIME = 0;
const DOS_DATE = 0x0021; // 1980-01-01, deterministic
const chunks = [];
const central = [];
let offset = 0;
for (const f of files) {
const nameBytes = enc.encode(f.name);
const data = toBytes(f.data);
const crc = crc32(data);
const local = new Uint8Array(30 + nameBytes.length);
const lv = new DataView(local.buffer);
lv.setUint32(0, 0x04034b50, true); // local file header signature
lv.setUint16(4, 20, true); // version needed to extract
lv.setUint16(6, 0x0800, true); // flags: bit 11 = UTF-8 filenames
lv.setUint16(8, 0, true); // compression method 0 = store
lv.setUint16(10, DOS_TIME, true);
lv.setUint16(12, DOS_DATE, true);
lv.setUint32(14, crc, true);
lv.setUint32(18, data.length, true); // compressed size (== uncompressed for store)
lv.setUint32(22, data.length, true); // uncompressed size
lv.setUint16(26, nameBytes.length, true);
lv.setUint16(28, 0, true); // extra field length
local.set(nameBytes, 30);
chunks.push(local, data);
const cd = new Uint8Array(46 + nameBytes.length);
const cv = new DataView(cd.buffer);
cv.setUint32(0, 0x02014b50, true); // central directory header signature
cv.setUint16(4, 20, true); // version made by
cv.setUint16(6, 20, true); // version needed
cv.setUint16(8, 0x0800, true);
cv.setUint16(10, 0, true); // method
cv.setUint16(12, DOS_TIME, true);
cv.setUint16(14, DOS_DATE, true);
cv.setUint32(16, crc, true);
cv.setUint32(20, data.length, true);
cv.setUint32(24, data.length, true);
cv.setUint16(28, nameBytes.length, true);
cv.setUint16(30, 0, true); // extra length
cv.setUint16(32, 0, true); // comment length
cv.setUint16(34, 0, true); // disk number start
cv.setUint16(36, 0, true); // internal attributes
cv.setUint32(38, 0, true); // external attributes
cv.setUint32(42, offset, true); // relative offset of local header
cd.set(nameBytes, 46);
central.push(cd);
offset += local.length + data.length;
}
const centralStart = offset;
let centralSize = 0;
for (const cd of central) { chunks.push(cd); centralSize += cd.length; }
const eocd = new Uint8Array(22);
const ev = new DataView(eocd.buffer);
ev.setUint32(0, 0x06054b50, true); // end of central directory signature
ev.setUint16(8, central.length, true); // entries on this disk
ev.setUint16(10, central.length, true); // total entries
ev.setUint32(12, centralSize, true);
ev.setUint32(16, centralStart, true);
ev.setUint16(20, 0, true); // comment length
chunks.push(eocd);
let total = 0;
for (const c of chunks) total += c.length;
const out = new Uint8Array(total);
let p = 0;
for (const c of chunks) { out.set(c, p); p += c.length; }
return out;
}
+7
View File
@@ -31,6 +31,12 @@ export const DEFAULT_SETTINGS = {
maxSourceChars: 24000, maxSourceChars: 24000,
verification: true, verification: true,
}, },
// Optional: push quizzes straight into a Canvas course (lib/canvas/api.js).
// baseUrl is the institution's Canvas, token is a personal access token.
canvas: {
baseUrl: "",
token: "",
},
}; };
function emptyDb() { function emptyDb() {
@@ -69,6 +75,7 @@ export function mergeSettings(saved) {
} }
out.generation = { ...base.generation, ...(saved.generation || {}) }; out.generation = { ...base.generation, ...(saved.generation || {}) };
out.profile = { ...base.profile, ...(saved.profile || {}) }; out.profile = { ...base.profile, ...(saved.profile || {}) };
out.canvas = { ...base.canvas, ...(saved.canvas || {}) };
return out; return out;
} }