Created
May 3, 2026 15:54
-
-
Save acbart/98b15f21f7eb20d3f0280eee69ab961a to your computer and use it in GitHub Desktop.
Diff two canvas courses
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (async function CanvasCourseDiffTool() { | |
| const base = ENV.DEEP_LINKING_POST_MESSAGE_ORIGIN || location.origin; | |
| const api = `${base}/api/v1`; | |
| const RECENTS_KEY = "canvas_course_diff_recent_courses_v1"; | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| function parseNext(linkHeader) { | |
| if (!linkHeader) return null; | |
| const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/); | |
| return match ? match[1] : null; | |
| } | |
| async function getAll(url) { | |
| const out = []; | |
| let next = url; | |
| while (next) { | |
| const resp = await fetch(next, { credentials: "include" }); | |
| if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}: ${next}`); | |
| out.push(...await resp.json()); | |
| next = parseNext(resp.headers.get("link")); | |
| } | |
| return out; | |
| } | |
| async function getJson(url) { | |
| const resp = await fetch(url, { credentials: "include" }); | |
| if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}: ${url}`); | |
| return resp.json(); | |
| } | |
| function esc(s) { | |
| return String(s ?? "") | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """); | |
| } | |
| function norm(s) { | |
| return String(s ?? "").trim().replace(/\s+/g, " ").toLowerCase(); | |
| } | |
| function isoDate(v) { | |
| return v ? new Date(v).toISOString() : ""; | |
| } | |
| function levenshtein(a, b) { | |
| a = norm(a); | |
| b = norm(b); | |
| const dp = Array.from({ length: a.length + 1 }, () => []); | |
| for (let i = 0; i <= a.length; i++) dp[i][0] = i; | |
| for (let j = 0; j <= b.length; j++) dp[0][j] = j; | |
| for (let i = 1; i <= a.length; i++) { | |
| for (let j = 1; j <= b.length; j++) { | |
| dp[i][j] = Math.min( | |
| dp[i - 1][j] + 1, | |
| dp[i][j - 1] + 1, | |
| dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1) | |
| ); | |
| } | |
| } | |
| return dp[a.length][b.length]; | |
| } | |
| function stable(value) { | |
| return JSON.stringify(value ?? "", Object.keys(value ?? {}).sort()); | |
| } | |
| function getRecents() { | |
| try { | |
| return JSON.parse(localStorage.getItem(RECENTS_KEY) || "[]"); | |
| } catch { | |
| return []; | |
| } | |
| } | |
| function saveRecent(course) { | |
| const recents = getRecents().filter((c) => String(c.id) !== String(course.id)); | |
| recents.unshift({ id: course.id, name: course.name || `Course ${course.id}` }); | |
| localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, 10))); | |
| } | |
| async function chooseCoursesDialog() { | |
| const courses = await getAll( | |
| `${api}/courses?enrollment_state=active&include[]=term&per_page=100` | |
| ); | |
| const merged = [ | |
| ...getRecents().map((c) => ({ ...c, recent: true })), | |
| ...courses.map((c) => ({ | |
| id: c.id, | |
| name: `${c.name || "Untitled"}${c.term?.name ? ` — ${c.term.name}` : ""}`, | |
| })), | |
| ]; | |
| const unique = []; | |
| const seen = new Set(); | |
| for (const c of merged) { | |
| if (!seen.has(String(c.id))) { | |
| seen.add(String(c.id)); | |
| unique.push(c); | |
| } | |
| } | |
| const options = unique | |
| .map( | |
| (c) => | |
| `<option value="${esc(c.id)}">${c.recent ? "Recent: " : ""}${esc( | |
| c.name | |
| )} [${esc(c.id)}]</option>` | |
| ) | |
| .join(""); | |
| return new Promise((resolve) => { | |
| const html = ` | |
| <div id="ccd-picker" style="line-height:1.5"> | |
| <p>Choose two Canvas courses to compare.</p> | |
| <label>Course A</label> | |
| <select id="ccd-a" style="width:100%;margin-bottom:10px">${options}</select> | |
| <label>Course B</label> | |
| <select id="ccd-b" style="width:100%;margin-bottom:10px">${options}</select> | |
| <label>Name match tolerance</label> | |
| <select id="ccd-tol" style="width:100%"> | |
| ${[0, 1, 2, 3, 4, 5, 8, 10] | |
| .map((n) => `<option value="${n}" ${n === 2 ? "selected" : ""}>${n} characters</option>`) | |
| .join("")} | |
| </select> | |
| </div> | |
| `; | |
| $(html).dialog({ | |
| title: "Compare Canvas Courses", | |
| width: 520, | |
| modal: true, | |
| buttons: { | |
| Compare() { | |
| const a = $("#ccd-a").val(); | |
| const b = $("#ccd-b").val(); | |
| const tolerance = Number($("#ccd-tol").val()); | |
| const courseA = unique.find((c) => String(c.id) === String(a)); | |
| const courseB = unique.find((c) => String(c.id) === String(b)); | |
| $(this).dialog("destroy").remove(); | |
| resolve({ courseA, courseB, tolerance }); | |
| }, | |
| Cancel() { | |
| $(this).dialog("destroy").remove(); | |
| resolve(null); | |
| }, | |
| }, | |
| }); | |
| }); | |
| } | |
| function simplifyOverride(o) { | |
| if (o.student_ids?.length) return null; // ignore individual-student overrides | |
| if (!o.course_section_id) return null; | |
| return { | |
| section_id: o.course_section_id, | |
| title: o.title || "", | |
| due_at: isoDate(o.due_at), | |
| unlock_at: isoDate(o.unlock_at), | |
| lock_at: isoDate(o.lock_at), | |
| }; | |
| } | |
| async function fetchAssignmentOverrides(courseId, assignmentId) { | |
| try { | |
| const overrides = await getAll( | |
| `${api}/courses/${courseId}/assignments/${assignmentId}/overrides?per_page=100` | |
| ); | |
| return overrides.map(simplifyOverride).filter(Boolean); | |
| } catch { | |
| return []; | |
| } | |
| } | |
| async function fetchCourseBundle(course) { | |
| const courseId = course.id; | |
| const [assignmentsRaw, pagesRaw, modulesRaw] = await Promise.all([ | |
| getAll( | |
| `${api}/courses/${courseId}/assignments?include[]=all_dates&include[]=overrides&per_page=100` | |
| ), | |
| getAll(`${api}/courses/${courseId}/pages?per_page=100`), | |
| getAll(`${api}/courses/${courseId}/modules?per_page=100`), | |
| ]); | |
| const assignments = []; | |
| for (const a of assignmentsRaw) { | |
| const overrides = | |
| (a.assignment_overrides || a.overrides || []) | |
| .map(simplifyOverride) | |
| .filter(Boolean); | |
| const fetchedOverrides = overrides.length | |
| ? overrides | |
| : await fetchAssignmentOverrides(courseId, a.id); | |
| assignments.push({ | |
| type: "assignment", | |
| id: a.id, | |
| keyName: a.name, | |
| name: a.name, | |
| points_possible: a.points_possible, | |
| grading_type: a.grading_type, | |
| submission_types: (a.submission_types || []).sort(), | |
| due_at: isoDate(a.due_at), | |
| unlock_at: isoDate(a.unlock_at), | |
| lock_at: isoDate(a.lock_at), | |
| published: a.published, | |
| muted: a.muted, | |
| only_visible_to_overrides: a.only_visible_to_overrides, | |
| section_overrides: fetchedOverrides.sort((x, y) => | |
| String(x.section_id).localeCompare(String(y.section_id)) | |
| ), | |
| }); | |
| await sleep(20); | |
| } | |
| const pages = await Promise.all( | |
| pagesRaw.map(async (p) => { | |
| let full = p; | |
| try { | |
| full = await getJson(`${api}/courses/${courseId}/pages/${encodeURIComponent(p.url)}`); | |
| } catch {} | |
| return { | |
| type: "page", | |
| id: p.page_id || p.url, | |
| keyName: p.title, | |
| title: p.title, | |
| url: p.url, | |
| published: p.published, | |
| front_page: p.front_page, | |
| editing_roles: full.editing_roles, | |
| lock_at: isoDate(full.lock_at), | |
| unlock_at: isoDate(full.unlock_at), | |
| todo_date: isoDate(full.todo_date), | |
| body_text: String(full.body || "").replace(/<[^>]+>/g, "").trim(), | |
| }; | |
| }) | |
| ); | |
| const modules = []; | |
| for (const m of modulesRaw) { | |
| const items = await getAll( | |
| `${api}/courses/${courseId}/modules/${m.id}/items?per_page=100` | |
| ); | |
| modules.push({ | |
| type: "module", | |
| id: m.id, | |
| keyName: m.name, | |
| name: m.name, | |
| position: m.position, | |
| published: m.published, | |
| unlock_at: isoDate(m.unlock_at), | |
| require_sequential_progress: m.require_sequential_progress, | |
| items: items.map((i) => ({ | |
| title: i.title, | |
| type: i.type, | |
| content_id: i.content_id, | |
| position: i.position, | |
| indent: i.indent, | |
| published: i.published, | |
| completion_requirement: i.completion_requirement || null, | |
| })), | |
| }); | |
| } | |
| return { | |
| course, | |
| assignments, | |
| pages, | |
| modules, | |
| }; | |
| } | |
| function pairByName(left, right, tolerance) { | |
| const pairs = []; | |
| const usedRight = new Set(); | |
| for (const l of left) { | |
| let best = null; | |
| for (const r of right) { | |
| if (usedRight.has(r)) continue; | |
| const d = levenshtein(l.keyName, r.keyName); | |
| if (d <= tolerance && (!best || d < best.distance)) { | |
| best = { r, distance: d }; | |
| } | |
| } | |
| if (best) { | |
| usedRight.add(best.r); | |
| pairs.push({ left: l, right: best.r, distance: best.distance }); | |
| } else { | |
| pairs.push({ left: l, right: null, distance: null }); | |
| } | |
| } | |
| for (const r of right) { | |
| if (!usedRight.has(r)) pairs.push({ left: null, right: r, distance: null }); | |
| } | |
| return pairs; | |
| } | |
| function diffObjects(a, b, ignore = ["id", "type", "keyName"]) { | |
| const keys = [...new Set([...Object.keys(a || {}), ...Object.keys(b || {})])] | |
| .filter((k) => !ignore.includes(k)); | |
| return keys | |
| .map((k) => { | |
| const av = a ? a[k] : undefined; | |
| const bv = b ? b[k] : undefined; | |
| return stable(av) === stable(bv) ? null : { field: k, a: av, b: bv }; | |
| }) | |
| .filter(Boolean); | |
| } | |
| function renderValue(v) { | |
| if (Array.isArray(v) || typeof v === "object") { | |
| return `<pre>${esc(JSON.stringify(v, null, 2))}</pre>`; | |
| } | |
| return `<span>${esc(v ?? "—")}</span>`; | |
| } | |
| function renderDiffSection(title, pairs) { | |
| const rows = pairs | |
| .map((p) => { | |
| if (!p.left || !p.right) { | |
| const item = p.left || p.right; | |
| return ` | |
| <details open class="ccd-node"> | |
| <summary> | |
| <b>${esc(item.keyName)}</b> | |
| <span class="ccd-badge">${p.left ? "Only in Course A" : "Only in Course B"}</span> | |
| </summary> | |
| </details> | |
| `; | |
| } | |
| const diffs = diffObjects(p.left, p.right); | |
| if (!diffs.length) return ""; | |
| return ` | |
| <details class="ccd-node"> | |
| <summary> | |
| <b>${esc(p.left.keyName)}</b> | |
| ${p.distance ? `<span class="ccd-muted">name distance ${p.distance}</span>` : ""} | |
| </summary> | |
| <table class="ccd-table"> | |
| <thead> | |
| <tr><th>Field</th><th>Course A</th><th>Course B</th></tr> | |
| </thead> | |
| <tbody> | |
| ${diffs | |
| .map( | |
| (d) => ` | |
| <tr> | |
| <td><code>${esc(d.field)}</code></td> | |
| <td class="ccd-diff">${renderValue(d.a)}</td> | |
| <td class="ccd-diff">${renderValue(d.b)}</td> | |
| </tr>` | |
| ) | |
| .join("")} | |
| </tbody> | |
| </table> | |
| </details> | |
| `; | |
| }) | |
| .join(""); | |
| if (!rows.trim()) return ""; | |
| return ` | |
| <section class="ccd-section"> | |
| <h3>${esc(title)}</h3> | |
| ${rows} | |
| </section> | |
| `; | |
| } | |
| function showProgress(msg) { | |
| let box = $("#ccd-progress"); | |
| if (!box.length) { | |
| box = $(`<div id="ccd-progress" style="padding:16px">${esc(msg)}</div>`); | |
| box.dialog({ title: "Canvas Course Diff", width: 480, modal: true }); | |
| } else { | |
| box.text(msg); | |
| } | |
| } | |
| try { | |
| const choice = await chooseCoursesDialog(); | |
| if (!choice) return; | |
| saveRecent(choice.courseA); | |
| saveRecent(choice.courseB); | |
| showProgress("Downloading Course A resources..."); | |
| const left = await fetchCourseBundle(choice.courseA); | |
| showProgress("Downloading Course B resources..."); | |
| const right = await fetchCourseBundle(choice.courseB); | |
| showProgress("Building diff..."); | |
| const assignmentPairs = pairByName(left.assignments, right.assignments, choice.tolerance); | |
| const pagePairs = pairByName(left.pages, right.pages, choice.tolerance); | |
| const modulePairs = pairByName(left.modules, right.modules, choice.tolerance); | |
| const body = ` | |
| <style> | |
| .ccd-wrap { | |
| font-family: system-ui, sans-serif; | |
| max-height: 72vh; | |
| overflow: auto; | |
| padding: 12px; | |
| } | |
| .ccd-header { | |
| position: sticky; | |
| top: 0; | |
| background: #fff; | |
| z-index: 2; | |
| border-bottom: 1px solid #ddd; | |
| padding-bottom: 10px; | |
| margin-bottom: 12px; | |
| } | |
| .ccd-section { | |
| margin: 18px 0; | |
| } | |
| .ccd-section h3 { | |
| border-bottom: 2px solid #ddd; | |
| padding-bottom: 4px; | |
| } | |
| .ccd-node { | |
| border: 1px solid #ddd; | |
| border-radius: 8px; | |
| margin: 8px 0; | |
| padding: 8px 10px; | |
| background: #fafafa; | |
| } | |
| .ccd-node summary { | |
| cursor: pointer; | |
| } | |
| .ccd-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| margin-top: 8px; | |
| background: #fff; | |
| } | |
| .ccd-table th, | |
| .ccd-table td { | |
| border: 1px solid #ddd; | |
| padding: 6px; | |
| vertical-align: top; | |
| width: 33%; | |
| } | |
| .ccd-table th { | |
| background: #f3f3f3; | |
| position: sticky; | |
| top: 54px; | |
| } | |
| .ccd-diff { | |
| background: #fff7d6; | |
| } | |
| .ccd-badge { | |
| display: inline-block; | |
| margin-left: 8px; | |
| padding: 2px 6px; | |
| border-radius: 999px; | |
| background: #e8eefc; | |
| font-size: 12px; | |
| } | |
| .ccd-muted { | |
| color: #666; | |
| font-size: 12px; | |
| margin-left: 8px; | |
| } | |
| .ccd-table pre { | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| margin: 0; | |
| max-height: 220px; | |
| overflow: auto; | |
| } | |
| </style> | |
| <div class="ccd-wrap"> | |
| <div class="ccd-header"> | |
| <h2>Canvas Course Diff</h2> | |
| <div><b>Course A:</b> ${esc(left.course.name)} [${esc(left.course.id)}]</div> | |
| <div><b>Course B:</b> ${esc(right.course.name)} [${esc(right.course.id)}]</div> | |
| <div><b>Name tolerance:</b> ${esc(choice.tolerance)} characters</div> | |
| </div> | |
| ${ | |
| renderDiffSection("Assignments", assignmentPairs) || | |
| renderDiffSection("Pages", pagePairs) || | |
| renderDiffSection("Modules", modulePairs) | |
| ? [ | |
| renderDiffSection("Assignments", assignmentPairs), | |
| renderDiffSection("Pages", pagePairs), | |
| renderDiffSection("Modules", modulePairs), | |
| ].join("") | |
| : "<p>No differences found.</p>" | |
| } | |
| </div> | |
| `; | |
| $("#ccd-progress").dialog("destroy").remove(); | |
| $(body).dialog({ | |
| title: "Canvas Course Diff", | |
| width: Math.min(window.innerWidth - 80, 1200), | |
| height: Math.min(window.innerHeight - 80, 820), | |
| modal: false, | |
| buttons: { | |
| Close() { | |
| $(this).dialog("destroy").remove(); | |
| }, | |
| }, | |
| }); | |
| } catch (err) { | |
| $("#ccd-progress").dialog("destroy").remove(); | |
| console.error(err); | |
| alert(`Canvas course diff failed:\n\n${err.message || err}`); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment