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:
@@ -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.
|
||||
|
||||

|
||||
|
||||
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).
|
||||
|
||||

|
||||
|
||||
> 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?
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import QuestionCard from "@/components/QuestionCard";
|
||||
import CanvasExportDialog from "@/components/CanvasExportDialog";
|
||||
import { QUESTION_TYPES, blankQuestion, totalPoints } from "@/lib/schema";
|
||||
import { exportTxt, exportDoc, exportClipboard, exportPrint } from "@/lib/exporter";
|
||||
|
||||
@@ -19,6 +20,7 @@ export default function EditorPage() {
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [canvasOpen, setCanvasOpen] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [profile, setProfile] = useState({});
|
||||
const toastTimer = useRef(null);
|
||||
@@ -272,10 +274,14 @@ export default function EditorPage() {
|
||||
<button className="btn btn-sm" onClick={() => doExport("copy", true)}>Copy</button>
|
||||
</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("doc", "packet")}>Word</button>
|
||||
</div>
|
||||
<div className="field-label">Canvas (LMS)</div>
|
||||
<button className="btn btn-sm" onClick={() => { setExportOpen(false); setCanvasOpen(true); }}>
|
||||
Set up & download .zip…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -350,6 +356,15 @@ export default function EditorPage() {
|
||||
</span>
|
||||
</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>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function SettingsPage() {
|
||||
const [modelsBusy, setModelsBusy] = useState("");
|
||||
const [modelsErr, setModelsErr] = useState({}); // provider -> error
|
||||
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 [saving, setSaving] = useState(false);
|
||||
const [toast, setToast] = useState("");
|
||||
@@ -78,6 +79,27 @@ export default function SettingsPage() {
|
||||
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
|
||||
// while staying crisp at the printed header size.
|
||||
function onLogoFile(e) {
|
||||
@@ -308,6 +330,47 @@ export default function SettingsPage() {
|
||||
</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">
|
||||
<h2>Generation defaults</h2>
|
||||
<label className="check" style={{ marginTop: 14 }}>
|
||||
|
||||
@@ -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’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 & 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 |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
// 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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -31,6 +31,12 @@ export const DEFAULT_SETTINGS = {
|
||||
maxSourceChars: 24000,
|
||||
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() {
|
||||
@@ -69,6 +75,7 @@ export function mergeSettings(saved) {
|
||||
}
|
||||
out.generation = { ...base.generation, ...(saved.generation || {}) };
|
||||
out.profile = { ...base.profile, ...(saved.profile || {}) };
|
||||
out.canvas = { ...base.canvas, ...(saved.canvas || {}) };
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user