From 4036ad58398054c2a9fd1c7d368fe22db7778c5e Mon Sep 17 00:00:00 2001 From: bizzle Date: Thu, 25 Jun 2026 16:43:35 -0400 Subject: [PATCH] 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) --- README.md | 17 ++ app/api/canvas/route.js | 90 +++++++ app/editor/[id]/page.jsx | 17 +- app/settings/page.jsx | 63 +++++ components/CanvasExportDialog.jsx | 315 ++++++++++++++++++++++ docs/screenshots/canvas-export-dialog.png | Bin 0 -> 175445 bytes docs/screenshots/canvas-settings.png | Bin 0 -> 116848 bytes docs/screenshots/settings.png | Bin 538388 -> 670970 bytes lib/canvas/api.js | 86 ++++++ lib/canvas/export.js | 49 ++++ lib/canvas/manifest.js | 55 ++++ lib/canvas/map.js | 189 +++++++++++++ lib/canvas/qti.js | 139 ++++++++++ lib/canvas/zip.js | 99 +++++++ lib/store.js | 7 + 15 files changed, 1125 insertions(+), 1 deletion(-) create mode 100644 app/api/canvas/route.js create mode 100644 components/CanvasExportDialog.jsx create mode 100644 docs/screenshots/canvas-export-dialog.png create mode 100644 docs/screenshots/canvas-settings.png create mode 100644 lib/canvas/api.js create mode 100644 lib/canvas/export.js create mode 100644 lib/canvas/manifest.js create mode 100644 lib/canvas/map.js create mode 100644 lib/canvas/qti.js create mode 100644 lib/canvas/zip.js diff --git a/README.md b/README.md index 4cefcfb..d5a6392 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,23 @@ Open ****. 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? 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. diff --git a/app/api/canvas/route.js b/app/api/canvas/route.js new file mode 100644 index 0000000..c0257d6 --- /dev/null +++ b/app/api/canvas/route.js @@ -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 }); + } +} diff --git a/app/editor/[id]/page.jsx b/app/editor/[id]/page.jsx index 7f763b8..170c387 100644 --- a/app/editor/[id]/page.jsx +++ b/app/editor/[id]/page.jsx @@ -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() {
Complete packet — student + answer key
-
+
+
Canvas (LMS)
+
)} @@ -350,6 +356,15 @@ export default function EditorPage() { + {canvasOpen && ( + setCanvasOpen(false)} + onDone={() => { setCanvasOpen(false); showToast("Downloaded Canvas .zip"); }} + onError={(msg) => { setCanvasOpen(false); setError(msg); }} + /> + )} + {toast &&
{toast}
} ); diff --git a/app/settings/page.jsx b/app/settings/page.jsx index ab6c059..cfffd6a 100644 --- a/app/settings/page.jsx +++ b/app/settings/page.jsx @@ -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() { +
+

Canvas (LMS) integration

+

+ 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 .zip instead. +

+ + +
+ + {canvasTest && !canvasTest.busy && ( + + {canvasTest.ok ? "✓ " : "✕ "}{canvasTest.message} + + )} +
+
+

Generation defaults