Skip to content

Instantly share code, notes, and snippets.

@acbart
Created May 3, 2026 15:54
Show Gist options
  • Select an option

  • Save acbart/98b15f21f7eb20d3f0280eee69ab961a to your computer and use it in GitHub Desktop.

Select an option

Save acbart/98b15f21f7eb20d3f0280eee69ab961a to your computer and use it in GitHub Desktop.
Diff two canvas courses
(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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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