const domain = window.location.origin;
let current_page = window.location.pathname;
// Canvas' "New Canvas" UI navigates client-side via history.pushState/
// replaceState without a full page reload. current_page is captured once at
// document_start, so without this hook it goes stale and page-specific features
// (Back to Assignment button, sequence-footer removal, profile logout button)
// never activate when the user clicks into a page instead of loading it directly.
function setupNavigationListener() {
const update = () => {
const next = window.location.pathname;
if (next === current_page) return;
current_page = next;
// Re-run the page-scoped watchers so they evaluate against the new URL.
watchSequenceFooter();
watchSubmissionPageButton();
watchProfileLogoutPageButton();
watchGradeAnalytics();
};
for (const method of ["pushState", "replaceState"]) {
const orig = history[method];
history[method] = function (...args) {
const ret = orig.apply(this, args);
update();
return ret;
};
}
window.addEventListener("popstate", update);
}
function getCurrentCourseId() {
const match = current_page.match(/^\/courses\/(\d+)(?:\/|$)/);
return match ? parseInt(match[1]) : null;
}
function getSidebarLayoutMode() {
if (current_page.match(/^\/courses\/(\d+)(?:\/|$)/)) return "course";
if (isProfilePage()) return "course";
if (current_page === "/courses" || current_page === "/courses/") return "dash";
if (current_page === "/" || current_page === "") return "dash";
return "dash";
}
function isGradesPage() {
return /^\/courses\/\d+\/grades(?:\/|$)/.test(current_page);
}
function isCoursesIndexPage() {
return /^\/courses\/?$/.test(current_page);
}
function isGroupsIndexPage() {
return /^\/groups\/?$/.test(current_page);
}
function isConversationsPage() {
return /^\/conversations(?:\/|$)/.test(current_page);
}
function isAccountsPage() {
return /^\/accounts(?:\/|$)/.test(current_page);
}
function isProfilePage() {
return /^\/profile(?:\/|$)/.test(current_page);
}
// Quiz pages: /courses/123/quizzes/456 (pre-take/intro) and
// /courses/123/quizzes/456/take (the actual quiz).
function isQuizPage() {
return /^\/courses\/\d+\/quizzes\/\d+(?:\/|$)/.test(current_page);
}
function isQuizTakePage() {
return /^\/courses\/\d+\/quizzes\/\d+\/take(?:\/|$)/.test(current_page);
}
function isQuizPreTakePage() {
return isQuizPage() && !isQuizTakePage();
}
// "Quiz safe mode" disables features that interfere with the default Canvas
// quiz experience. Only active on quiz pages when the user has opted in.
function quizSafeModeActive() {
return isQuizPage() && options.quiz_safe_mode === true;
}
// Read the URL live: Canvas' "New Canvas" UI navigates client-side via
// history.pushState/replaceState, and current_page (captured at document_start)
// can be stale when the user clicks into a submission page. Reading
// window.location.pathname at check time makes this correct regardless.
function getSubmissionAssignmentLink() {
const match = window.location.pathname.match(/^\/courses\/(\d+)\/assignments\/(\d+)\/submissions\/(\d+)(?:\/|$)/);
if (!match) return null;
return `${domain}/courses/${match[1]}/assignments/${match[2]}/`;
}
// The content container isn't always #content on every Canvas layout (submission
// pages in particular may render into .ic-Layout-contentMain or #main). Match the
// quiz-safe-mode banner's container finder so the button lands in the visible
// content area; fall back to
so injection never silently no-ops.
function findContentContainer() {
return document.querySelector(".ic-Layout-contentMain")
|| document.getElementById("content")
|| document.querySelector("#main")
|| document.body;
}
let submissionPageButtonObserver = null;
let submissionButtonScheduled = false;
let profileLogoutButtonObserver = null;
let newCanvasButtonObserver = null;
let sequenceFooterObserver = null;
function addSubmissionPageButton() {
const assignmentLink = getSubmissionAssignmentLink();
if (!assignmentLink) return;
// Place the button inline with the "Submission Details" heading and the
// grade-values table, inside the .submission-details-header__heading-and-grades
// flex row (appended so it sits to the right of the grade summary). Only inject
// once that row exists; if it's not there yet the persistent MutationObserver
// re-tries on the next DOM change so we never fall back to body/#content
// (which would put the button at the bottom of the page).
const row = document.querySelector(".submission-details-header__heading-and-grades")
|| document.querySelector(".submission-details-header")
|| document.querySelector(".submission_details");
if (!row || row.querySelector("#canvasrefined-assignment-return")) return;
// Insert between the h1 heading and the grade-summary div so it reads
// [Heading] [Back to Assignment] [Grade]. Falls back to appending if the
// grade-summary div isn't found for some reason.
const gradeSummary = row.querySelector(".submission-details-header__grade-summary");
const btn = makeElement("a", row, {
id: "canvasrefined-assignment-return",
className: "canvasrefined-custom-btn",
href: assignmentLink,
textContent: "Back to Assignment",
style: "display:inline-flex;align-items:center;justify-content:center;align-self:center;margin-left:auto;margin-right:12px;padding:6px 12px;text-decoration:none;font-weight:700;color:inherit!important;",
});
if (gradeSummary && gradeSummary.parentNode === row) {
row.insertBefore(btn, gradeSummary);
}
}
function addProfileLogoutPageButton() {
if (!isProfilePage()) return;
const content = document.getElementById("content");
if (!content || content.querySelector("#canvasrefined-profile-logout")) return;
makeElement("a", content, {
id: "canvasrefined-profile-logout",
className: "canvasrefined-custom-btn",
href: `${domain}/logout`,
textContent: "Logout",
style: "display:inline-flex;align-items:center;justify-content:center;align-self:flex-start;margin:0 0 12px 0;padding:10px 14px;text-decoration:none;font-weight:700;",
}, true);
}
function ensureProfileLogoutPageButton() {
if (!isProfilePage()) return false;
const content = document.getElementById("content");
if (!content) return false;
if (content.querySelector("#canvasrefined-profile-logout")) return true;
addProfileLogoutPageButton();
return Boolean(content.querySelector("#canvasrefined-profile-logout"));
}
function watchProfileLogoutPageButton() {
if (!isProfilePage()) {
document.getElementById("canvasrefined-profile-logout")?.remove();
return;
}
if (ensureProfileLogoutPageButton()) return;
if (profileLogoutButtonObserver) return;
profileLogoutButtonObserver = new MutationObserver(() => {
if (ensureProfileLogoutPageButton() && profileLogoutButtonObserver) {
profileLogoutButtonObserver.disconnect();
profileLogoutButtonObserver = null;
}
});
profileLogoutButtonObserver.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => {
if (profileLogoutButtonObserver) {
profileLogoutButtonObserver.disconnect();
profileLogoutButtonObserver = null;
}
}, 10000);
}
// Reconcile the button against the current page on a rAF-throttled schedule.
// Canvas (React-based "New Canvas" UI) re-renders the content area on SPA
// navigation and can wipe our injected node; a persistent observer re-adds it.
function maintainSubmissionPageButton() {
if (submissionButtonScheduled) return;
submissionButtonScheduled = true;
requestAnimationFrame(() => {
submissionButtonScheduled = false;
const link = getSubmissionAssignmentLink();
const existing = document.getElementById("canvasrefined-assignment-return");
if (!link) {
existing?.remove();
return;
}
if (existing) {
if (existing.href !== link) existing.href = link;
return;
}
addSubmissionPageButton();
});
}
function isAssignmentPage() {
return /^\/courses\/\d+\/assignments(?:\/\d+)?(?:\/|$)/.test(current_page);
}
function removeSequenceFooter() {
if (options.hide_sequence_footer !== true) return false;
if (!isAssignmentPage()) return false;
const sequenceFooter = document.getElementById("sequence_footer");
if (!sequenceFooter) return false;
sequenceFooter.remove();
return true;
}
// CSS-based hiding is the primary mechanism: the style element persists across
// Canvas re-renders and full reloads, so the footer can never flash back after
// the JS observer has removed it (or timed out) once.
function applyHideSequenceFooter() {
let style = document.getElementById("canvasrefined-hide-sequence-footer");
if (options.hide_sequence_footer === true) {
if (!style) {
style = document.createElement("style");
style.id = "canvasrefined-hide-sequence-footer";
style.textContent = "#sequence_footer{display:none!important}";
(document.head || document.documentElement).appendChild(style);
}
} else if (style) {
style.remove();
}
}
function watchSequenceFooter() {
applyHideSequenceFooter();
if (options.hide_sequence_footer !== true) {
if (sequenceFooterObserver) {
sequenceFooterObserver.disconnect();
sequenceFooterObserver = null;
}
return;
}
if (!isAssignmentPage()) return;
if (removeSequenceFooter()) return;
if (sequenceFooterObserver) return;
// The observer strips the footer from the DOM (no leftover gap), and
// disconnects once removed — after that (or after the 10s timeout below)
// the CSS rule above is what keeps it hidden across Canvas re-renders.
sequenceFooterObserver = new MutationObserver(() => {
if (removeSequenceFooter() && sequenceFooterObserver) {
sequenceFooterObserver.disconnect();
sequenceFooterObserver = null;
}
});
sequenceFooterObserver.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => {
if (sequenceFooterObserver) {
sequenceFooterObserver.disconnect();
sequenceFooterObserver = null;
}
}, 10000);
}
// One persistent, rAF-throttled observer that keeps the button present whenever
// we're on a submission page. Unlike the old 10s-disconnecting observer, this
// survives Canvas' post-navigation re-renders that remove injected nodes. The
// extra delayed checks cover React hydration that wipes the button after our
// first add without emitting any later mutation for the observer to catch.
function watchSubmissionPageButton() {
if (submissionPageButtonObserver) return;
maintainSubmissionPageButton();
submissionPageButtonObserver = new MutationObserver(maintainSubmissionPageButton);
submissionPageButtonObserver.observe(document.documentElement, { childList: true, subtree: true });
for (const ms of [300, 800, 1600, 3000, 5000]) {
setTimeout(maintainSubmissionPageButton, ms);
}
}
function removeNewCanvasButton() {
document.querySelectorAll('[data-testid="switch-to-new-dashboard-button"]').forEach(btn => btn.remove());
}
function watchNewCanvasButton() {
if (newCanvasButtonObserver) {
newCanvasButtonObserver.disconnect();
newCanvasButtonObserver = null;
}
if (options.hide_new_canvas !== true) return;
removeNewCanvasButton();
let newCanvasButtonScheduled = false;
newCanvasButtonObserver = new MutationObserver((mutationList) => {
if (options.hide_new_canvas !== true) {
if (newCanvasButtonObserver) {
newCanvasButtonObserver.disconnect();
newCanvasButtonObserver = null;
}
return;
}
// Only scan when nodes were actually added, and coalesce to one pass per frame.
let added = false;
for (const mutation of mutationList) {
if (mutation.addedNodes && mutation.addedNodes.length) { added = true; break; }
}
if (!added || newCanvasButtonScheduled) return;
newCanvasButtonScheduled = true;
requestAnimationFrame(() => {
newCanvasButtonScheduled = false;
if (options.hide_new_canvas !== true) return;
removeNewCanvasButton();
});
});
newCanvasButtonObserver.observe(document.documentElement, { childList: true, subtree: true });
}
async function getActiveCustomBackground() {
const syncOpts = await chrome.storage.sync.get([
"customBackgroundDaily",
"customBackgroundNasaDaily",
"customBackgroundLink",
"customBackgroundScale",
]);
if (syncOpts.customBackgroundNasaDaily === true) {
return await getNasaDailyBackground();
}
if (syncOpts.customBackgroundDaily === true) {
const dailyPreset = await getDailyBackgroundPreset();
if (dailyPreset) {
return {
url: dailyPreset.url,
scale: dailyPreset.scale,
};
}
}
if (syncOpts.customBackgroundLink && syncOpts.customBackgroundLink !== "") {
return {
url: syncOpts.customBackgroundLink,
scale: syncOpts.customBackgroundScale || 100,
};
}
return null;
}
async function getDailyBackgroundPreset() {
const today = new Date();
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const cacheKey = `picsum_daily_${dateStr}`;
const cached = await chrome.storage.local.get(cacheKey);
if (cached[cacheKey]) return cached[cacheKey];
const url = `https://picsum.photos/seed/${dateStr}/1920/1080`;
const result = { url, scale: 100 };
await chrome.storage.local.set({ [cacheKey]: result });
return result;
}
async function getNasaDailyBackground() {
try {
return await chrome.runtime.sendMessage({ type: "getNasaBackground" });
} catch (error) {
console.error("[CanvasRefined] Failed to fetch NASA APOD:", error);
return null;
}
}
let nasaInfoOverlayEl = null;
function isDashboardPage() {
return !!document.querySelector("#DashboardCard_Container");
}
function createNasaInfoOverlay() {
if (options.customBackgroundNasaDaily !== true) return;
if (nasaInfoOverlayEl || !isDashboardPage()) return;
const contentMain = document.querySelector("#content.ic-Layout-contentMain, .ic-Layout-contentMain");
if (!contentMain) return;
if (getComputedStyle(contentMain).position === "static") {
contentMain.style.position = "relative";
}
nasaInfoOverlayEl = document.createElement("div");
nasaInfoOverlayEl.id = "canvasrefined-nasa-info-overlay";
nasaInfoOverlayEl.style.cssText = "position:absolute;right:24px;bottom:24px;z-index:9999;";
nasaInfoOverlayEl.innerHTML = `
`;
const icon = nasaInfoOverlayEl.querySelector("#nasa-info-icon");
const panel = nasaInfoOverlayEl.querySelector("#nasa-info-panel");
const populatePanel = async () => {
// Search backward up to 7 days for the APOD actually cached, since today's may not be ready.
const date = new Date();
for (let i = 0; i < 7; i++) {
const dateStr = date.toISOString().slice(0, 10);
const cacheKey = `nasa_apod_${dateStr}`;
const cached = await chrome.storage.local.get(cacheKey);
if (!cached[cacheKey]) {
date.setDate(date.getDate() - 1);
continue;
}
const metadataKey = `nasa_apod_meta_${dateStr}`;
const metadata = await chrome.storage.local.get(metadataKey);
const meta = metadata[metadataKey];
if (!meta) return false;
document.getElementById("nasa-info-title").textContent = meta.title || "";
document.getElementById("nasa-info-date").textContent = `Date: ${meta.date}`;
document.getElementById("nasa-info-credit").textContent = meta.copyright ? `Credit: ${meta.copyright}` : "";
document.getElementById("nasa-info-explanation").textContent = meta.explanation || "No description available.";
return true;
}
return false;
};
let pinned = false;
const showPanel = async () => {
if (pinned) return;
if (await populatePanel()) panel.style.display = "block";
};
const hidePanel = () => {
if (pinned) return;
panel.style.display = "none";
};
const togglePanel = async () => {
if (pinned) {
pinned = false;
panel.style.display = "none";
} else {
pinned = true;
if (await populatePanel()) panel.style.display = "block";
}
};
icon.addEventListener("mouseenter", showPanel);
icon.addEventListener("mouseleave", hidePanel);
panel.addEventListener("mouseenter", showPanel);
panel.addEventListener("mouseleave", hidePanel);
icon.addEventListener("click", togglePanel);
contentMain.appendChild(nasaInfoOverlayEl);
}
function removeNasaInfoOverlay() {
if (nasaInfoOverlayEl) {
nasaInfoOverlayEl.remove();
nasaInfoOverlayEl = null;
}
}
function getSidebarStateMode(mode = getSidebarLayoutMode()) {
return mode === "course" ? "course" : "dashboard";
}
function getSidebarStateKey(mode = getSidebarLayoutMode()) {
return `better_sidebar_expanded_${getSidebarStateMode(mode)}`;
}
async function getSidebarExpandedState(mode = getSidebarLayoutMode()) {
const key = getSidebarStateKey(mode);
const result = await chrome.storage.local.get(key);
return result[key] ?? false;
}
function setSidebarExpandedState(mode, expanded) {
chrome.storage.local.set({ [getSidebarStateKey(mode)]: expanded });
}
let assignments = null;
let grades = null;
let announcements = [];
let completed = [];
let assignmentsDue = [];
let options = {};
let timeCheck = null;
let reminderCheck = null;
let betterSidebarLoading = false;
let dashboardReadyTimer = null;
let sidebarReadyTimer = null;
// Signature of the dashboard card set last time we ran the full setup pass.
// The MutationObserver in checkDashboardReady fires on every childList change
// in the document — including the ones our own setup pass (loadCardAssignments,
// customizeCards, etc.) causes. Without a guard, that re-triggers the observer
// and re-runs the whole pass every animation frame (an infinite reflow loop).
// We only need to re-run when Canvas actually changes the dashboard cards, so
// we skip the heavy pass whenever the card set is unchanged.
let lastDashboardCardSignature = null;
let sidebarBadgeObserver = null;
let sidebarBadgeSyncTimer = null;
let sidebarBadgeWatchRetries = 0;
/*
Start
*/
/*
Todo Reminders
*/
const canvas_svg = ``;
async function insertReminders(reminders) {
const toAdd = [];
const storage = await chrome.storage.sync.get("reminders");
// overrides = if theres a item that needs to update, but already exists
let overrides = false;
for (const insert of reminders) {
let found = false;
for (let i = 0; i < storage["reminders"].length; i++) {
// check if item was recently submitted
if (insert.c === -1 && insert.h === storage["reminders"][i].h) {
overrides = true;
storage["reminders"][i] = insert;
} else if (insert.h === storage["reminders"][i].h) {
found = true;
}
}
if (found === false) toAdd.push(insert);
}
if (toAdd.length > 0 || overrides === true) chrome.storage.sync.set({ "reminders": [...storage["reminders"], ...toAdd] });
}
async function hideReminder(href) {
const storage = await chrome.storage.sync.get("reminders");
for (let i = 0; i < storage["reminders"].length; i++) {
if (storage["reminders"][i]["h"] === href) {
storage["reminders"][i]["c"]++;
chrome.storage.sync.set({ "reminders": storage["reminders"] });
break;
}
}
}
function createReminder(reminder, location) {
const remaining = getRelativeDate(new Date(reminder.d));
const wrapper = makeElement("div", location, { "className": "canvasrefined-reminder-wrapper" });
const container = makeElement("div", wrapper, { "className": "canvasrefined-reminder-container" });
const svg = makeElement("div", container, { "innerHTML": canvas_svg });
const content = makeElement("a", container, { "className": "canvasrefined-reminder-content", "href": reminder.h, "target": "_blank" });
const title = makeElement("h2", content, { "className": "canvasrefined-reminder-title", "textContent": reminder.t });
const due = makeElement("p", content, { "className": "canvasrefined-reminder-due", "textContent": `Assignment due in ${remaining.time}` });
const hidebtn = makeElement("btn", wrapper, { "className": "canvasrefined-reminder-hide", "textContent": "x" });
hidebtn.addEventListener("click", () => {
hideReminder(reminder.h);
wrapper.remove();
});
return container;
}
async function reminderWatch() {
const sync = await chrome.storage.sync.get("remind");
if (sync["remind"] !== true) {
if (document.getElementById("canvasrefined-reminders")) document.getElementById("canvasrefined-reminders").style.display = "none";
return;
}
const container = document.getElementById("canvasrefined-reminders") || makeElement("div", document.body, { "id": "canvasrefined-reminders" });
container.style.display = "flex";
container.textContent = "";
const alertPeriod = 1000 * 60 * 60 * 6; // 6 hours
const alertPeriod2 = 1000 * 60 * 60 * 2; // 2 hours
const storage = await chrome.storage.sync.get(["reminders", "reminder_count"]);
const now = (new Date()).getTime();
storage["reminders"].forEach((reminder, index) => {
if (reminder.d < now) {
storage["reminders"].splice(index, 1);
} else if ((reminder.c == 0 && reminder.d < now + alertPeriod) || (reminder.c == 1 && reminder.d < now + alertPeriod2)) {
createReminder(reminder, container);
}
});
chrome.storage.sync.set({ "reminders": storage["reminders"] });
}
function updateReminders() {
if (!assignments || typeof assignments.then !== "function") return;
const fiveDays = 1000 * 60 * 60 * 24 * 5;
const now = (new Date()).getTime();
const list = [];
assignments.then(data => {
data.forEach(item => {
const due = (new Date(item.plannable_date)).getTime();
if (item.plannable_type === "announcement") return;
if (due < now) return;
if (due > now + fiveDays * 2) return;
// { due, title, href, hide count }
// hide count of -1 indicates the item has a submission
list.push({ "d": due, "t": item.plannable.title, "h": domain + item.html_url, "c": item?.submissions?.submitted || false ? -1 : 0 });
});
insertReminders(list);
});
}
function showExampleReminder() {
const location = document.getElementById("canvasrefined-reminders") || makeElement("div", document.body, { "id": "canvasrefined-reminders" });
if (options.remind !== true) {
location.remove();
return;
}
location.textContent = "";
const example = createReminder({ "d": new Date(), "t": "This is an example reminder", }, location);
example.querySelector(".canvasrefined-reminder-due").textContent = "This notification will pop up in other pages to remind you of incomplete assignments that are due in less than 6 hours." /*It will notify again at 2 hours if the 'Remind 2x' option is on."*/;
}
isDomainCanvasPage();
function isDomainCanvasPage() {
chrome.storage.sync.get(['custom_domain', 'dark_mode', 'dark_preset', 'device_dark', 'remind'], result => {
options = result;
if (result.custom_domain.length && result.custom_domain[0] !== "") {
for (let i = 0; i < result.custom_domain.length; i++) {
if (domain.includes(result.custom_domain[i])) {
startExtension();
return;
}
}
// if the code reaches this point, its not a canvas page so run the reminders
setTimeout(reminderWatch, 2000);
setInterval(reminderWatch, 60000);
// turn the reminders on/off if the option is changed
chrome.storage.onChanged.addListener((changes) => {
Object.keys(changes).forEach(key => {
if (key === "remind") reminderWatch();
})
})
} else {
setupCustomURL();
}
});
}
function startExtension() {
// Remove footer robustly - run first so a crash below can't block it
const removeFooter = () => {
const footer = document.querySelector('footer#footer.ic-app-footer, footer#footer');
if (footer) footer.remove();
};
removeFooter();
let footerScheduled = false;
const footerObserver = new MutationObserver(() => {
// Canvas mutates the DOM constantly; only check for the footer at most once
// per animation frame instead of running a querySelector on every mutation.
if (footerScheduled) return;
footerScheduled = true;
requestAnimationFrame(() => {
footerScheduled = false;
removeFooter();
});
});
footerObserver.observe(document.documentElement, { childList: true, subtree: true });
// Start the submission-page "Back to Assignment" button watcher and the SPA
// navigation hook immediately, before the async storage callbacks below.
// These don't depend on `options`, and running them first means a throw in
// any later init step can't prevent the button from appearing. The watcher
// reads window.location.pathname live and uses a persistent MutationObserver
// to (re)inject the button once the content container exists.
setupNavigationListener();
watchSubmissionPageButton();
toggleDarkMode();
// Include bg_opacity/bg_blur so setupBetterSidebar (called below) tints the
// course-content panel with the user's slider values on first load, instead
// of falling back to the defaults until a slider is touched.
chrome.storage.sync.get(["better_sidebar", "sidebar_scale", "bg_opacity", "bg_blur"], result => {
options = { ...options, ...result };
ensureBetterSidebar();
});
chrome.storage.sync.get(null, result => {
options = { ...options, ...result };
applyTodoAlternateColors();
toggleAutoDarkMode();
// toggleScheduledReminders();
getApiData();
checkDashboardReady();
loadCustomFont();
applyAestheticChanges();
watchNewCanvasButton();
changeFavicon();
updateReminders();
applyCustomBackground();
ensureBetterSidebar();
watchSequenceFooter();
watchProfileLogoutPageButton();
watchGradeAnalytics();
setupQuizSafeModeBanner();
setupGlobalSearch();
setTimeout(() => runDarkModeFixer(false), 800);
setTimeout(() => runDarkModeFixer(false), 4500);
});
chrome.runtime.onMessage.addListener(recieveMessage);
chrome.storage.onChanged.addListener(applyOptionsChanges);
console.log("Canvas Refined - running");
}
function applyOptionsChanges(changes) {
let rewrite = {};
Object.keys(changes).forEach(key => {
rewrite[key] = changes[key].newValue;
});
options = { ...options, ...rewrite };
// when an option is updated it will call the necessary functions again
// so any changes made in the menu no longer require a refresh to apply
Object.keys(changes).forEach(key => {
switch (key) {
case "dark_mode":
case "dark_preset":
case "device_dark":
toggleDarkMode();
applyTodoAlternateColors();
// "Ignore card colors" picks black vs. theme text color based on dark
// mode, so re-render the Better Todo list to keep it in sync.
if (options.todo_ignore_card_colors && options.better_todo && document.getElementById("better-todo-main")) {
clearTodoList();
createTodoSections(document.querySelector("#canvasrefined-todo-list"));
}
break;
case "todo_alternate_colors":
applyTodoAlternateColors();
break;
case "auto_dark":
case "auto_dark_start":
case "auto_dark_end":
toggleAutoDarkMode();
break;
case "gradient_cards":
changeGradientCards();
break;
case "dashboard_notes":
case "dashboard_notes_text":
case "dashboard_notes_mode":
loadDashboardNotes();
break;
case "dashboard_grades":
case "grade_hover":
if (!grades) getGrades();
insertGrades();
break;
case "assignments_due":
case "num_assignments":
if (!assignments) getAssignments();
if (
document.querySelectorAll(".canvasrefined-card-assignment")
.length === 0
)
setupCardAssignments();
loadCardAssignments();
// The card overflow fix in applyAestheticChanges() depends on
// assignments_due, so re-run it when that option toggles to keep
// the overflow rule in sync without a page reload.
applyAestheticChanges();
break;
case "custom_assignments":
case "assignment_date_format":
case "card_overdues":
case "relative_dues":
cardAssignments = preloadAssignmentEls();
loadCardAssignments();
break;
case "equal_height_cards":
// Stretch or reset card heights in place instead of rebuilding rows.
equalizeCardHeights();
break;
case "custom_cards":
customizeCards();
// Hiding/unhiding a card changes which courses appear in the todo
// list and the progress display, so re-render them immediately.
if (options.better_todo && document.getElementById("better-todo-main")) {
moreAnnouncementCount = 0;
moreAssignmentCount = 0;
clearTodoList();
createTodoSections(document.querySelector("#canvasrefined-todo-list"));
}
break;
case "custom_cards_2":
case "custom_cards_3":
customizeCards();
break;
case "todo_hr24":
case "todo_separate_scrollbar":
case "num_todo_items":
case "hover_preview":
case "todo_timeframe":
// case "todo_overdues":
case "todo_hide_feedback":
case "todo_full_height":
case "todo_ignore_card_colors":
case "todo_remove_icons":
case "custom_cards_3":
moreAnnouncementCount = 0;
moreAssignmentCount = 0;
// loadBetterTodo();
clearTodoList();
createTodoSections(document.querySelector("#canvasrefined-todo-list"));
break;
case "gpa_calc":
case "gpa_calc_prepend":
case "gpa_calc_weighted":
case "gpa_calc_cumulative":
if (!grades) getGrades();
setupGPACalc();
break;
case "gpa_calc_bounds":
calculateGPA2();
break;
case "custom_font":
loadCustomFont();
break;
case "remlogo":
case "disable_color_overlay":
case "condensed_cards":
case "full_width":
case "center_cards":
case "custom_styles":
applyAestheticChanges();
break;
case "hide_new_canvas":
watchNewCanvasButton();
break;
case "hide_sequence_footer":
watchSequenceFooter();
break;
case "global_search":
if (options.global_search) {
setupGlobalSearch();
} else {
removeGlobalSearch();
}
break;
case "customBackgroundScale":
applyCustomBackground();
break;
case "customBackgroundDaily":
applyCustomBackground();
removeNasaInfoOverlay();
break;
case "customBackgroundNasaDaily":
applyCustomBackground();
if (options.customBackgroundNasaDaily === true) {
createNasaInfoOverlay();
} else {
removeNasaInfoOverlay();
}
break;
case "fitImageToScreen":
applyCustomBackground();
break;
case "remind":
showExampleReminder();
break;
case "imageSize":
case "cardRoundness":
case "cardSpacing":
case "cardWidth":
case "cardHeight":
case "cardPadding":
case "customCardStyles":
// Coalesce rapid card-style edits (e.g. holding the arrow keys on a
// number input) into a single applyAestheticChanges() call. Each
// storage onChanged event would otherwise re-run the dashboard style
// pass immediately; a ~150ms cooldown is barely noticeable but keeps
// the page from thrashing while the user is still adjusting values.
debouncedApplyAestheticChanges();
break;
case "customBackgroundLink":
applyCustomBackground();
break;
case "bg_opacity":
applyCustomBackground();
applyBetterSidebarContentPanel();
break;
case "bg_blur":
applyCustomBackground();
applyBetterSidebarContentPanel();
break;
case "sidebar_opacity":
case "sidebar_blur":
applyCustomBackground();
break;
case "card_transparency":
case "card_opacity":
case "card_blur":
applyCustomBackground();
break;
case "better_todo":
if (options.better_todo) {
setupBetterTodo();
} else {
window.location.reload();
}
break;
case "todo_progress_rings": {
// toggle progress rings immediately
const placeholder = document.getElementById("better-todo-progress-placeholder");
if (!placeholder) break;
if (progressRingsEnabled()) {
if (typeof assignments?.then === 'function') {
assignments.then(data => {
const courseId = getCurrentCourseId();
const scopedData = getTodoScopedData(data, courseId);
renderProgressRings(placeholder, scopedData);
});
}
} else {
placeholder.innerHTML = "";
}
break;
}
case "better_sidebar":
if (options.better_sidebar) {
ensureBetterSidebar();
} else {
resetBetterSidebarLayout();
}
break;
case "grade_analytics":
watchGradeAnalytics();
break;
case "grade_analytics_zones":
// Colored 10% zones on the line chart — just redraw the charts.
if (gradeAnalyticsActive() && gaOpen && gaData) renderGradeAnalytics();
break;
case "quiz_safe_mode":
// Toggling safe mode changes which features run on quiz pages; reload
// so the gating is applied cleanly.
if (isQuizPage()) window.location.reload();
break;
case "sidebar_scale": {
const existingSidebar = document.getElementById("better-sidebar-container");
if (existingSidebar) {
const expander = existingSidebar.querySelector(".better-sidebar-expander");
updateSidebar(existingSidebar.dataset.expanded === "true", existingSidebar, expander);
}
break;
}
}
});
}
function resetBetterSidebarLayout() {
document.getElementById("header")?.style.removeProperty("display");
document.querySelector(".ic-Layout-wrapper")?.style.removeProperty("margin-left");
document.querySelector("#main")?.style.removeProperty("margin-left");
document.querySelector(".ic-app-nav-toggle-and-crumbs")?.style.removeProperty("display");
document.getElementById("not_right_side")?.style.removeProperty("display");
document.getElementById("not_right_side")?.style.removeProperty("flex");
document.getElementById("not_right_side")?.style.removeProperty("min-width");
document.getElementById("right-side-wrapper")?.style.removeProperty("flex");
document.getElementById("right-side-wrapper")?.style.removeProperty("width");
document.getElementById("right-side-wrapper")?.style.removeProperty("max-width");
document.querySelector(".ic-Layout-contentWrapper")?.style.removeProperty("display");
document.querySelector(".ic-Layout-contentWrapper")?.style.removeProperty("align-items");
document.querySelector(".ic-Layout-contentWrapper")?.style.removeProperty("min-width");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("flex");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("min-width");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("margin");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("padding");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("background");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("backdrop-filter");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("-webkit-backdrop-filter");
document.getElementById("left-side")?.style.removeProperty("display");
document.getElementById("left-side")?.style.removeProperty("padding-top");
document.getElementById("left-side")?.style.removeProperty("padding-left");
document.getElementById("section-tabs")?.style.removeProperty("padding-top");
document.getElementById("better-sidebar-container")?.remove();
clearBetterSidebarLayoutFix();
if (sidebarBadgeObserver) { sidebarBadgeObserver.disconnect(); sidebarBadgeObserver = null; }
if (sidebarBadgeSyncTimer) { clearTimeout(sidebarBadgeSyncTimer); sidebarBadgeSyncTimer = null; }
sidebarBadgeWatchRetries = 0;
}
function ensureBetterSidebar() {
if (!options.better_sidebar) return;
// Quiz safe mode: don't replace the Canvas sidebar on quiz pages.
if (quizSafeModeActive()) return;
if (document.querySelector("#better-sidebar-container")) return;
if (!document.querySelector("#wrapper") || !document.querySelector(".ic-Layout-contentWrapper")) return;
setupBetterSidebar(getSidebarLayoutMode());
}
async function applyCustomBackground() {
// Quiz safe mode: leave the quiz page background untouched.
if (quizSafeModeActive()) return;
// let style = document.querySelector("#DashboardCard_Container")
let style = document.querySelector("#canvasrefined-background") || document.createElement('style');
style.id = "canvasrefined-background";
const activeBackground = await getActiveCustomBackground();
if (!activeBackground) {
if (style.isConnected) style.remove();
return;
}
const backgroundScale = Number(activeBackground.scale) || 100;
const backgroundUrl = JSON.stringify(activeBackground.url);
const fitToScreen = options.fitImageToScreen === true;
// Opacity sliders (0-100). 100 = fully opaque surface, 0 = fully transparent
// so the background image shows through. Only emitted while a background is
// active, since transparency without an image just exposes the dark body.
const bgOpacity = Math.max(0, Math.min(100, Number(options.bg_opacity ?? 65)));
const sidebarOpacity = Math.max(0, Math.min(100, Number(options.sidebar_opacity ?? 100)));
const bgTransparent = 100 - bgOpacity;
const sidebarTransparent = 100 - sidebarOpacity;
// Blur sliders (px). Pairs with opacity: blur only has a visible effect when
// the surface is semi-transparent (opacity < 100) so the background behind
// shows through and gets blurred. Default 8px on content surfaces preserves
// the previous dashboard-header glass look; sidebar defaults to none.
const bgBlur = Math.max(0, Math.min(30, Number(options.bg_blur ?? 8)));
const sidebarBlur = Math.max(0, Math.min(30, Number(options.sidebar_blur ?? 0)));
// Card transparency mirrors the content-panel glass effect (bg_opacity/
// bg_blur) but applies it to dashboard course cards (.ic-DashboardCard).
// Only active when the user explicitly enables it, since transparent cards
// over a busy background can hurt legibility.
const cardTransparency = options.card_transparency === true;
const cardOpacity = Math.max(0, Math.min(100, Number(options.card_opacity ?? 80)));
const cardBlur = Math.max(0, Math.min(30, Number(options.card_blur ?? 8)));
const cardTransparent = 100 - cardOpacity;
style.textContent = `
#wrapper {
background-image: url(${backgroundUrl}) !important;
background-repeat: no-repeat !important;
background-position: center center !important;
background-attachment: fixed !important;
}
@media (orientation: landscape) {
#wrapper { background-size: ${fitToScreen ? 'cover' : backgroundScale + '% auto'} !important; }
}
@media (orientation: portrait) {
#wrapper { background-size: cover !important; }
}
.ic-Dashboard-header__layout {
background: none !important;
/* backdrop-filter: blur(10px) !important; */
border-radius: 5px;
padding-left: 20px !important;
}
#dashboard_header_container {
margin-left: -35px !important;
margin-right: -35px !important;
box-sizing: border-box !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 60%, transparent) !important;
border-radius: 10px !important;
position: sticky !important;
top: 0 !important;
z-index: 1000 !important;
backdrop-filter: blur(${bgBlur}px) saturate(120%) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) saturate(120%) !important;
}
/* Dashboard list view (planner). Canvas paints each day block an
opaque theme color, so with a background image the whole list reads
as one solid slab that hides the image — unlike card view, where
the glass header and (optionally) translucent cards let it show
through. Give each day group the same glass treatment as the module
panels (color-mix tint + slider blur + rounded border) so the
background peeks through between the day cards. */
#dashboard-planner .planner-day,
#dashboard-planner .planner-empty-days {
background: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 12px !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 75%, transparent) !important;
padding: 8px 12px !important;
box-sizing: border-box !important;
}
/* Inner surfaces Canvas keeps opaque (course-grouping title strip,
"Show N completed item" facade, "Nothing Planned" filler, and the
Today/Add To Do header cluster that sits on the glass dashboard
header bar): flatten them so the glass behind shows through. */
#dashboard-planner .Grouping-styles__title,
#dashboard-planner .CompletedItemsFacade-styles__root,
#dashboard-planner .EmptyDays-styles__nothingPlanned,
#dashboard-planner-header .PlannerHeader-styles__root {
background: transparent !important;
}
/* Item-row hover: subtle tint on the glass instead of Canvas's flat
gray, so rows feel alive on the translucent day cards. */
#dashboard-planner .planner-item:hover,
#dashboard-planner .Grouping-styles__heroHover:hover {
background: color-mix(in srgb, var(--bctext-0) 5%, transparent) !important;
border-radius: 8px !important;
}
#right-side-wrapper {
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%);
border-radius: 5px;
}
/* Native left nav column: #left-side > #sticky-container.ic-sticky-frame
(the course/account/group menu links). Tint the whole #left-side column
rather than the inner .ic-sticky-frame, whose height only wraps its
links — the column spans the full viewport height like the other
sidebars, at the same bg_opacity/bg_blur as the Better Todo List
panel. Without this, a custom background (most visible in light mode)
shows through untinted behind the nav links. */
#left-side {
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
}
/* Recent feedback lives in #right-side. The dark-mode CSS
(darkmodecss.js) recolors its text, but those rules are dark-mode-
only — so in light mode + custom background the sub-text (context,
grade, quote) keeps Canvas's default gray on the now-translucent
panel and becomes hard to read. Recolor to the theme text color so
it stays readable in both modes whenever a background is active.
Mirrors the dark-mode selectors; redundant (same value) in dark mode. */
.recent_feedback .event-details {
background: none !important;
}
#right-side .event-details .event-details__context,
#right-side .event-details .event-details__context *,
#right-side .recent_feedback .event-details p,
#right-side .recent_feedback .event-details span {
color: var(--bctext-0) !important;
}
.event-details strong {
color: var(--bctext-0) !important;
}
/* Native global nav sidebar. color-mix only accepts a solid color, so
gradient/image sidebars keep their existing look (rule is invalid and
ignored). At 100% opacity this is equivalent to var(--bcsidebar).
Sidebar blur only shows when sidebar opacity < 100.
The icon/text colors are recolored to var(--bcsidebar-text) to match
the background we just set — without this, light mode (where
--bcsidebar is the light default #e3e3e3) would leave institution-
themed light icons on a now-light background = white-on-white.
Mirrors the dark-mode rules in css/darkmodecss.js. */
.ic-app-header {
background: color-mix(in srgb, var(--bcsidebar), transparent ${sidebarTransparent}%) !important;
backdrop-filter: blur(${sidebarBlur}px) !important;
-webkit-backdrop-filter: blur(${sidebarBlur}px) !important;
}
.ic-app-header__menu-list-link svg,
.ic-app-header__menu-list-item.ic-app-header__menu-list-item--active svg {
fill: var(--bcsidebar-text) !important;
}
.menu-item-icon-container,
.ic-app-header__menu-list-link .menu-item__text,
.ic-app-header__menu-list-item.ic-app-header__menu-list-item--active .menu-item__text {
color: var(--bcsidebar-text) !important;
}
.ic-app-header__menu-list-item.ic-app-header__menu-list-item--active .ic-app-header__menu-list-link,
.ic-app-header__menu-list-link:hover {
background: #0000004f !important;
}
/* Better sidebar. The inline background-color is var(--bcsidebar), so the
!important here is required to override it. The same sidebar_opacity /
sidebar_blur sliders drive both surfaces, so whichever sidebar is
active (Better Sidebar when enabled, otherwise the native nav) picks
up the value. */
#better-sidebar-container {
background-color: color-mix(in srgb, var(--bcsidebar), transparent ${sidebarTransparent}%) !important;
backdrop-filter: blur(${sidebarBlur}px) !important;
-webkit-backdrop-filter: blur(${sidebarBlur}px) !important;
}
.header-bar {
background: none !important;
padding: 0 !important;
border: none !important;
}
.item-group-condensed,
.item-group-container {
background: transparent !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 12px !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 75%, transparent) !important;
/* box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12) !important; */
}
#context_modules_sortable_container {
border: none !important;
background: none !important;
padding: 0 !important;
/* backdrop-filter: blur(0) !important; */
}
.item-group-condensed .ig-header,
.item-group-condensed .ig-row,
.item-group-container .ig-header,
.item-group-container .ig-row,
.item-group-condensed .header,
.item-group-container .header {
background: transparent !important;
}
.item-group-condensed .ig-header.header,
.item-group-container .ig-header.header {
background: none !important;
border: none !important;
border-radius: 0 !important;
}
#assignments.ui-tabs-panel {
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 5px !important;
}
#assignments {
padding-top: 0px !important;
padding-bottom: 0px !important;
padding-left: 10px !important;
padding-right: 10px !important;
}
${isCoursesIndexPage() ? `
#content {
margin: 36px 48px 48px !important;
padding: 10px !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 5px !important;
box-sizing: border-box !important;
}
` : ""}
${isGroupsIndexPage() ? `
#content {
margin: 36px 48px 48px !important;
padding: 10px !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 5px !important;
box-sizing: border-box !important;
}
` : ""}
/* Course content pages (sidebar layout "course": /courses/:id/* and
/profile): tint the #content.ic-Layout-contentMain panel so the
bg_opacity/bg_blur sliders have a surface to control even without Better
Sidebar. setupBetterSidebar only adds this panel when Better Sidebar is
on; this mirrors its inline values so the panel shows regardless. When
Better Sidebar is on, its inline !important overrides these (same values),
so this rule is inert in that case. */
${getSidebarLayoutMode() === "course" ? `
.ic-Layout-contentMain {
margin: 26px 38px 38px !important;
padding: 10px !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 10px !important;
}
` : ""}
${isConversationsPage() ? `
.css-1nh4pc4-view-flexItem {
background-color: color-mix(in srgb, var(--bcbackground-0), transparent ${bgTransparent}%) !important;
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
border-radius: 5px !important;
box-sizing: border-box !important;
}
.css-1nh4pc4-view-flexItem svg,
.css-1nh4pc4-view-flexItem svg * {
fill: currentColor !important;
stroke: currentColor !important;
color: var(--bctext-0) !important;
}
` : ""}
.item-group-condensed .ig-row.ig-published.no-estimated-duration {
color: var(--bctext-1) !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 60%, transparent) !important;
border-radius: 0 !important;
padding: 10px 12px !important;
}
.item-group-condensed .context_module_item,
.item-group-container .context_module_item {
background: transparent !important;
/* backdrop-filter: blur(10px) saturate(115%) !important;
-webkit-backdrop-filter: blur(10px) saturate(115%) !important; */
}
.item-group-condensed .context_module_item:hover,
.item-group-container .context_module_item:hover,
.item-group-condensed .context_module_item.context_module_item_hover,
.item-group-container .context_module_item.context_module_item_hover {
background: transparent !important;
border-radius: 10px !important;
}
.item-group-container {
background: transparent !important;
border-radius: 12px !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 75%, transparent) !important;
}
.ig-header {
/* backdrop-filter: blur(10px) !important; */
}
.item-group-condensed.context_module,
.item-group-condensed.context_module_item,
.item-group-condensed[class~="context_module"] {
margin-bottom: 10px !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.item-group-condensed .ig-header.header,
.item-group-container .ig-header.header {
padding-top: 0 !important;
}
/* Module panels keep the slider blur on hover (previously a fixed 5px). */
.item-group-condensed.context_module,
.item-group-condensed.context_module_item,
.item-group-condensed[class~="context_module"],
.item-group-condensed.context_module:hover,
.item-group-condensed.context_module_item:hover,
.item-group-condensed.context_module.context_module_item_hover,
.item-group-condensed.context_module_item.context_module_item_hover {
backdrop-filter: blur(${bgBlur}px) !important;
-webkit-backdrop-filter: blur(${bgBlur}px) !important;
}
.canvasrefined-gpa-card,
.canvasrefined-gpa,
.ic-DashboardCard {
${cardTransparency
? `background: color-mix(in srgb, var(--bcbackground-0), transparent ${cardTransparent}%) !important;
backdrop-filter: blur(${cardBlur}px) saturate(120%) !important;
-webkit-backdrop-filter: blur(${cardBlur}px) saturate(120%) !important;`
: `background: var(--bcbackground-0) !important;`}
}
/* Card header strip (the course-nickname bar under the hero). Canvas
paints it a solid light color ($ic-color-light) and nothing overrides
that in light mode, so with card transparency on it reads as a solid
band across an otherwise translucent card. Mirror the card surface:
transparent cards drop the strip's background so the card's glass
(tint + blur already applied to .ic-DashboardCard) shows through;
opaque cards paint it the same solid theme color as the card body.
Dark mode already flattens this strip via darkmodecss.js, so this
is inert there (same value). */
.ic-DashboardCard__header_content {
${cardTransparency
? `background: none !important;`
: `background: var(--bcbackground-0) !important;`}
}
tr.student_assignment.assignment_graded.editable > * {
border:none!important
}`;
// TODO: liquid glass?
document.documentElement.appendChild(style);
}
function applyBetterSidebarLayoutFix() {
let style = document.querySelector("#canvasrefined-sidebar-layout-fix") || document.createElement("style");
style.id = "canvasrefined-sidebar-layout-fix";
style.textContent = `
#wrapper,
.ic-Layout-wrapper,
#main {
margin-left: 0 !important;
}
`;
document.documentElement.appendChild(style);
}
function clearBetterSidebarLayoutFix() {
let style = document.querySelector("#canvasrefined-sidebar-layout-fix");
if (style) style.remove();
}
let insertTimer;
function resetTimer() {
clearTimeout(insertTimer);
insertTimer = setTimeout(() => {
if (document.querySelectorAll(".ic-DashboardCard__link").length > 0) {
loadCardAssignments();
loadBetterTodo();
} else {
resetTimer();
}
}, 1);
}
function checkDashboardReady() {
const isDashboard = () => current_page == "/" || current_page == "" || /^\/courses\/(\d+)(?:\/|$)/.test(current_page);
const callback = (mutationList) => {
// Ignore attribute-only mutations; only structural (childList) changes matter here.
let hasChildList = false;
for (const mutation of mutationList) {
if (mutation.type === "childList") { hasChildList = true; break; }
}
if (!hasChildList) return;
if (isDashboard()) {
// Debounce: a single setup pass per burst of mutations.
if (dashboardReadyTimer) return;
dashboardReadyTimer = setTimeout(() => {
dashboardReadyTimer = null;
const c = document.querySelector("#DashboardCard_Container");
if (c) {
let cards = document.querySelectorAll(".ic-DashboardCard");
// Build a cheap signature of the current card set. The setup
// pass below mutates the cards' internals (assignment rows,
// grades, etc.) but never adds/removes the .ic-DashboardCard
// elements themselves, so the signature stays stable across
// our own mutations. It only changes when Canvas re-renders the
// dashboard (cards added/removed/reordered/replaced). Skipping
// when it's unchanged breaks the self-retriggering reflow loop.
let signature = cards.length + "";
for (let i = 0; i < cards.length; i++) {
const link = cards[i].querySelector(".ic-DashboardCard__link");
signature += "|" + (link ? link.getAttribute("href") : "");
}
// Canvas often re-renders the dashboard on a hard reload and
// replaces the .ic-DashboardCard nodes with fresh ones that have
// the same courses/links (so the signature is unchanged) but no
// longer carry our .canvasrefined-card-assignment marker. The
// signature guard alone would skip re-setup in that case, leaving
// card assignments empty until a popup toggle forces a reload.
// Re-run whenever any card is missing its marker too. This is safe
// from the self-retriggering reflow loop: after the pass every
// card has the marker, so our own subsequent mutation bursts skip.
let missingMarker = false;
for (let i = 0; i < cards.length; i++) {
if (!cards[i].querySelector(".canvasrefined-card-assignment")) {
missingMarker = true;
break;
}
}
if (signature !== lastDashboardCardSignature || missingMarker) {
lastDashboardCardSignature = signature;
changeGradientCards();
setupCardAssignments();
loadCardAssignments();
customizeCards(cards);
insertGrades();
loadDashboardNotes();
setupGPACalc();
showUpdateMsg();
createNasaInfoOverlay();
}
}
const rightSide = document.querySelector("#right-side");
if (rightSide && !rightSide.querySelector(".canvasrefined-todosidebar")) {
setupBetterTodo();
setupBetterSidebar(getSidebarLayoutMode());
}
if (options.better_sidebar) {
ensureBetterSidebar();
}
}, 0);
} else if (options.better_sidebar) {
// Throttle sidebar setup checks on non-dashboard (course) pages instead
// of calling ensureBetterSidebar() on every mutation burst.
if (sidebarReadyTimer) return;
sidebarReadyTimer = setTimeout(() => {
sidebarReadyTimer = null;
ensureBetterSidebar();
}, 100);
}
};
const observer = new MutationObserver(callback);
observer.observe(document.documentElement, { childList: true, subtree: true });
}
function recieveMessage(request, sender, sendResponse) {
switch (request.message) {
case ("getCards"):
if (options["card_method_dashboard"] === true) {
getCardsFromDashboard().then(() => sendResponse(true));
} else {
getCards().then(() => sendResponse(true));
}
return true; // keep the message channel open for async sendResponse
case ("setcolors"): changeColorPreset(request.options); sendResponse(true); break;
case ("getcolors"): sendResponse(getCardColors()); break;
case ("inspect"): sendResponse(inspectDarkMode(true)); break;
case ("fixdm"): sendResponse(runDarkModeFixer(true)); break;
case ("updateBackground"): applyCustomBackground(); sendResponse(true); break;
default: sendResponse(true);
}
}
function hexToRgb(hex) {
let match = (/#(.{2})(.{2})(.{2})/).exec(hex);
if (match) {
return { "r": parseInt(match[1], 16), "g": parseInt(match[2], 16), "b": parseInt(match[3], 16) };
}
}
function inspectDarkMode(withOutput = false) {
let output = "";
let bgcount = 0, textcount = 0, time = performance.now();
let bg0 = hexToRgb(options.dark_preset["background-0"]);
let bg1 = hexToRgb(options.dark_preset["background-1"]);
let txt = hexToRgb(options.dark_preset["text-0"]);
let bdr = hexToRgb(options.dark_preset["borders"]);
let lnk = hexToRgb(options.dark_preset["links"]);
document.querySelectorAll("*").forEach(el => {
let style = getComputedStyle(el);
let bgcolor = style.getPropertyValue("background").match(/rgb\((?\d*)\, ?(?\d*)\, ?(?\d*)\) none/);
let selector = "class=." + el.className + ",id=#" + el.id;
if (bgcolor) {
const r = parseInt(bgcolor.groups["r"]);
const g = parseInt(bgcolor.groups["g"]);
const b = parseInt(bgcolor.groups["b"]);
if (r > 245 && g > 245 && b > 245 && !(r === bg0.r && g === bg0.g && b === bg0.b) && !(r === lnk.r && g === lnk.g && b === lnk.b)) {
el.style.cssText = (";background:" + options.dark_preset["background-0"] + "!important;color" + options.dark_preset["text-0"] + "!important;") + el.style.cssText;
if (withOutput === true) output += selector + "{background: background-0, color: text-0}\n";
bgcount++;
} else if (r > 225 && r < 245 && g > 225 && g < 245 && b > 225 && b < 245 && !(r === bg1.r && g === bg1.g && b === bg1.b) && !(r === lnk.r && g === lnk.g && b === lnk.b)) {
el.style.cssText = (";background:" + options.dark_preset["background-1"] + "!important;color" + options.dark_preset["text-0"] + "!important;") + el.style.cssText;
if (withOutput === true) output += selector + "{background: background-1, color: text-0}";
bgcount++;
}
}
let bordercolor = style.getPropertyValue("border-color").match(/rgb\((?\d*)\, ?(?\d*)\, ?(?\d*)/);
if (bordercolor) {
const r = parseInt(bordercolor.groups["r"]);
const g = parseInt(bordercolor.groups["g"]);
const b = parseInt(bordercolor.groups["b"]);
if (r > 195 && g > 195 && b > 195 && !(r === bdr.r && g === bdr.g && b === bdr.b) && !(r === lnk.r && g === lnk.g && b === lnk.b)) {
el.style.cssText = "border-color:" + options.dark_preset["borders"] + "!important;" + el.style.cssText;
if (withOutput === true) output += selector + "{border: borders}";
}
}
let text = style.getPropertyValue("color").match(/rgb\((?\d*)\, ?(?\d*)\, ?(?\d*)/);
if (text) {
const r = parseInt(text.groups["r"]);
const g = parseInt(text.groups["g"]);
const b = parseInt(text.groups["b"]);
if (r <= 70 && g <= 70 && b <= 70 && !(r === txt.r && g === txt.g && b === txt.b)) {
el.style.cssText = "color:" + options.dark_preset["text-0"] + "!important;" + el.style.cssText;
if (withOutput === true) output += selector + "{text: text-0}";
textcount++;
}
}
});
console.log("done fixing dark mode - time:", performance.now() - time, "total backgrounds changed: ", bgcount, ", total colors changed: ", textcount);
return { "selectors": output === "" ? "no gaps determined" : output, "time": performance.now() - time };
}
function getCardColors() {
let cards = document.querySelectorAll(".ic-DashboardCard__header");
let colors = [];
cards.forEach(card => {
let rgbColor = card.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor;
colors.push({ "href": card.querySelector(".ic-DashboardCard__link").href, "color": rgbToHex(rgbColor) });
});
colors.sort((a, b) => a.href > b.href ? 1 : -1);
colors = colors.map(x => x.color);
return colors;
}
function getCardsFromDashboard() {
console.log("getting cards from dashboard")
const dashboard_cards = document.querySelectorAll(".ic-DashboardCard");
return new Promise(resolve => {
chrome.storage.sync.get(["custom_cards", "custom_cards_2", "custom_cards_3"], storage => {
let cards = storage["custom_cards"] || {};
let cards_2 = storage["custom_cards_2"] || {};
let cards_3 = storage["custom_cards_3"] || {};
let newCards = false;
let count = 0;
try {
dashboard_cards.forEach(card => {
const id = card.querySelector(".ic-DashboardCard__link").href.split("courses/")[1];
if (count >= (options["card_limit"] || 25)) return;
if (!cards[id]) {
newCards = true;
cards[id] = { "default": card.querySelector(".ic-DashboardCard__header-subtitle").textContent.substring(0, 20), "fullName": card.querySelector(".ic-DashboardCard__header-title")?.textContent?.trim() || "", "name": "", "code": "", "img": "", "hidden": false, "weight": "regular", "credits": 1, "eid": 100000 - count, "gr": null };
let links = [];
for (let i = 0; i < 4; i++) {
links.push({ "path": "default", "is_default": true });
}
cards_2[id] = { "links": links };
cards_3[id] = { "url": domain };
} else {
// backfill full name for cards created before this field existed
const full = card.querySelector(".ic-DashboardCard__header-title")?.textContent?.trim() || "";
if (full && cards[id].fullName !== full) {
cards[id].fullName = full;
newCards = true;
}
}
count++;
});
// there shouldn't be 0 cards
if (count === 0) return;
//delete cards that aren't on the dashboard anymore
Object.keys(cards).forEach(key => {
let found = false;
// ignore cards that are not for the current url
if (cards_3[key] && cards_3[key].url !== domain) {
found = true;
} else {
dashboard_cards.forEach(card => {
const id = card.querySelector(".ic-DashboardCard__link").href.split("courses/")[1];
if (parseInt(key) === parseInt(id)) found = true;
});
}
if (found === false) {
console.log("Deleting " + key);
cards[key] && delete cards[key];
cards_2[key] && delete cards_2[key];
cards_3[key] && delete cards_3[key];
newCards = true;
}
});
} catch (e) {
console.log("Error getting dashboard cards\n", e);
logError(e);
} finally {
if(newCards !== true) { resolve(); return; }
console.log(newCards ? "new cards found" : "");
chrome.storage.sync.set({ "custom_cards": cards, "custom_cards_2": cards_2, "custom_cards_3": cards_3 }, () => resolve());
}
});
});
}
async function getCards(api = null) {
let dashboard_cards = api ? api : await getData(`${domain}/api/v1/courses?${/*enrollment_state=active&*/""}per_page=100`);
await new Promise(resolve => {
chrome.storage.sync.get(["custom_cards", "custom_cards_2", "custom_cards_3"], storage => {
let cards = storage["custom_cards"] || {};
let cards_2 = storage["custom_cards_2"] || {};
let cards_3 = storage["custom_cards_3"] || {};
let newCards = false;
let count = 0;
// sort cards by enrollment id (i think the higher the id, the more recent it is)
if (options["card_method_date"] === true) {
dashboard_cards.sort((a, b) => (b?.created_at) > (a?.created_at) ? 1 : -1);
} else {
dashboard_cards.sort((a, b) => (b?.enrollment_term_id || 0) - (a?.enrollment_term_id || 0));
}
try {
dashboard_cards.forEach(card => {
if (!card.course_code || count >= (options["card_limit"] || 25)) return;
let id = card.id;
if (!cards || !cards[id]) {
newCards = true;
cards[id] = { "default": card.course_code.substring(0, 20), "fullName": card.name || card.course_code || "", "name": "", "code": "", "img": "", "hidden": false, "weight": "regular", "credits": 1, "eid": card.enrollment_term_id || 0, "gr": null };
} else if (cards && cards[id]) {
newCards = true;
cards[id].default = card.course_code.substring(0, 20);
cards[id].fullName = card.name || card.course_code || cards[id].fullName || "";
cards[id].eid = card.enrollment_term_id || 0;
if (!cards[id].code) cards[id].code = "";
}
if (!cards_2 || !cards_2[id]) {
newCards = true;
let links = [];
for (let i = 0; i < 4; i++) {
links.push({ "path": "default", "is_default": true });
}
cards_2[id] = { "links": links };
}
if (!cards_3 || !cards_3[id]) {
newCards = true;
cards_3[id] = { "url": domain };
}
count++;
});
//delete cards that aren't on the dashboard anymore
Object.keys(cards).forEach(key => {
let found = false;
// ignore cards that are not for the current url
if (cards_3[key] && cards_3[key].url !== domain) {
found = true;
} else {
dashboard_cards.forEach(card => {
if (parseInt(key) === card.id) found = true;
});
}
if (found === false) {
console.log("Deleting " + key + " from custom_cards...", cards[key]);
cards[key] && delete cards[key];
cards_2[key] && delete cards_2[key];
cards_3[key] && delete cards_3[key];
newCards = true;
}
});
} catch (e) {
console.log(e);
} finally {
chrome.storage.sync.set(newCards ? { "custom_cards": cards, "custom_cards_2": cards_2, "custom_cards_3": cards_3 } : {}, () => resolve());
}
});
});
}
/*
Better todo list
*/
function convertToDueDate(dueAt) {
final = "due ";
let date = new Date(dueAt);
final += date.toLocaleString("en-US", { month: "short", day: "numeric" });
final += " at " + date.toLocaleString("en-US", { hour: "numeric", minute: "numeric", hour12: !options.todo_hr24 });
return final;
}
function updateIndicator(element) {
const indicator = document.getElementById("better-todo-indicator");
indicator.style.width = `${element.offsetWidth*2}px`;
indicator.style.left = `${element.offsetLeft - (element.offsetWidth * .5)}px`;
const buttons = ["announcement", "assignments", "completed"];
buttons.forEach(button => {
const btn = document.getElementById(`better-todo-${button}`);
if (btn == element) {
btn.firstElementChild.style.opacity = "1";
// btn.style.filter = "none";
}
else {
btn.firstElementChild.style.opacity = ".5";
// btn.style.filter = "grayscale(100%)";
}
})
}
// better todo html
betterTodoFilter = "tasks";
// Timeframe filter for the upcoming Tasks tab. "all" = no limit; otherwise
// items are limited to those due on/before now+range (which also keeps
// overdue items). Only affects the Tasks (upcoming) tab; announcements and
// completed are unaffected because their dates are in the past.
let betterTodoTimeframe = "all";
const BETTER_TODO_TIMEFRAME_DAYS = {
"1week": 7,
"2week": 14,
"month": 30,
"2month": 60,
};
// null = show every class; a string courseId = only that class's tasks.
let betterTodoProgressFilter = null;
let domContainers = {};
// Better Todo timeframe filter, shared by the task list and the progress
// display so their counts always agree: keeps items due on/before now+range
// (overdue items are before now, so they are kept too). "all" keeps
// everything.
function applyTodoTimeframe(items) {
betterTodoTimeframe = (options.todo_timeframe && Object.prototype.hasOwnProperty.call(BETTER_TODO_TIMEFRAME_DAYS, options.todo_timeframe)) ? options.todo_timeframe : "all";
if (betterTodoTimeframe === "all") return items;
const cutoff = Date.now() + (BETTER_TODO_TIMEFRAME_DAYS[betterTodoTimeframe] * 24 * 60 * 60 * 1000);
return items.filter(item => new Date(item.plannable_date).getTime() <= cutoff);
}
// true when `courseId` is the dimmed-out class because another class is selected.
function progressFilterDim(courseId) {
return betterTodoProgressFilter != null && String(courseId) !== String(betterTodoProgressFilter);
}
// Make an element filter the todo list to one class on click. A no-op on
// course pages (where only one class is in scope anyway); toggles off when the
// active class is clicked again.
function attachProgressFilterClick(el, courseId) {
if (getCurrentCourseId() != null) { el.style.cursor = ''; el.onclick = null; return; }
el.style.cursor = 'pointer';
el.onclick = () => {
betterTodoProgressFilter = (String(betterTodoProgressFilter) === String(courseId)) ? null : String(courseId);
const loc = document.querySelector("#canvasrefined-todo-list");
if (loc) { clearTodoList(); createTodoSections(loc); }
};
}
function formatDateForInput(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function formatTimeForInput(date) {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
}
/* Progress display mode, stored in options.todo_progress_rings.
Modes: "none", "rings", "rainbow", "lines", "line".
Legacy booleans normalize: true/undefined -> rings, false -> none. */
function getProgressRingMode() {
const v = options.todo_progress_rings;
if (v === false) return "none";
if (v === true || v === undefined || v === null) return "rings";
const allowed = ["none", "rings", "rainbow", "lines", "line"];
if (allowed.includes(v)) return v;
// migrate unreleased string values from the prior experimental set
if (v === "per_course") return "rings";
if (v === "combined" || v === "segmented") return "line";
return "rings";
}
function progressRingsEnabled() {
return getProgressRingMode() !== "none";
}
function courseRingColor(courseId, idx) {
return options.custom_cards_3?.[String(courseId)]?.color
|| options.custom_cards_3?.[courseId]?.color
|| `hsl(${(idx * 60) % 360} 70% 50%)`;
}
function courseRingLabel(courseId) {
const card = options.custom_cards?.[String(courseId)] || options.custom_cards?.[courseId];
return card?.default || `Course ${courseId}`;
}
// Planner items for courses the user has hidden from their dashboard should
// not appear in the Better Todo list or its progress display. Personal
// tasks (planner notes with no course) are always kept.
function isCourseHidden(courseId) {
if (courseId === undefined || courseId === null) return false;
const cards = options.custom_cards || {};
const card = cards[String(courseId)] || cards[courseId];
return !!card && card.hidden === true;
}
function filterHiddenCourses(data) {
return data.filter(item => {
const cid = item.course_id || item.context_id || item?.plannable?.course_id;
return !isCourseHidden(cid);
});
}
// Build scoped data for the Better Todo list: drop hidden courses, then (on
// a course page) restrict to the current course.
function getTodoScopedData(data, courseId) {
const visible = filterHiddenCourses(data);
if (!courseId) return visible;
return visible.filter(item => {
const itemCourseId = parseInt(item.course_id || item.context_id || item?.plannable?.course_id);
return itemCourseId === courseId;
});
}
// Returns a Map of courseId (string) -> dashboard position index, read from
// the live dashboard card DOM order. Empty when not on the dashboard. Used
// to order the progress display the same way the user ordered their cards.
function getDashboardCourseOrder() {
const order = new Map();
document.querySelectorAll('.ic-DashboardCard').forEach((card, idx) => {
const id = getCardId(card);
if (id && id !== -1 && !order.has(String(id))) order.set(String(id), idx);
});
return order;
}
// Mode "rings": concentric rings, one per course, each filled by completion.
function renderProgressRingsMode(wrapper, shown, totalAll, completedAll, percent) {
const containerWidth = wrapper.clientWidth || 240;
const size = Math.min(280, Math.floor(containerWidth * 0.99));
const cx = size / 2;
const cy = size / 2;
const padding = 2;
const outerRadius = Math.floor((size / 2) - padding);
let svg = wrapper.querySelector('svg.canvasrefined-progress-svg');
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'canvasrefined-progress-svg');
svg.style.display = 'block';
wrapper.appendChild(svg);
}
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(size));
svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
let overlay = wrapper.querySelector('.canvasrefined-progress-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'canvasrefined-progress-overlay';
overlay.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center;pointer-events:none;';
const textWrap = document.createElement('div');
textWrap.style.cssText = 'text-align:center;color:var(--bctext-0);';
textWrap.innerHTML = ``;
overlay.appendChild(textWrap);
wrapper.appendChild(overlay);
}
overlay.querySelector('.canvasrefined-progress-percent').textContent = `${percent}%`;
overlay.querySelector('.canvasrefined-progress-count').textContent = `${completedAll}/${totalAll} done`;
const stroke = 8;
const gap = 4;
const decrement = stroke + gap;
const ringCount = shown.length;
const startRadius = outerRadius - stroke / 2;
const minCenterRadius = 28;
const requiredSpace = (ringCount - 1) * decrement + stroke / 2 + minCenterRadius;
let adjustFactor = 1;
if (requiredSpace > startRadius) {
adjustFactor = (startRadius - minCenterRadius - stroke / 2) / Math.max(1, (ringCount - 1) * decrement);
}
shown.forEach((entry, idx) => {
const radius = startRadius - idx * Math.max(1, Math.floor(decrement * adjustFactor));
if (radius <= 0) return;
const circumference = 2 * Math.PI * radius;
const prog = entry.total === 0 ? 0 : entry.completed / entry.total;
const color = courseRingColor(entry.courseId, idx);
let bg = svg.querySelector(`circle[data-idx='${idx}'][data-role='bg']`);
if (!bg) {
bg = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
bg.setAttribute('data-idx', String(idx));
bg.setAttribute('data-role', 'bg');
bg.classList.add('canvasrefined-ring-bg');
svg.appendChild(bg);
}
bg.setAttribute('cx', String(cx));
bg.setAttribute('cy', String(cy));
bg.setAttribute('r', String(radius));
bg.setAttribute('stroke', color);
bg.setAttribute('stroke-opacity', '0.25');
bg.setAttribute('stroke-width', String(stroke));
bg.setAttribute('fill', 'none');
bg.removeAttribute('stroke-dasharray');
bg.removeAttribute('stroke-dashoffset');
bg.removeAttribute('transform');
let fg = svg.querySelector(`circle[data-idx='${idx}'][data-role='fg']`);
const dasharrayVal = circumference.toFixed(3);
const target = (circumference * (1 - prog)).toFixed(3);
if (!fg) {
fg = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
fg.setAttribute('data-idx', String(idx));
fg.setAttribute('data-role', 'fg');
fg.classList.add('canvasrefined-progress-ring');
fg.setAttribute('stroke-linecap', 'round');
fg.setAttribute('transform', `rotate(-90 ${cx} ${cy})`);
fg.setAttribute('stroke-dasharray', dasharrayVal);
fg.setAttribute('stroke-dashoffset', dasharrayVal); // start empty
fg.style.transition = 'stroke-dashoffset .8s cubic-bezier(.2,.9,.2,1), opacity .3s ease';
svg.appendChild(fg);
}
fg.setAttribute('cx', String(cx));
fg.setAttribute('cy', String(cy));
fg.setAttribute('r', String(radius));
fg.setAttribute('stroke', color);
fg.setAttribute('stroke-width', String(stroke));
fg.setAttribute('fill', 'none');
fg.setAttribute('stroke-dasharray', dasharrayVal);
const dim = progressFilterDim(entry.courseId);
bg.style.transition = 'opacity .3s ease';
bg.style.opacity = dim ? '0.25' : '';
fg.style.opacity = dim ? '0.3' : '';
requestAnimationFrame(() => requestAnimationFrame(() => fg.setAttribute('stroke-dashoffset', target)));
// transparent hit band on top so the whole ring is easy to click;
// width tracks the ring spacing so adjacent bands don't overlap.
const step = Math.max(1, Math.floor(decrement * adjustFactor));
let hit = svg.querySelector(`circle[data-idx='${idx}'][data-role='hit']`);
if (!hit) {
hit = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
hit.setAttribute('data-idx', String(idx));
hit.setAttribute('data-role', 'hit');
hit.setAttribute('fill', 'none');
hit.setAttribute('stroke', 'transparent');
hit.setAttribute('pointer-events', 'stroke');
svg.appendChild(hit);
}
hit.setAttribute('cx', String(cx));
hit.setAttribute('cy', String(cy));
hit.setAttribute('r', String(radius));
hit.setAttribute('stroke-width', String(Math.max(stroke, step)));
attachProgressFilterClick(hit, entry.courseId);
hit.onmouseenter = () => { bg.style.opacity = '0.8'; fg.style.opacity = '0.8'; };
hit.onmouseleave = () => {
const d = progressFilterDim(entry.courseId);
bg.style.opacity = d ? '0.25' : '';
fg.style.opacity = d ? '0.3' : '';
};
});
const maxIdx = shown.length - 1;
svg.querySelectorAll('circle').forEach(c => {
const idx = parseInt(c.getAttribute('data-idx'));
if (Number.isNaN(idx) || idx > maxIdx) c.remove();
});
}
// Mode "rainbow": like "rings" (one arc per class) but condensed into a top
// half-circle and colored with a rainbow palette instead of course colors.
function renderProgressRainbow(wrapper, shown, totalAll, completedAll, percent) {
const containerWidth = wrapper.clientWidth || 240;
const size = Math.min(280, Math.floor(containerWidth * 0.99));
const cx = size / 2;
const stroke = 8;
const gap = 4;
const decrement = stroke + gap;
const ringCount = shown.length;
const pad = 2;
const outerRadius = Math.max(20, Math.floor(size / 2) - stroke / 2 - pad);
// diameter line; arcs bulge UPWARD from here (into y < baseY)
const baseY = outerRadius + stroke / 2 + pad;
const svgHeight = Math.ceil(baseY + stroke / 2 + 2);
let svg = wrapper.querySelector('svg.canvasrefined-progress-svg');
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'canvasrefined-progress-svg');
svg.style.display = 'block';
wrapper.appendChild(svg);
}
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(svgHeight));
svg.setAttribute('viewBox', `0 0 ${size} ${svgHeight}`);
// shrink spacing if too many classes would overflow the inner radius
const minInnerRadius = 14;
const requiredSpace = (ringCount - 1) * decrement;
let adjustFactor = 1;
if (requiredSpace > outerRadius - minInnerRadius) {
adjustFactor = (outerRadius - minInnerRadius) / Math.max(1, (ringCount - 1) * decrement);
}
shown.forEach((entry, idx) => {
const radius = outerRadius - idx * Math.max(1, Math.floor(decrement * adjustFactor));
if (radius <= 0) return;
// sweep-flag 1 => arc bulges UPWARD (top semicircle), drawn left -> right
const arcPath = `M ${cx - radius} ${baseY} A ${radius} ${radius} 0 0 1 ${cx + radius} ${baseY}`;
const prog = entry.total === 0 ? 0 : entry.completed / entry.total;
const color = courseRingColor(entry.courseId, idx);
// track: full semicircle, faded class color
let track = svg.querySelector(`path[data-idx='${idx}'][data-role='bg']`);
if (!track) {
track = document.createElementNS('http://www.w3.org/2000/svg', 'path');
track.setAttribute('data-idx', String(idx));
track.setAttribute('data-role', 'bg');
track.setAttribute('fill', 'none');
track.setAttribute('stroke-linecap', 'round');
svg.appendChild(track);
}
track.setAttribute('d', arcPath);
track.setAttribute('stroke', color);
track.setAttribute('stroke-opacity', '0.25');
track.setAttribute('stroke-width', String(stroke));
track.removeAttribute('stroke-dasharray');
track.removeAttribute('stroke-dashoffset');
// progress arc: completed portion drawn from the left, via pathLength=100
let progArc = svg.querySelector(`path[data-idx='${idx}'][data-role='fg']`);
const targetOff = (100 * (1 - prog)).toFixed(3);
if (!progArc) {
progArc = document.createElementNS('http://www.w3.org/2000/svg', 'path');
progArc.setAttribute('data-idx', String(idx));
progArc.setAttribute('data-role', 'fg');
progArc.setAttribute('fill', 'none');
progArc.setAttribute('stroke-linecap', 'round');
progArc.setAttribute('pathLength', '100');
progArc.setAttribute('stroke-dasharray', '100 100');
progArc.setAttribute('stroke-dashoffset', '100'); // start empty
progArc.style.transition = 'stroke-dashoffset .8s cubic-bezier(.2,.9,.2,1), opacity .3s ease';
svg.appendChild(progArc);
}
progArc.setAttribute('d', arcPath);
progArc.setAttribute('stroke', color);
progArc.setAttribute('stroke-width', String(stroke));
progArc.setAttribute('stroke-dasharray', '100 100');
const dim = progressFilterDim(entry.courseId);
track.style.transition = 'opacity .3s ease';
track.style.opacity = dim ? '0.25' : '';
progArc.style.opacity = dim ? '0.3' : '';
requestAnimationFrame(() => requestAnimationFrame(() => progArc.setAttribute('stroke-dashoffset', targetOff)));
// transparent hit arc on top so the whole arc is easy to click.
let hit = svg.querySelector(`path[data-idx='${idx}'][data-role='hit']`);
if (!hit) {
hit = document.createElementNS('http://www.w3.org/2000/svg', 'path');
hit.setAttribute('data-idx', String(idx));
hit.setAttribute('data-role', 'hit');
hit.setAttribute('fill', 'none');
hit.setAttribute('stroke', 'transparent');
hit.setAttribute('stroke-linecap', 'round');
hit.setAttribute('pointer-events', 'stroke');
svg.appendChild(hit);
}
hit.setAttribute('d', arcPath);
hit.setAttribute('stroke-width', String(stroke + 6));
attachProgressFilterClick(hit, entry.courseId);
hit.onmouseenter = () => { track.style.opacity = '0.8'; progArc.style.opacity = '0.8'; };
hit.onmouseleave = () => {
const d = progressFilterDim(entry.courseId);
track.style.opacity = d ? '0.25' : '';
progArc.style.opacity = d ? '0.3' : '';
};
});
// drop arcs for classes no longer shown
const maxIdx = shown.length - 1;
svg.querySelectorAll('path').forEach(p => {
const idx = parseInt(p.getAttribute('data-idx'));
if (Number.isNaN(idx) || idx > maxIdx) p.remove();
});
// Overlay the percent/count text INSIDE the rainbow's semicircle hole
// (the empty bowl bounded by the INNERMOST arc) instead of beneath it or
// up among the arcs. The SVG is centered in the wrapper, so an absolutely-
// positioned overlay covering the wrapper with flex centering aligns the
// text to the SVG's horizontal center. Vertically we target the centroid of
// the innermost semicircle (a touch above the diameter line) so the text
// sits in the arc-free pocket near the bottom rather than near the apexes
// of the inner arcs, where it would overlap the rainbow strokes.
const step = Math.max(1, Math.floor(decrement * adjustFactor));
const innerRadius = ringCount > 0 ? Math.max(1, outerRadius - (ringCount - 1) * step) : outerRadius;
const holeCenterY = baseY - (4 * innerRadius) / (3 * Math.PI) + 6;
const nudge = Math.round(holeCenterY - svgHeight / 2);
let overlay = wrapper.querySelector('.canvasrefined-progress-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'canvasrefined-progress-overlay';
overlay.style.cssText = `position:absolute;left:0;top:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;transform:translateY(${nudge}px);`;
overlay.innerHTML = ``;
wrapper.appendChild(overlay);
} else {
overlay.style.transform = `translateY(${nudge}px)`;
}
overlay.querySelector('.canvasrefined-progress-percent').textContent = `${percent}%`;
overlay.querySelector('.canvasrefined-progress-count').textContent = `${completedAll}/${totalAll} done`;
}
// Mode "lines": one horizontal bar per course, each with its own %.
function renderProgressLines(wrapper, shown) {
let list = wrapper.querySelector('.canvasrefined-progress-lines');
if (!list) {
list = document.createElement('div');
list.className = 'canvasrefined-progress-lines';
list.style.cssText = 'display:flex;flex-direction:column;gap:8px;width:100%;box-sizing:border-box;';
wrapper.appendChild(list);
}
while (list.children.length > shown.length) list.lastChild.remove();
shown.forEach((entry, idx) => {
const prog = entry.total === 0 ? 0 : entry.completed / entry.total;
const pct = Math.round(prog * 100);
const color = courseRingColor(entry.courseId, idx);
let row = list.children[idx];
if (!row) {
row = document.createElement('div');
row.className = 'canvasrefined-progress-line';
row.style.cssText = 'display:flex;flex-direction:column;gap:3px;width:100%;';
row.innerHTML = `
`;
list.appendChild(row);
}
const labelEl = row.querySelector('.cr-pl-label');
labelEl.textContent = courseRingLabel(entry.courseId);
labelEl.style.color = color;
row.querySelector('.cr-pl-pct').textContent = `${pct}% (${entry.completed}/${entry.total})`;
const track = row.children[1];
track.style.background = `color-mix(in srgb, ${color} 25%, transparent)`;
const fill = row.querySelector('.cr-pl-fill');
fill.style.background = color;
if (!fill.dataset.init) {
fill.dataset.init = '1';
requestAnimationFrame(() => requestAnimationFrame(() => fill.style.width = `${pct}%`));
} else {
fill.style.width = `${pct}%`;
}
row.style.transition = 'opacity .3s ease';
row.style.opacity = progressFilterDim(entry.courseId) ? '0.4' : '';
attachProgressFilterClick(row, entry.courseId);
row.onmouseenter = () => { row.style.opacity = '0.8'; };
row.onmouseleave = () => { row.style.opacity = progressFilterDim(entry.courseId) ? '0.4' : ''; };
});
}
// Mode "line": one horizontal bar where each class's COMPLETED portion is packed
// to the left (full course color) and its UNCOMPLETED portion to the right
// (faded course color), with no gaps between segments. Overall % shown above.
function renderProgressOneLine(wrapper, shown, totalAll, completedAll, percent) {
let box = wrapper.querySelector('.canvasrefined-progress-oneline');
if (!box) {
box = document.createElement('div');
box.className = 'canvasrefined-progress-oneline';
box.style.cssText = 'display:flex;flex-direction:column;gap:5px;width:100%;box-sizing:border-box;';
wrapper.appendChild(box);
}
let head = box.querySelector('.cr-ol-head');
if (!head) {
head = document.createElement('div');
head.className = 'cr-ol-head';
head.style.cssText = 'display:flex;justify-content:space-between;align-items:center;font-size:12px;color:var(--bctext-0);';
head.innerHTML = `Overall`;
box.appendChild(head);
}
box.querySelector('.cr-ol-pct').textContent = `${percent}% (${completedAll}/${totalAll})`;
// Clicking the "Overall" header clears the active class filter.
if (getCurrentCourseId() == null) {
head.style.cursor = betterTodoProgressFilter != null ? 'pointer' : '';
head.onclick = () => {
if (betterTodoProgressFilter == null) return;
betterTodoProgressFilter = null;
const loc = document.querySelector("#canvasrefined-todo-list");
if (loc) { clearTodoList(); createTodoSections(loc); }
};
} else {
head.style.cursor = '';
head.onclick = null;
}
let bar = box.querySelector('.cr-ol-bar');
if (!bar) {
bar = document.createElement('div');
bar.className = 'cr-ol-bar';
// position:relative so absolutely-positioned segments anchor to it
bar.style.cssText = 'position:relative;width:100%;height:14px;border-radius:999px;overflow:hidden;background:var(--bcbackground-1);';
box.appendChild(bar);
}
// Build the segment list left -> right: ALL completed blocks first (each
// class's full course color), then ALL remaining blocks (each class's faded
// course color). This keeps every completed segment on the left and every
// remaining segment on the right. Consecutive segments share edges, so
// there are no gaps between them.
const segs = [];
let left = 0;
// First pass: completed blocks for every class (left side).
shown.forEach((entry, idx) => {
const color = courseRingColor(entry.courseId, idx);
const doneW = totalAll === 0 ? 0 : (entry.completed / totalAll) * 100;
if (doneW > 0) { segs.push({ left, w: doneW, bg: color, courseId: entry.courseId }); left += doneW; } // completed: full color
});
// Second pass: remaining blocks for every class (right side).
shown.forEach((entry, idx) => {
const color = courseRingColor(entry.courseId, idx);
const remW = totalAll === 0 ? 0 : ((entry.total - entry.completed) / totalAll) * 100;
if (remW > 0) { segs.push({ left, w: remW, bg: `color-mix(in srgb, ${color} 25%, transparent)`, courseId: entry.courseId }); left += remW; } // remaining: faded
});
while (bar.children.length > segs.length) bar.lastChild.remove();
segs.forEach((seg, idx) => {
let el = bar.children[idx];
if (!el) {
el = document.createElement('div');
el.className = 'cr-ol-seg';
el.style.cssText = 'position:absolute;top:0;height:100%;transition:left .6s ease,width .6s ease,background .3s ease,opacity .3s ease;';
bar.appendChild(el);
}
el.style.left = `${seg.left}%`;
el.style.width = `${seg.w}%`;
el.style.background = seg.bg;
el.style.opacity = progressFilterDim(seg.courseId) ? '0.35' : '';
attachProgressFilterClick(el, seg.courseId);
el.onmouseenter = () => { el.style.opacity = '0.8'; };
el.onmouseleave = () => { el.style.opacity = progressFilterDim(seg.courseId) ? '0.35' : ''; };
});
}
function renderProgressRings(container, scopedData) {
const mode = getProgressRingMode();
if (mode === "none") { container.innerHTML = ""; return; }
// Apply the same timeframe filter the list uses so the counts in the
// display match what's shown below it.
const allAssignments = applyTodoTimeframe(scopedData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note")));
const groups = {};
allAssignments.forEach(item => {
const cid = String(item.course_id || item.context_id || item.plannable?.course_id || "personal");
groups[cid] = groups[cid] || [];
groups[cid].push(item);
});
const entries = Object.keys(groups).map(cid => {
const arr = groups[cid];
const completed = arr.filter(it => (it.submissions?.submitted || it.planner_override?.marked_complete)).length;
return { courseId: cid, total: arr.length, completed };
}).filter(e => e.total > 0);
if (!entries.length) { container.innerHTML = ""; return; }
// Order courses to match the user's dashboard card order. Courses that
// aren't on the dashboard (personal tasks, dropped courses) sort after
// dashboard courses, keeping their relative order; ties fall back to
// most assignments first so the display stays stable.
const dashboardOrder = getDashboardCourseOrder();
entries.sort((a, b) => {
const ai = dashboardOrder.has(a.courseId) ? dashboardOrder.get(a.courseId) : Infinity;
const bi = dashboardOrder.has(b.courseId) ? dashboardOrder.get(b.courseId) : Infinity;
if (ai !== bi) return ai - bi;
return b.total - a.total;
});
const shown = entries.slice(0, 6);
const totalAll = shown.reduce((s, e) => s + e.total, 0);
const completedAll = shown.reduce((s, e) => s + e.completed, 0);
const percent = totalAll === 0 ? 0 : Math.round((completedAll / totalAll) * 100);
// wrapper reused across renders; clear on mode switch so each mode rebuilds fresh DOM
let wrapper = container.querySelector('.canvasrefined-progress-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'canvasrefined-progress-wrapper';
wrapper.style.cssText = 'display:flex;flex-direction:column;align-items:center;position:relative;width:100%;box-sizing:border-box;';
container.appendChild(wrapper);
}
if (wrapper.dataset.mode !== mode) {
wrapper.innerHTML = '';
wrapper.dataset.mode = mode;
}
if (mode === "rings") {
renderProgressRingsMode(wrapper, shown, totalAll, completedAll, percent);
} else if (mode === "rainbow") {
renderProgressRainbow(wrapper, shown, totalAll, completedAll, percent);
} else if (mode === "lines") {
renderProgressLines(wrapper, shown);
} else if (mode === "line") {
renderProgressOneLine(wrapper, shown, totalAll, completedAll, percent);
}
}
function buildPlannerNotePayload(form) {
const title = form.querySelector("#better-todo-new-task-title")?.value?.trim();
const details = form.querySelector("#better-todo-new-task-details")?.value?.trim();
const courseIdRaw = form.querySelector("#better-todo-new-task-course")?.value;
const dateValue = form.querySelector("#better-todo-new-task-date")?.value;
const timeValue = form.querySelector("#better-todo-new-task-time")?.value;
const link = form.querySelector("#better-todo-new-task-link")?.value?.trim() || "";
if (!title) {
throw new Error("Task title is required.");
}
if (!dateValue || !timeValue) {
throw new Error("Please choose both a date and time.");
}
const localDateTime = new Date(`${dateValue}T${timeValue}:00`);
if (Number.isNaN(localDateTime.getTime())) {
throw new Error("Invalid task date.");
}
return {
title,
details,
link,
courseId: courseIdRaw ? parseInt(courseIdRaw) : null,
// Canvas accepts local timestamp strings more reliably than UTC ISO strings for planner notes.
todoDate: `${dateValue}T${timeValue}:00`,
};
}
async function createCanvasPlannerNote(payload) {
const csrfToken = CSRFtoken();
const plannerNote = {
title: payload.title,
todo_date: payload.todoDate,
};
if (payload.details) plannerNote.details = payload.details;
if (payload.courseId) plannerNote.course_id = payload.courseId;
const attempts = [
{
headers: {
"content-type": "application/json",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify({ planner_note: plannerNote }),
},
{
headers: {
"content-type": "application/json",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify(plannerNote),
},
{
headers: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: (() => {
const formBody = new URLSearchParams();
formBody.set("planner_note[title]", plannerNote.title);
formBody.set("planner_note[todo_date]", plannerNote.todo_date);
if (plannerNote.details) formBody.set("planner_note[details]", plannerNote.details);
if (plannerNote.course_id) formBody.set("planner_note[course_id]", plannerNote.course_id);
return formBody.toString();
})(),
},
];
let lastError = "Canvas rejected task creation.";
for (const attempt of attempts) {
const response = await fetch(domain + "/api/v1/planner_notes", {
method: "POST",
headers: attempt.headers,
body: attempt.body,
});
if (response.status === 200 || response.status === 201) {
return response.json();
}
try {
const errData = await response.json();
if (errData?.errors?.length) {
lastError = errData.errors.join(" ");
} else if (errData?.message) {
lastError = errData.message;
}
} catch (_) {
// Keep prior error text when body is not JSON.
}
}
throw new Error(lastError || "Canvas rejected task creation.");
}
/* Custom task links: Canvas planner notes have no link field, so store a
user link in sync storage keyed by the note id. */
function getCustomTaskLinks() {
return (options && options.custom_task_links) || {};
}
function getCustomTaskLinkId(item) {
return item?.plannable_id ?? item?.plannable?.id ?? null;
}
function normalizeTaskLink(link) {
if (!link) return "";
link = String(link).trim();
if (!link) return "";
if (/^https?:\/\//i.test(link)) return link;
if (link.startsWith("//")) return "https:" + link;
return domain + (link.startsWith("/") ? link : "/" + link);
}
function customTaskHref(item) {
const id = getCustomTaskLinkId(item);
const links = getCustomTaskLinks();
if (id != null && links[String(id)]) {
return normalizeTaskLink(links[String(id)]);
}
const courseId = item?.course_id || item?.plannable?.course_id || item?.context_id;
if (courseId) return `${domain}/courses/${courseId}`;
return `${domain}/`;
}
function saveCustomTaskLink(noteId, link) {
if (noteId == null) return;
const links = { ...getCustomTaskLinks() };
const key = String(noteId);
if (link && String(link).trim()) {
links[key] = String(link).trim();
} else {
delete links[key];
}
options = { ...options, custom_task_links: links };
chrome.storage.sync.set({ custom_task_links: links });
}
function deleteCustomTaskLink(noteId) {
if (noteId == null) return;
const links = { ...getCustomTaskLinks() };
delete links[String(noteId)];
options = { ...options, custom_task_links: links };
chrome.storage.sync.set({ custom_task_links: links });
}
async function updateCanvasPlannerNote(id, payload) {
if (!id) throw new Error("Missing task id.");
const csrfToken = CSRFtoken();
// Canvas's planner_notes update endpoint permits FLAT parameters
// (title, details, course_id, todo_date) — NOT nested under planner_note.
// Sending nested params is silently ignored, so note.update({}) runs and
// returns 200 with the unchanged note, making edits appear to "not save".
// Always include details (even empty) so the field can be cleared.
const plannerNote = {
title: payload.title,
todo_date: payload.todoDate,
details: payload.details || "",
// Sending course_id as empty string disassociates the note from its course.
course_id: payload.courseId || "",
};
const attempts = [
{
headers: {
"content-type": "application/json",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify(plannerNote),
},
{
headers: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: (() => {
const formBody = new URLSearchParams();
formBody.set("title", plannerNote.title);
formBody.set("todo_date", plannerNote.todo_date);
formBody.set("details", plannerNote.details);
formBody.set("course_id", plannerNote.course_id);
return formBody.toString();
})(),
},
];
let lastError = "Canvas rejected task update.";
for (const attempt of attempts) {
const response = await fetch(`${domain}/api/v1/planner_notes/${id}`, {
method: "PUT",
headers: attempt.headers,
body: attempt.body,
});
if (response.status === 200 || response.status === 201) {
return response.json();
}
try {
const errData = await response.json();
if (errData?.errors?.length) {
lastError = errData.errors.join(" ");
} else if (errData?.message) {
lastError = errData.message;
}
} catch (_) {
// Keep prior error text when body is not JSON.
}
}
throw new Error(lastError || "Canvas rejected task update.");
}
async function deleteCanvasPlannerNote(id) {
if (!id) throw new Error("Missing task id.");
const csrfToken = CSRFtoken();
const response = await fetch(`${domain}/api/v1/planner_notes/${id}`, {
method: "DELETE",
headers: {
"content-type": "application/json",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
});
if (response.status === 200 || response.status === 201 || response.status === 204) {
return true;
}
let lastError = "Canvas rejected task deletion.";
try {
const errData = await response.json();
if (errData?.errors?.length) lastError = errData.errors.join(" ");
else if (errData?.message) lastError = errData.message;
} catch (_) { /* ignore */ }
throw new Error(lastError);
}
/* Scroll a task form field into view, leaving room below for the native date/time picker. */
function scrollTodoIntoView(el, smooth = true) {
if (!el) return;
const sidebar = document.getElementById("right-side-wrapper");
const style = sidebar ? getComputedStyle(sidebar) : null;
const scrollsSidebar = sidebar &&
(style.overflowY === "auto" || style.overflowY === "scroll") &&
sidebar.scrollHeight > sidebar.clientHeight;
const behavior = smooth ? "smooth" : "auto";
if (scrollsSidebar) {
const rect = el.getBoundingClientRect();
const sRect = sidebar.getBoundingClientRect();
const elTop = rect.top - sRect.top + sidebar.scrollTop;
// Keep the element near the top so the picker below it stays on screen.
const target = elTop - (sidebar.clientHeight - rect.height) * 0.3;
sidebar.scrollTo({ top: Math.max(0, target), behavior });
} else {
el.scrollIntoView({ block: "center", behavior });
}
}
function fillTaskCourseOptions(courseSelect) {
const cards = options.custom_cards || {};
const courseColors = options.custom_cards_3 || {};
const currentCourseId = getCurrentCourseId();
// Hidden courses should not be offered as a target for custom tasks.
const entries = Object.entries(cards)
.filter(([, card]) => card?.hidden !== true)
.map(([id, card]) => ({
id,
label: card?.default || `Course ${id}`,
color:
courseColors?.[String(id)]?.color ??
courseColors?.[id]?.color ??
"#c7cdd1",
}))
.sort((a, b) => a.label.localeCompare(b.label));
courseSelect.innerHTML = '';
courseSelect.options[0].dataset.color = "#c7cdd1";
entries.forEach(entry => {
const option = makeElement("option", courseSelect, {
value: entry.id,
textContent: entry.label,
});
option.dataset.color = entry.color;
option.style.color = entry.color;
if (currentCourseId && String(currentCourseId) === String(entry.id)) {
option.selected = true;
}
});
}
function updateTaskCourseSelectColor(courseSelect) {
const selectedOption = courseSelect?.options?.[courseSelect.selectedIndex];
const color = selectedOption?.dataset?.color || "#c7cdd1";
courseSelect.style.borderLeft = `4px solid ${color}`;
courseSelect.style.paddingLeft = "8px";
}
function ensureTodoTaskMenu(location, feedbackElement) {
let actionsRow = location.querySelector("#better-todo-actions-row");
if (!actionsRow) {
actionsRow = makeElement("div", location, {
id: "better-todo-actions-row",
style: "display:flex;flex-direction:column;gap:8px;margin-top:14px;",
});
const addTaskButton = makeElement("button", actionsRow, {
id: "better-todo-add-task-btn",
className: "canvasrefined-custom-btn",
textContent: "+ Add Task",
style: "width:100%;padding:6px 8px;cursor:pointer;",
});
const menu = makeElement("div", actionsRow, {
id: "better-todo-add-task-menu",
className: "canvasrefined-add-assignment",
});
menu.innerHTML = `
`;
const today = new Date();
menu.querySelector("#better-todo-new-task-date").value = formatDateForInput(today);
menu.querySelector("#better-todo-new-task-time").value = formatTimeForInput(today);
const courseSelect = menu.querySelector("#better-todo-new-task-course");
fillTaskCourseOptions(courseSelect);
updateTaskCourseSelectColor(courseSelect);
courseSelect.addEventListener("change", () => updateTaskCourseSelectColor(courseSelect));
addTaskButton.addEventListener("click", () => {
const willOpen = !menu.classList.contains("canvasrefined-custom-open");
menu.classList.toggle("canvasrefined-custom-open");
if (willOpen) {
resetTaskFormToCreate(menu);
// Scroll the form up so the picker stays on screen.
scrollTodoIntoView(menu, true);
}
});
const submitTask = async () => {
const status = menu.querySelector("#better-todo-add-task-status");
const submitButton = menu.querySelector("#better-todo-add-task-submit");
const deleteButton = menu.querySelector("#better-todo-add-task-delete");
status.textContent = "";
submitButton.disabled = true;
if (deleteButton) deleteButton.disabled = true;
try {
const payload = buildPlannerNotePayload(menu);
const editingId = menu.dataset.editingId || null;
if (editingId) {
await updateCanvasPlannerNote(editingId, payload);
saveCustomTaskLink(editingId, payload.link);
status.textContent = "Task updated.";
} else {
const created = await createCanvasPlannerNote(payload);
const newId = created?.id;
if (newId != null) saveCustomTaskLink(newId, payload.link);
status.textContent = "Task created.";
}
status.style.color = "#198754";
resetTaskFormToCreate(menu);
menu.classList.remove("canvasrefined-custom-open");
getAssignments();
clearTodoList();
createTodoSections(location);
} catch (e) {
status.textContent = e?.message || "Could not save task.";
status.style.color = "#db3754";
} finally {
submitButton.disabled = false;
if (deleteButton) deleteButton.disabled = false;
}
};
menu.querySelector("#better-todo-add-task-submit").addEventListener("click", submitTask);
// Close (×) button: dismiss the form without creating/editing a task.
menu.querySelector("#better-todo-add-task-close")?.addEventListener("click", () => {
resetTaskFormToCreate(menu);
menu.classList.remove("canvasrefined-custom-open");
});
// Reposition the field on focus so the picker opens on screen.
["#better-todo-new-task-date", "#better-todo-new-task-time"].forEach((sel) => {
const input = menu.querySelector(sel);
input?.addEventListener("focus", () => scrollTodoIntoView(input, false));
});
menu.querySelector("#better-todo-add-task-delete").addEventListener("click", async () => {
const editingId = menu.dataset.editingId || null;
if (!editingId) return;
if (!confirm("Delete this custom task? This cannot be undone.")) return;
const status = menu.querySelector("#better-todo-add-task-status");
const submitButton = menu.querySelector("#better-todo-add-task-submit");
const deleteButton = menu.querySelector("#better-todo-add-task-delete");
status.textContent = "";
submitButton.disabled = true;
deleteButton.disabled = true;
try {
await deleteCanvasPlannerNote(editingId);
deleteCustomTaskLink(editingId);
resetTaskFormToCreate(menu);
menu.classList.remove("canvasrefined-custom-open");
getAssignments();
clearTodoList();
createTodoSections(location);
} catch (e) {
status.textContent = e?.message || "Could not delete task.";
status.style.color = "#db3754";
} finally {
submitButton.disabled = false;
deleteButton.disabled = false;
}
});
}
if (feedbackElement) {
if (actionsRow.nextSibling !== feedbackElement) {
location.insertBefore(actionsRow, feedbackElement);
}
} else if (actionsRow.parentElement !== location) {
location.append(actionsRow);
}
}
// Reset the shared add/edit task form back to a blank "create" state.
function resetTaskFormToCreate(menu) {
if (!menu) return;
menu.querySelector("#better-todo-new-task-title").value = "";
menu.querySelector("#better-todo-new-task-details").value = "";
menu.querySelector("#better-todo-new-task-link").value = "";
const courseSelect = menu.querySelector("#better-todo-new-task-course");
if (courseSelect) {
fillTaskCourseOptions(courseSelect);
updateTaskCourseSelectColor(courseSelect);
}
const now = new Date();
menu.querySelector("#better-todo-new-task-date").value = formatDateForInput(now);
menu.querySelector("#better-todo-new-task-time").value = formatTimeForInput(now);
const status = menu.querySelector("#better-todo-add-task-status");
if (status) { status.textContent = ""; status.style.color = ""; }
const del = menu.querySelector("#better-todo-add-task-delete");
if (del) del.style.display = "none";
const submit = menu.querySelector("#better-todo-add-task-submit");
if (submit) submit.textContent = "Create";
delete menu.dataset.editingId;
}
// Open the shared form pre-filled with a custom task for editing or deletion.
function openTaskForEdit(item) {
const location = document.getElementById("canvasrefined-todo-list");
if (!location) return;
const feedbackElement = location.querySelector(".recent_feedback");
ensureTodoTaskMenu(location, feedbackElement);
const menu = document.getElementById("better-todo-add-task-menu");
if (!menu) return;
const noteId = getCustomTaskLinkId(item);
menu.querySelector("#better-todo-new-task-title").value = item?.plannable?.title || "";
menu.querySelector("#better-todo-new-task-details").value = item?.plannable?.details || "";
const courseSelect = menu.querySelector("#better-todo-new-task-course");
const cid = item?.course_id || item?.plannable?.course_id || "";
if (courseSelect) courseSelect.value = cid ? String(cid) : "";
updateTaskCourseSelectColor(courseSelect);
const dateObj = new Date(item?.plannable_date || item?.plannable?.todo_date || Date.now());
if (!Number.isNaN(dateObj.getTime())) {
menu.querySelector("#better-todo-new-task-date").value = formatDateForInput(dateObj);
menu.querySelector("#better-todo-new-task-time").value = formatTimeForInput(dateObj);
}
const links = getCustomTaskLinks();
menu.querySelector("#better-todo-new-task-link").value = (noteId != null && links[String(noteId)]) || "";
menu.dataset.editingId = noteId != null ? String(noteId) : "";
const del = menu.querySelector("#better-todo-add-task-delete");
if (del) del.style.display = "";
menu.querySelector("#better-todo-add-task-submit").textContent = "Save";
const status = menu.querySelector("#better-todo-add-task-status");
if (status) { status.textContent = ""; status.style.color = ""; }
menu.classList.add("canvasrefined-custom-open");
scrollTodoIntoView(menu, true);
}
async function createTodoSections(location) {
if (!location.querySelector("#better-todo-header")) {
let header = makeElement("div", location, { id: "better-todo-header" });
header.style = "display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--bcbackground-1);padding-bottom:-2px;";
let today = new Date();
today.setHours(0,0,0,0);
const todayString = today.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" });
header.innerHTML = `
Tasks
${todayString}
`;
// placeholder for progress rings above the tab/filter control
makeElement("div", location, { id: "better-todo-progress-placeholder", style: "display:flex;justify-content:center;margin-top:8px;" });
let filterControl = makeElement("div", location, { "id": "better-todo-filter" });
filterControl.innerHTML = `
`;
setTimeout(() => updateIndicator(document.getElementById("better-todo-assignments")), 10);
document.getElementById("better-todo-announcement").addEventListener("click", (e) => {
betterTodoFilter = "announcements";
moreAnnouncementCount = 0;
updateIndicator(e.currentTarget);
clearTodoList();
createTodoSections(location);
});
document.getElementById("better-todo-assignments").addEventListener("click", (e) => {
betterTodoFilter = "tasks";
moreAssignmentCount = 0;
updateIndicator(e.currentTarget);
clearTodoList();
createTodoSections(location);
});
document.getElementById("better-todo-completed").addEventListener("click", (e) => {
betterTodoFilter = "completed";
moreCompletedCount = 0;
updateIndicator(e.currentTarget);
clearTodoList();
createTodoSections(location);
});
let mainSection = makeElement("div", location, {
id: "better-todo-main",
});
mainSection.style = "display:flex;flex-direction:column;";
}
let mainSection = location.querySelector("#better-todo-main");
assignments.then(data => {
const courseId = getCurrentCourseId();
const scopedData = getTodoScopedData(data, courseId);
// Clicking a color in the progress display filters the list to that
// one class. The filter only makes sense where multiple classes show
// (dashboard/profile), so it is cleared on course pages.
if (courseId) betterTodoProgressFilter = null;
const displayData = (betterTodoProgressFilter == null)
? scopedData
: scopedData.filter(item => {
const cid = String(item.course_id || item.context_id || item.plannable?.course_id || "personal");
return cid === String(betterTodoProgressFilter);
});
announcements = displayData.filter(item => item.plannable_type == "announcement");
assignmentsDue = displayData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note") && !item.submissions?.submitted && !item.planner_override?.marked_complete);
completed = displayData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note") && (item.submissions?.submitted || item.planner_override?.marked_complete));
// The timeframe is a persisted Better Todo List sub-option set in the
// popup. Read the current value each render so popup changes apply on
// the next render. Only the Tasks tab is affected (announcements and
// the completed tab always show everything).
assignmentsDue = applyTodoTimeframe(assignmentsDue);
// console.log("assignments", assignmentsDue);
// console.log("announcements", announcements);
// console.log("completed", completed);
if (document.getElementById("better-todo-announcement-badge")) {
document.getElementById("better-todo-announcement-badge").remove();
}
let isAnnoucementBadge = 0;
announcements.forEach(item => {
if (item.plannable.read_state == "unread") {
isAnnoucementBadge++;
return;
}
})
if (isAnnoucementBadge > 0) {
makeElement("div", document.getElementById("better-todo-announcement"), {
id: "better-todo-announcement-badge",
style: "background-color:#ff0000;width:15px;height:15px;border-radius:50%;font-size:12px;position:absolute;top:-7px;left:16px;display:flex;justify-content:center;align-items:center;", // TODO: theme compatibility
innerHTML: `${isAnnoucementBadge}`
})
}
domContainers = {};
const groupKeys = ["-1", "0", "1", "2", "3", "4", "5", "6", "7", "14", "21", "30", "Later", "New", "Seen", "Ungraded", "Graded"];
for (const key of groupKeys) {
let wrapper = makeElement("div", mainSection, {
style: "display:none;margin-top:10px;",
className: "better-todo-dueheader",
});
let label = "";
if (key == "Later") label = "Due Later";
if (key == "-1") label = "Overdue";
else if (key == "0") label = "Due Today";
else if (key == "1") label = "Due Tomorrow";
else if (key >= 2 && key < 7) label = "Due " + key + " days";
else if (key >= 7 && key < 30) label = "Due " + key/7 + " weeks";
else if (key == "30") label = "Due 1 month";
else label = "" + key + "";
makeElement("div", wrapper, {
innerHTML: "" + label + "",
style: "display:flex;flex-direction:column;gap:10px;font-size:12px;color:var(--bctext-0);"
})
let listContainer = makeElement("div", wrapper, { className: "todo-group-list" });
listContainer.style = "display:flex;flex-direction:column;gap:10px;";
domContainers[key] = { wrapper, listContainer };
}
if (betterTodoFilter == "tasks") {
populateAssignments();
}
if (betterTodoFilter == "announcements") {
populateAnnouncements();
}
if (betterTodoFilter == "completed") {
populateAssignments(true);
}
const feedbackElement = location.querySelector(".recent_feedback");
// populate progress rings placeholder (respect user toggle)
const progressPlaceholder = document.getElementById("better-todo-progress-placeholder");
if (progressPlaceholder) {
if (progressRingsEnabled()) {
renderProgressRings(progressPlaceholder, scopedData);
} else {
progressPlaceholder.innerHTML = "";
}
}
// Only show the Add Task control on the Assignments (tasks) tab.
if (betterTodoFilter === "tasks") {
ensureTodoTaskMenu(location, feedbackElement);
} else {
const existing = location.querySelector("#better-todo-actions-row");
if (existing) existing.remove();
}
if (feedbackElement) {
if (options.todo_hide_feedback == true) {
feedbackElement.style.display = "none";
} else {
feedbackElement.style.display = "block";
}
}
const sidebar = document.getElementById("right-side-wrapper");
ensureRightSideWrapperScrollbarHidden();
sidebar.style.setProperty("scrollbar-width", "none");
sidebar.style.setProperty("-ms-overflow-style", "none");
if (options.todo_full_height) {
sidebar.style.minHeight = "100vh";
} else {
sidebar.style.minHeight = "";
}
if (options.todo_separate_scrollbar) {
sidebar.style.position = "sticky";
sidebar.style.top = "0";
sidebar.style.height = "100vh";
sidebar.style.overflowY = "auto";
} else {
sidebar.style.position = "";
sidebar.style.top = "";
sidebar.style.height = "";
sidebar.style.overflowY = "";
// maybe invisible scrollbar?
}
});
}
function ensureRightSideWrapperScrollbarHidden() {
let style = document.getElementById("canvasrefined-hide-right-sidebar-scrollbar") || document.createElement("style");
style.id = "canvasrefined-hide-right-sidebar-scrollbar";
style.textContent = `
#right-side-wrapper {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
#right-side-wrapper::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
}
`;
document.head.append(style);
}
function clearTodoList() {
const seeMoreBtn = document.getElementById("better-todo-see-more");
if (seeMoreBtn) {
seeMoreBtn.remove();
}
document.getElementById("better-todo-main").querySelectorAll(".todo-group-list").forEach(list => {
list.innerHTML = "";
});
document.querySelectorAll(".better-todo-dueheader").forEach(header => {
header.remove();
});
}
// "Alternate colors" (Better Todo List, light mode only): recolors the main
// todo-list icon fill to white instead of the default --bctext-0, so icons
// stay visible on lighter course-color strips. Implemented through a CSS
// variable so toggling the option or dark mode recolors existing icons live
// without a re-render.
const TODO_ALT_ICON_COLOR = "#ffffff";
let todoAltStyleEl = null;
function applyTodoAlternateColors() {
const altOn = options.todo_alternate_colors === true && options.dark_mode !== true;
const color = altOn ? TODO_ALT_ICON_COLOR : "var(--bctext-0)";
if (!todoAltStyleEl) {
todoAltStyleEl = document.createElement("style");
todoAltStyleEl.id = "crtodoaltcss";
document.documentElement.append(todoAltStyleEl);
}
todoAltStyleEl.textContent = `:root{--cr-todo-icon:${color};}`;
}
// --- Hover preview (Better Todo List: "Previews on hover") ---
// The todo list is rendered by createTodoSections -> populateAssignments /
// populateAnnouncements (the old loadBetterTodo renderer is no longer called).
// A single shared, body-level tooltip is reused across items so it is never
// clipped by the sidebar's scroll/overflow containers. It reuses the
// .canvasrefined-hover-preview class so existing light/dark styling applies.
let todoPreviewDelay = null;
let todoPreviewToken = 0;
const todoPreviewCache = new Map();
function stripHtmlPreview(html) {
if (!html) return "";
return String(html).replace(/<\/?[^>]+(>|$)/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim();
}
function getTodoPreviewEl() {
let el = document.getElementById("canvasrefined-todo-preview");
if (el) return el;
el = document.createElement("div");
el.id = "canvasrefined-todo-preview";
el.className = "canvasrefined-hover-preview";
el.innerHTML = '';
el.style.position = "fixed";
el.style.zIndex = "100001";
el.style.width = "300px";
el.style.maxWidth = "90vw";
el.style.maxHeight = "260px";
document.body.append(el);
return el;
}
function positionTodoPreview(el, anchor) {
const r = anchor.getBoundingClientRect();
const gap = 10;
const pw = el.offsetWidth || 300;
const ph = el.offsetHeight || 160;
let left = r.left - pw - gap;
let top = r.top;
if (left < gap) {
left = r.right + gap;
if (left + pw > window.innerWidth - gap) left = Math.max(gap, window.innerWidth - pw - gap);
}
if (top + ph > window.innerHeight - gap) top = Math.max(gap, window.innerHeight - ph - gap);
el.style.left = left + "px";
el.style.top = top + "px";
}
async function getTodoPreviewText(item) {
const type = item.plannable_type;
const id = item.plannable_id;
const key = type + ":" + id;
if (todoPreviewCache.has(key)) return todoPreviewCache.get(key);
// Custom task (planner note): the description is already on the planner
// item as item.plannable.details, so no API call is needed.
if (type === "planner_note" || (item.planner_override && item.planner_override.custom === true)) {
const raw = item.plannable && item.plannable.details ? item.plannable.details : "";
const text = stripHtmlPreview(raw) || "No details given";
todoPreviewCache.set(key, text);
return text;
}
let url = null;
let field = "description";
if (type === "assignment") {
url = `${domain}/api/v1/courses/${item.course_id}/assignments/${id}`;
} else if (type === "quiz") {
url = `${domain}/api/v1/courses/${item.course_id}/quizzes/${id}`;
} else if (type === "discussion_topic" || type === "announcement") {
url = `${domain}/api/v1/courses/${item.course_id}/discussion_topics/${id}`;
field = "message";
}
if (!url) {
const text = "No preview available";
todoPreviewCache.set(key, text);
return text;
}
try {
const data = await getData(url);
const raw = data && data[field] ? data[field] : "";
const text = stripHtmlPreview(raw) || "No details given";
todoPreviewCache.set(key, text);
return text;
} catch (e) {
return "Couldn't load preview";
}
}
function hideTodoPreview() {
todoPreviewToken++; // cancel any pending async text update
const el = document.getElementById("canvasrefined-todo-preview");
if (el) el.style.display = "none";
}
async function showTodoPreview(anchor, item) {
const token = ++todoPreviewToken;
const el = getTodoPreviewEl();
const title = el.querySelector(".canvasrefined-preview-title");
const text = el.querySelector(".canvasrefined-preview-text");
title.textContent = item.plannable && item.plannable.title ? item.plannable.title : "";
text.textContent = "Loading…";
el.style.display = "block";
positionTodoPreview(el, anchor);
const content = await getTodoPreviewText(item);
if (token !== todoPreviewToken) return; // a newer hover (or hide) superseded this one
if (el.style.display !== "block") return; // user already moved away
text.textContent = content;
positionTodoPreview(el, anchor); // reposition now that the height is known
}
function attachTodoHoverPreview(anchor, item) {
if (options.hover_preview !== true) return;
anchor.addEventListener("mouseenter", () => {
clearTimeout(todoPreviewDelay);
todoPreviewDelay = setTimeout(() => {
if (anchor.matches(":hover")) showTodoPreview(anchor, item);
}, 250);
});
anchor.addEventListener("mouseleave", () => {
clearTimeout(todoPreviewDelay);
hideTodoPreview();
});
}
function populateAssignments(iscompleted = false) {
const today = new Date();
today.setHours(0,0,0,0);
let assignments = (iscompleted ? completed : assignmentsDue).slice();
if (iscompleted) {
assignments.sort((a, b) => {
const aIsGraded = Boolean(a.submissions?.graded);
const bIsGraded = Boolean(b.submissions?.graded);
if (aIsGraded !== bIsGraded) {
return aIsGraded - bIsGraded;
}
return new Date(b.plannable_date) - new Date(a.plannable_date);
});
}
let assignmentCount = 0;
const maxElements = options.num_todo_items;
assignments.forEach((item) => {
let dueGroup = -1;
if (!iscompleted) {
let dueDate = new Date(item.plannable_date);
dueDate.setHours(0,0,0,0);
const diffDays = Math.round((dueDate - today) / (1000 * 60 * 60 * 24));
if (diffDays < 0) {dueGroup = -1;}
else if (diffDays <= 1) { dueGroup = diffDays.toString(); }
else if (diffDays <= 7) { dueGroup = diffDays.toString(); }
else if (diffDays <= 14) {dueGroup = 14;}
else if (diffDays <= 21) {dueGroup = 21;}
else if (diffDays <= 30) {dueGroup = 30;}
else {dueGroup = "Later"};
} else {
dueGroup = item.submissions?.graded ? "Graded" : "Ungraded";
}
let assignment
const targetContainer = domContainers[dueGroup];
assignmentCount++;
let isHidden = assignmentCount > maxElements;
if (targetContainer) {
if (!isHidden) {
targetContainer.wrapper.style.display = "block";
targetContainer.wrapper.setAttribute("data-has-visible", "true");
}
else {
if (!targetContainer.wrapper.hasAttribute("data-has-visible")) {
targetContainer.wrapper.classList.add(
"better-todo-hidden-wrapper",
);
}
}
// targetContainer.wrapper.style.display = "block";
assignment = makeElement("div", targetContainer.listContainer, {
class: "better-todo-assignment",
});
if (isHidden) {
assignment.style.display = "none";
assignment.classList.add("better-todo-hidden-assignment");
}
}
const courseColor =
options.custom_cards_3?.[String(item.course_id)]?.color ??
options.custom_cards_3?.[item.course_id]?.color ??
options.custom_cards_3?.[item.plannable.course_id]?.color ??
"#cccccc";
// "Ignore card colors" (Better Todo List): when on, the class name is
// rendered black in light mode or the theme text color in dark mode
// instead of the course's card color.
const classNameColor = options.todo_ignore_card_colors
? (options.dark_mode === true ? "var(--bctext-0)" : "#000000")
: courseColor;
// "Remove icons" (Better Todo List): when on, the task-type icon is
// omitted from the colored strip on the left of each task.
const removeIcons = options.todo_remove_icons === true;
const isCustomTask = item.plannable_type == "planner_note" || item.planner_override?.custom === true;
const taskHref = isCustomTask ? customTaskHref(item) : (domain + item.html_url);
const editButtonSvg = isCustomTask
? ``
: "";
const iconSize = isCustomTask ? 26 : 20;
const iconLeftOffset = isCustomTask ? 2 : 5;
const taskIcon = removeIcons ? "" : isCustomTask
? ``
: ``;
assignment.style.overflowX = "hidden";
assignment.innerHTML = `
`;
attachTodoHoverPreview(announcement, item);
});
}
function createConfettiBurst(targetElement, opts = {}) {
try {
if (options.todo_confetti === false) return;
const count = opts.count || 48;
const colors = opts.colors || ['#ff4d4f', '#ffc107', '#28a745', '#17a2b8', '#6f42c1', '#ff6b6b', '#ff8a65', '#ffd54f'];
const rect = targetElement.getBoundingClientRect();
const container = document.createElement('div');
container.className = 'canvasrefined-confetti-container';
container.style.position = 'fixed';
container.style.left = '0';
container.style.top = '0';
container.style.pointerEvents = 'none';
container.style.overflow = 'visible';
container.style.zIndex = '2147483647';
document.body.appendChild(container);
const originX = rect.left + rect.width / 2;
const originY = rect.top + rect.height * 0.35;
const particles = [];
for (let i = 0; i < count; i++) {
const el = document.createElement('div');
el.className = 'canvasrefined-confetti';
const w = 4 + Math.floor(Math.random() * 7); // smaller pieces
const h = Math.max(3, Math.floor(w * (0.4 + Math.random() * 0.8)));
el.style.position = 'absolute';
el.style.width = w + 'px';
el.style.height = h + 'px';
el.style.background = colors[Math.floor(Math.random() * colors.length)];
el.style.left = (originX - w / 2) + 'px';
el.style.top = (originY - h / 2) + 'px';
el.style.opacity = '1';
el.style.borderRadius = Math.random() > 0.75 ? '50%' : '2px';
el.style.boxShadow = '0 2px 6px rgba(0,0,0,0.18)';
el.style.transformOrigin = 'center center';
el.style.willChange = 'transform, opacity';
container.appendChild(el);
const duration = 850 + Math.floor(Math.random() * 500);
const delay = Math.floor(Math.random() * 90);
const spread = opts.spread || 110;
const horizontalBias = (Math.random() - 0.5) * 2;
// Arc stays lower and wider than the old cone-shaped burst.
const endX = originX + horizontalBias * (spread * (0.7 + Math.random() * 0.6));
const endY = originY - (16 + Math.random() * 34);
const ctrlX = originX + horizontalBias * (spread * 0.25) + (Math.random() - 0.5) * 14;
const ctrlY = originY - (30 + Math.random() * 55);
particles.push({
el,
delay,
duration,
originX,
originY,
ctrlX,
ctrlY,
endX,
endY,
rotate: (Math.random() * 260) - 130,
scale: 0.8 + Math.random() * 0.5,
});
}
const startTime = performance.now();
let rafId = null;
const animate = now => {
let active = false;
for (let i = particles.length - 1; i >= 0; i--) {
const particle = particles[i];
const elapsed = now - startTime - particle.delay;
if (elapsed < 0) {
active = true;
continue;
}
const progress = Math.min(1, elapsed / particle.duration);
const eased = 1 - Math.pow(1 - progress, 3);
const x = (1 - eased) * (1 - eased) * particle.originX + 2 * (1 - eased) * eased * particle.ctrlX + eased * eased * particle.endX;
const y = (1 - eased) * (1 - eased) * particle.originY + 2 * (1 - eased) * eased * particle.ctrlY + eased * eased * particle.endY;
particle.el.style.transform = `translate(${Math.round(x - particle.originX)}px, ${Math.round(y - particle.originY)}px) rotate(${particle.rotate * eased}deg) scale(${particle.scale * (1 - eased * 0.15)})`;
particle.el.style.opacity = String(1 - progress);
if (progress < 1) {
active = true;
} else {
particle.el.remove();
particles.splice(i, 1);
}
}
if (active) {
rafId = requestAnimationFrame(animate);
} else {
try { container.remove(); } catch (e) { /* ignore */ }
if (rafId) cancelAnimationFrame(rafId);
}
};
rafId = requestAnimationFrame(animate);
// cleanup container after animations
setTimeout(() => {
try { container.remove(); } catch (e) { /* ignore */ }
}, 2400);
} catch (e) {
console.error('confetti error', e);
}
}
function markAs(item, element) {
const csrfToken = CSRFtoken();
const completeState = item.planner_override ? !item.planner_override.marked_complete : true;
// --- Optimistic UI ---
// Canvas's /planner/overrides endpoint occasionally returns 400 (Bad
// Request) for both custom tasks and normal tasks, even though the action
// is actually applied server-side shortly after. When that happens the
// item would neither visually mark nor animate, and would later appear
// "secretly complete" on reload. To avoid that confusing UX, we update the
// UI as if the request succeeded immediately, and fire the actual API
// call in the background for persistence.
item.planner_override = item.planner_override || {};
item.planner_override.marked_complete = completeState;
element.style.transform = "translate(100%)";
element.style.opacity = "0";
// fire confetti only when marking complete (not when unmarking)
if (completeState) {
try { createConfettiBurst(element); } catch (e) { console.error('confetti trigger error', e); }
}
// update progress rings immediately so they animate while the item slides/fades
const progressPlaceholder = document.getElementById("better-todo-progress-placeholder");
if (progressPlaceholder && typeof assignments?.then === 'function' && progressRingsEnabled()) {
assignments.then(data => {
const courseId = getCurrentCourseId();
const scopedData = getTodoScopedData(data.map(d => Object.assign({}, d)), courseId);
// reflect the updated state for this item in the snapshot
for (let i = 0; i < scopedData.length; i++) {
if (scopedData[i].plannable_id === item.plannable_id && scopedData[i].plannable_type === item.plannable_type) {
scopedData[i].planner_override = scopedData[i].planner_override || {};
scopedData[i].planner_override.marked_complete = item.planner_override.marked_complete;
break;
}
}
renderProgressRings(progressPlaceholder, scopedData);
});
}
setTimeout(() => {
clearTodoList();
createTodoSections(document.querySelector("#canvasrefined-todo-list"));
}, 400);
// --- Persistence (background, best-effort) ---
// A 400/non-OK response is treated as a soft success because Canvas often
// completes the override server-side anyway; we already reflected the
// change in the UI, so just log it.
fetch(domain + "/api/v1/planner/overrides" + (item.planner_override && item.planner_override.id ? "/" + item.planner_override.id : ""), {
method: item.planner_override && item.planner_override.id ? "PUT" : "POST",
headers: {
"content-type":"application/json",
"accept":"application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({
id: item.planner_override && item.planner_override.id ? item.planner_override.id : null,
marked_complete: completeState,
plannable_id: item.plannable_id,
plannable_type: item.plannable_type
})
})
.then(resp => {
if (resp.ok) {
console.log("marked as complete");
} else {
// Non-OK (e.g. 400) is logged but not surfaced: the UI already
// reflects the intended state, and Canvas typically applies the
// override server-side regardless.
console.warn("planner override request returned", resp.status, "— UI already updated optimistically");
}
})
.catch(err => console.error("error marking as complete", err));
}
function createTodoViewMore(location, type) {
let viewMoreButton = makeElement("button", location, { "className": "canvasrefined-custom-btn canvasrefined-viewmore-btn", "textContent": "View More" });
//viewMoreButton.classList.add("canvasrefined-viewmore-btn");
const showMoreCount = 3;
viewMoreButton.addEventListener("click", function (e) {
if (type === "announcement") {
moreAnnouncementCount += showMoreCount;
} else {
moreAssignmentCount += showMoreCount;
}
loadBetterTodo();
});
}
// better todo init
function setupBetterTodo() {
// Better Todo list is removed from quizzes (it interferes with the quiz page).
if (isQuizPage()) return;
if (options.better_todo !== true || isGradesPage()) return;
if (document.querySelector('#canvasrefined-todo-list')) return;
let list = document.querySelector("#right-side");
if (!list) return;
//if (!list || list.childElementCount === 0 || list.children[0].id === "canvasrefined-todo-list") return;
try {
/* save the feedback to append it later */
const feedback = list.querySelector(".events_list.recent_feedback");
list.textContent = "";
list = makeElement("div", list, { "className": "canvasrefined-todosidebar","id": "canvasrefined-todo-list"});
createTodoSections(list);
if (feedback) list.append(feedback);
} catch (e) {
logError(e);
}
}
function getSidebarScale() {
const rawScale = parseInt(options.sidebar_scale || 100);
if (isNaN(rawScale)) return 1;
return Math.max(0.7, Math.min(1.5, rawScale / 100));
}
function applySidebarScaleStyles(sidebarList) {
const scale = getSidebarScale();
sidebarList.style.setProperty("--bc-sidebar-icon-size", `${Math.round(20 * scale)}px`);
sidebarList.style.setProperty("--bc-sidebar-btn-height", `${Math.round(30 * scale)}px`);
sidebarList.style.setProperty("--bc-sidebar-btn-gap", `${Math.round(8 * scale)}px`);
sidebarList.style.setProperty("--bc-sidebar-label-size", `${Math.round(14 * scale)}px`);
}
// Re-apply the tinted course-content panel when the background opacity slider
// changes. Only the better sidebar (course mode) gives .ic-Layout-contentMain a
// tinted panel in the first place (see setupBetterSidebar). Re-applies both the
// opacity and blur sliders so changing either updates the panel live.
function applyBetterSidebarContentPanel() {
if (!options.better_sidebar) return;
if (getSidebarLayoutMode() !== "course") return;
const contentMain = document.querySelector(".ic-Layout-contentMain");
if (!contentMain) return;
const bgOpacity = Math.max(0, Math.min(100, Number(options.bg_opacity ?? 65)));
const bgBlur = Math.max(0, Math.min(30, Number(options.bg_blur ?? 8)));
contentMain.style.setProperty("background", `color-mix(in srgb, var(--bcbackground-0) ${bgOpacity}%, transparent)`, "important");
contentMain.style.setProperty("backdrop-filter", `blur(${bgBlur}px)`, "important");
contentMain.style.setProperty("-webkit-backdrop-filter", `blur(${bgBlur}px)`, "important");
}
async function setupBetterSidebar(mode = getSidebarLayoutMode()) {
if (!options.better_sidebar) return;
if (document.querySelector('#better-sidebar-container')) return;
let wrapper = document.querySelector("#wrapper");
if (!wrapper || betterSidebarLoading) return;
betterSidebarLoading = true;
try {
const layoutMode = mode === "course" || mode === "dash" ? mode : getSidebarLayoutMode();
let expanded = await getSidebarExpandedState(layoutMode);
const outerWrapper = document.getElementById("main");
outerWrapper?.style.setProperty("display", "flex", "important");
// document.getElementById("not_right_side").style.setProperty("display", "none", "important");
const leftSide = document.getElementById("left-side");
leftSide?.style.setProperty("opacity", "1");
leftSide?.style.setProperty("position", "static");
const mainWrapper = document.querySelector(".ic-Layout-contentWrapper");
if (!mainWrapper) return;
applyBetterSidebarLayoutFix();
mainWrapper.style.display = "flex";
mainWrapper.style.alignItems = "stretch";
mainWrapper.style.minWidth = "0";
const contentMain = document.querySelector(".ic-Layout-contentMain");
contentMain?.style.setProperty("flex", "1 1 auto");
contentMain?.style.setProperty("min-width", "0");
const notRightSide = document.getElementById("not_right_side");
if (notRightSide && isAccountsPage()) {
notRightSide.style.setProperty("width", "100%");
notRightSide.style.setProperty("max-width", "100%");
notRightSide.style.setProperty("min-width", "0");
}
if (layoutMode === "course" && leftSide) {
const rightSideWrapper = document.getElementById("right-side-wrapper");
const sectionTabs = document.getElementById("section-tabs");
leftSide.style.setProperty("padding-top", "0", "important");
leftSide.style.setProperty("padding-left", "0", "important");
if (sectionTabs) {
if (getCurrentCourseId() !== null || isProfilePage()) {
sectionTabs.style.setProperty("padding-top", "40px", "important");
} else {
sectionTabs.style.removeProperty("padding-top");
}
}
leftSide.style.flex = "0 0 250px";
leftSide.style.width = "250px";
leftSide.style.maxWidth = "250px";
if (notRightSide) {
notRightSide.style.display = "flex";
notRightSide.style.flex = "1 1 auto";
notRightSide.style.minWidth = "0";
}
if (rightSideWrapper) {
rightSideWrapper.style.flex = "0 0 280px";
rightSideWrapper.style.width = "280px";
rightSideWrapper.style.maxWidth = "280px";
}
contentMain?.style.setProperty("margin", "26px 38px 38px", "important");
contentMain?.style.setProperty("padding", "10px", "important");
contentMain?.style.setProperty("border-radius", "10px", "important");
contentMain?.style.setProperty("background", `color-mix(in srgb, var(--bcbackground-0) ${Math.max(0, Math.min(100, Number(options.bg_opacity ?? 65)))}%, transparent)`, "important");
contentMain?.style.setProperty("backdrop-filter", `blur(${Math.max(0, Math.min(30, Number(options.bg_blur ?? 8)))}px)`, "important");
contentMain?.style.setProperty("-webkit-backdrop-filter", `blur(${Math.max(0, Math.min(30, Number(options.bg_blur ?? 8)))}px)`, "important");
}
// The rail must always render leftmost. Course-layout pages already
// prepend it into #left-side; dash-layout pages that still have a
// native left nav (accounts, groups, etc.) must too — otherwise the
// native #left-side column (made position:static above) flows before
// #not_right_side and shows up to the LEFT of the Better Sidebar,
// looking like a competing sidebar once the custom background tints
// it. Prepending keeps the order: [Better Sidebar rail][native nav].
const sidebarParent = leftSide ? leftSide : mainWrapper;
if (leftSide) {
leftSide.style.display = "flex";
leftSide.style.flexDirection = "row";
leftSide.style.alignItems = "stretch";
leftSide.style.minWidth = "0";
leftSide.style.gap = "0";
}
document.querySelector(".ic-app-nav-toggle-and-crumbs")?.style.setProperty("display", "none");
if (layoutMode == "dash") {
document.getElementById("header")?.style.setProperty("display", "none");
}
else if (layoutMode == "course") {
document.getElementById("header")?.style.setProperty("display", "none");
}
let sidebarList = makeElement("div", sidebarParent, { id: "better-sidebar-container",
style: `display:flex;flex-direction:column;width:50px;justify-content:center;align-items:center;box-sizing:border-box;position:relative;background-color:var(--bcsidebar);height:100vh;position:sticky;top:0;left:0;`
}, true);
let sidebarContent = makeElement("div", sidebarList, {
style: "display:flex;flex-direction:column;gap:20px;width:100%;flex:1;justify-content:flex-start;align-items:center;margin:40px;"
});
applySidebarScaleStyles(sidebarList);
let expander = makeElement("div", sidebarList, {
className: "better-sidebar-expander",
style: "display:flex;flex-direction:column;gap:0px;margin-top:auto;width:100%;justify-content:center;align-items:center;cursor:pointer;",
});
expander.innerHTML = `
`
sidebarList.dataset.expanded = expanded ? "true" : "false";
updateSidebar(expanded, sidebarList, expander);
setSidebarExpandedState(layoutMode, expanded);
requestAnimationFrame(() => {
populateSidebarFromNav(sidebarContent);
updateSidebar(expanded, sidebarList, expander);
watchSidebarBadges();
});
expander.addEventListener("click", () => {
expanded = !expanded;
sidebarList.dataset.expanded = expanded ? "true" : "false";
setSidebarExpandedState(layoutMode, expanded);
updateSidebar(expanded, sidebarList, expander);
})
} catch (e) {
logError(e);
} finally {
betterSidebarLoading = false;
}
}
function createSidebarButton(text, url, parent, icon) {
let button = makeElement("a", parent, {
style: "width:40%;height:var(--bc-sidebar-btn-height,30px);cursor:pointer;text-align:center;text-decoration:none;display:inline-flex;justify-content:center;align-items:center;gap:var(--bc-sidebar-btn-gap,8px);color:var(--bcsidebar-text) !important;font-weight:bold;position:relative;",
className: "canvasrefined-custom-btn better-sidebar-btn",
href: url,
});
button.innerHTML = `${icon ? `${icon}${text}` : `${text}`}`;
return button;
}
function getNavBadgeCount(item) {
const badge = item.querySelector(".menu-item__badge");
if (!badge) return 0;
const badgeText = badge.querySelector('[aria-hidden="true"]')?.textContent?.trim() || badge.textContent?.trim() || "";
const count = parseInt(badgeText, 10);
return Number.isFinite(count) && count > 0 ? count : 0;
}
function addSidebarButtonBadge(button, count) {
if (!button) return;
// Always clear any existing badge first so a drop to 0 unread removes it.
button.querySelector(".better-sidebar-badge")?.remove();
if (!count) return;
makeElement("div", button, {
className: "better-sidebar-badge",
style: "position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background-color:#ff0000;color:white;font-size:11px;line-height:16px;display:flex;justify-content:center;align-items:center;box-sizing:border-box;pointer-events:none;",
textContent: String(count),
});
}
// Re-read the global nav badge for each better-sidebar button and update its dot.
// Canvas loads the unread counts (Inbox/announcements) asynchronously after the
// page renders, so the dot captured at build time is often missing or stale.
function syncSidebarBadges() {
document.querySelectorAll(".better-sidebar-btn").forEach(button => {
const navId = button.dataset.navItemId;
if (!navId) return;
const navItem = document.getElementById(navId);
if (!navItem) return;
addSidebarButtonBadge(button, getNavBadgeCount(navItem));
});
}
function scheduleSidebarBadgeSync() {
if (sidebarBadgeSyncTimer) clearTimeout(sidebarBadgeSyncTimer);
sidebarBadgeSyncTimer = setTimeout(() => {
sidebarBadgeSyncTimer = null;
syncSidebarBadges();
}, 100);
}
// Watch the global nav for badge changes (late load, new mail, read/unread)
// and keep the better-sidebar dots in sync.
function watchSidebarBadges() {
const navMenu = document.getElementById("menu");
if (!navMenu) {
if (sidebarBadgeWatchRetries++ < 20) setTimeout(watchSidebarBadges, 500);
return;
}
sidebarBadgeWatchRetries = 0;
if (sidebarBadgeObserver) sidebarBadgeObserver.disconnect();
sidebarBadgeObserver = new MutationObserver(scheduleSidebarBadgeSync);
sidebarBadgeObserver.observe(navMenu, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style"] });
scheduleSidebarBadgeSync();
}
function populateSidebarFromNav(sidebarContent) {
const excludeIds = ["global_nav_help_link", "global_nav_history_link"];
const customIcons = {
"global_nav_profile_link": ``,
"global_nav_dashboard_link": ``,
"global_nav_conversations_link": ``,
"global_nav_calendar_link": ``,
"global_nav_courses_link": ``,
"global_nav_groups_link": ``,
"globalNavExternalTool-69": ``,
};
const navMenu = document.getElementById("menu");
let hasDashboardButton = false;
if (navMenu) {
const menuItems = navMenu.querySelectorAll("a[id^='global_nav'], .globalNavExternalTool a");
menuItems.forEach(item => {
const itemId = item.id;
if (excludeIds.includes(itemId)) return;
const href = item.getAttribute("href");
let textEl = item.querySelector(".menu-item__text");
let text = textEl?.textContent?.trim();
// If text not found, try other sources
if (!text) {
text = item.getAttribute("aria-label")?.trim() ||
item.getAttribute("title")?.trim() ||
item.textContent?.trim();
}
if (!text || !href) return;
let icon = customIcons[itemId] || "";
if (!icon) {
const svg = item.querySelector("svg");
if (svg) {
icon = svg.outerHTML;
// Detect and scale down large viewBox SVGs
const viewBoxMatch = icon.match(/viewBox="([^"]+)"/);
if (viewBoxMatch) {
const [, viewBox] = viewBoxMatch;
const parts = viewBox.split(/\s+/);
const width = parseFloat(parts[2]);
const height = parseFloat(parts[3]);
// If viewBox is large, add fixed size to scale it down
if (width > 32 || height > 32) {
// Check if svg already has a style attribute
if (icon.includes('style="')) {
// Append to existing style
icon = icon.replace(/style="([^"]*)"/, `style="$1 width:20px;height:20px;flex-shrink:0;fill:var(--bcsidebar-text);stroke:var(--bcsidebar-text);"`);
} else {
// Add new style attribute
icon = icon.replace("/g, ">").replace(/"/g, """).replace(/'/g, "'");
const sanitizeUrl = (u) => {
const v = String(u == null ? "" : u).trim();
if (!v) return "";
if (/^(https?:|mailto:|ftp:|tel:)/i.test(v)) return v;
if (/^(javascript:|vbscript:|file:|data:)/i.test(v)) return "#";
if (/^[#/?]/.test(v)) return v;
if (/^[a-z][a-z0-9+.-]*:/i.test(v)) return "#";
return v;
};
let text = String(src).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const out = [];
const lines = text.split("\n");
let i = 0;
let taskSeq = 0; // ordinal of rendered task items, stable across code-block stashing
const inline = (t) => {
let h = escapeHtml(t);
h = h.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (m, alt, url, title) =>
``);
h = h.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (m, txt, url, title) =>
`${txt}`);
h = h.replace(/`([^`\n]+)`/g, (m, c) => `${c}`);
h = h.replace(/\*\*([^*]+?)\*\*/g, "$1");
h = h.replace(/~~([^~]+?)~~/g, "$1");
h = h.replace(/(^|[^*])\*([^*]+?)\*(?!\*)/g, "$1$2");
return h;
};
while (i < lines.length) {
const line = lines[i];
if (/^\s*$/.test(line)) { i++; continue; }
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) { const l = h[1].length; out.push(`${inline(h[2])}`); i++; continue; }
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { out.push(""); i++; continue; }
if (/^>\s?/.test(line)) {
const q = []; while (i < lines.length && /^>\s?/.test(lines[i])) { q.push(inline(lines[i].replace(/^>\s?/, ""))); i++; }
out.push(`
${q.join(" ")}
`); continue;
}
if (/^\s*[-*+]\s+/.test(line)) {
const items = []; while (i < lines.length) { const m = lines[i].match(/^\s*[-*+]\s+(.*)$/); if (!m) break; const tk = m[1].match(/^\[([ xX])\]\s+(.*)$/); if (tk) { items.push(`
${inline(tk[2])}
`); } else { items.push(`
${inline(m[1])}
`); } i++; } out.push(`
${items.join("")}
`); continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items = []; while (i < lines.length) { const m = lines[i].match(/^\s*\d+\.\s+(.*)$/); if (!m) break; items.push(`
${inline(m[1])}
`); i++; } out.push(`${items.join("")}`); continue;
}
const para = [line]; i++; while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,6}\s|\s*[-*+]\s|\s*\d+\.\s|>)/.test(lines[i])) { para.push(lines[i]); i++; }
out.push(`
${para.map(inline).join(" ")}
`);
}
return out.join("\n");
}
function renderDashboardNotesPreview(preview, text) {
if (!preview) return;
// Skip identical re-renders to avoid a self-sustaining observer loop.
if (preview._crLastText === text) return;
preview._crLastText = text;
const renderer = (typeof window.renderMarkdown === "function") ? window.renderMarkdown : crRenderMarkdownFallback;
preview.innerHTML = renderer(text);
}
/* Insert/wrap Markdown formatting at the selection and fire an input event. */
function notesApplyFormat(editor, action) {
if (!editor) return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const value = editor.value;
const fire = () => {
editor.dispatchEvent(new Event("input", { bubbles: true }));
editor.focus();
};
const wrap = (before, after, placeholder) => {
const had = end > start;
const sel = had ? value.slice(start, end) : (placeholder || "");
editor.setRangeText(before + sel + after, start, end, "end");
editor.selectionStart = start + before.length;
editor.selectionEnd = start + before.length + sel.length;
fire();
};
// Range covering every line touched by the selection.
const lineBlock = () => {
const ls = start === 0 ? 0 : value.lastIndexOf("\n", start - 1) + 1;
let le = value.indexOf("\n", end);
if (le === -1) le = value.length;
return { ls, le, block: value.slice(ls, le) };
};
const togglePrefix = (prefix) => {
const { ls, le, block } = lineBlock();
const lines = block.split("\n");
const allHave = lines.every((l) => l.startsWith(prefix));
const newBlock = lines.map((l) => allHave ? l.slice(prefix.length) : prefix + l).join("\n");
editor.setRangeText(newBlock, ls, le, "end");
editor.selectionStart = ls;
editor.selectionEnd = ls + newBlock.length;
fire();
};
switch (action) {
case "bold": wrap("**", "**", "bold"); break;
case "italic": wrap("*", "*", "italic"); break;
case "strike": wrap("~~", "~~", "strikethrough"); break;
case "code": wrap("`", "`", "code"); break;
case "h1": togglePrefix("# "); break;
case "h2": togglePrefix("## "); break;
case "list": togglePrefix("- "); break;
case "numbered": togglePrefix("1. "); break;
case "quote": togglePrefix("> "); break;
case "task": {
const { ls, le, block } = lineBlock();
const marker = block.match(/^-\s*\[([ xX])\]\s+/);
const bullet = block.match(/^[-*+]\s+/);
let newBlock;
if (marker) {
newBlock = block.slice(marker[0].length);
} else if (bullet) {
newBlock = "- [ ] " + block.slice(bullet[0].length);
} else {
newBlock = "- [ ] " + block;
}
editor.setRangeText(newBlock, ls, le, "end");
editor.selectionStart = ls;
editor.selectionEnd = ls + newBlock.length;
fire();
break;
}
case "link": {
const had = end > start;
const sel = had ? value.slice(start, end) : "text";
editor.setRangeText("[" + sel + "](url)", start, end, "end");
const urlStart = start + 1 + sel.length + 2; // after "]("
editor.selectionStart = urlStart;
editor.selectionEnd = urlStart + 3; // select "url"
fire();
break;
}
case "hr": {
const lead = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
editor.setRangeText(lead + "---\n", start, start, "end");
fire();
break;
}
case "codeblock": {
const had = end > start;
const sel = had ? value.slice(start, end) : "code";
const lead = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
editor.setRangeText(lead + "```\n" + sel + "\n```", start, end, "end");
fire();
break;
}
}
}
/* Toggle the Nth rendered Markdown task checkbox and re-render, using a task
ordinal that skips fenced code blocks. */
function toggleDashboardNoteTask(editor, taskIndex) {
if (!editor) return;
const value = editor.value || "";
const lines = value.split("\n");
let inFence = false;
let count = 0;
for (let idx = 0; idx < lines.length; idx++) {
const line = lines[idx];
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (inFence) continue;
const m = line.match(/^(\s*[-*+]\s+)\[([ xX])\](\s+)/);
if (!m) continue;
if (count === taskIndex) {
const newMark = m[2] === " " ? "x" : " ";
lines[idx] = m[1] + "[" + newMark + "]" + m[3] + line.slice(m[0].length);
editor.value = lines.join("\n");
editor.dispatchEvent(new Event("input", { bubbles: true }));
const notes = editor.closest(".canvasrefined-dashboard-notes");
const rendered = notes ? notes.querySelector(".canvasrefined-notes-rendered") : null;
if (rendered) renderDashboardNotesPreview(rendered, editor.value);
return;
}
count++;
}
}
const DASHBOARD_NOTES_HTML = `
`;
function wireDashboardNotes(notes) {
const editor = notes.querySelector(".canvasrefined-notes-editor");
const rendered = notes.querySelector(".canvasrefined-notes-rendered");
editor.value = options.dashboard_notes_text || "";
renderDashboardNotesPreview(rendered, editor.value);
const enterEdit = () => {
if (notes.classList.contains("is-editing")) return;
notes.classList.add("is-editing");
editor.focus();
const len = editor.value.length;
editor.setSelectionRange(len, len);
};
const exitEdit = () => {
notes.classList.remove("is-editing");
renderDashboardNotesPreview(rendered, editor.value);
};
rendered.addEventListener("click", (e) => {
// Clicking a checkbox toggles the task; clicking the text edits.
const t = e.target;
if (t && t.nodeType === 1 && t.tagName === "INPUT" && t.type === "checkbox" && t.closest && t.closest("li.cr-task")) {
e.stopPropagation();
const li = t.closest("li.cr-task");
const taskIndex = parseInt(li.dataset.crTask, 10);
if (!Number.isNaN(taskIndex)) toggleDashboardNoteTask(editor, taskIndex);
return;
}
enterEdit();
});
rendered.addEventListener("focus", enterEdit);
editor.addEventListener("blur", exitEdit);
editor.addEventListener("input", function () {
options.dashboard_notes_text = this.value;
delayDashboardNotesStorage(this.value);
});
// preventDefault on the toolbar keeps focus in the editor across misclicks.
const toolbar = notes.querySelector(".canvasrefined-notes-toolbar");
if (toolbar) toolbar.addEventListener("mousedown", e => e.preventDefault());
notes.querySelectorAll(".cr-fmt").forEach(btn => {
btn.addEventListener("click", () => {
if (!notes.classList.contains("is-editing")) enterEdit();
notesApplyFormat(editor, btn.dataset.action);
});
});
editor.addEventListener("keydown", function (e) {
if (e.key === "Escape") { e.preventDefault(); editor.blur(); return; } // Esc: render
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
const k = e.key.toLowerCase();
if (k === "b") { e.preventDefault(); notesApplyFormat(editor, "bold"); }
else if (k === "i") { e.preventDefault(); notesApplyFormat(editor, "italic"); }
else if (k === "enter") { e.preventDefault(); editor.blur(); } // Ctrl/Cmd+Enter: render
});
}
function loadDashboardNotes() {
const container = document.querySelector("#DashboardCard_Container");
if (options.dashboard_notes === true) {
if (!container) return;
let notes = document.querySelector('.canvasrefined-dashboard-notes');
// Rebuild older (split edit/preview) markup into the new single-surface layout.
if (notes && !notes.querySelector(".canvasrefined-notes-surface")) {
notes.remove();
notes = null;
}
if (!notes) {
notes = document.createElement("div");
notes.classList.add("canvasrefined-dashboard-notes");
notes.innerHTML = DASHBOARD_NOTES_HTML;
// Mount as a full-width sibling above the card grid. Prepending inside the
// DashboardCard_Container makes the notes a masonry/grid cell (narrow & broken).
const parent = container.parentNode;
if (parent) parent.insertBefore(notes, container);
else container.prepend(notes);
wireDashboardNotes(notes);
} else {
notes.style.display = "";
const editor = notes.querySelector(".canvasrefined-notes-editor");
const rendered = notes.querySelector(".canvasrefined-notes-rendered");
// Only sync and render in view mode; the textarea is the source of truth while editing.
if (!notes.classList.contains("is-editing")) {
if (editor && editor.value !== (options.dashboard_notes_text || "")) {
editor.value = options.dashboard_notes_text || "";
}
renderDashboardNotesPreview(rendered, editor ? editor.value : "");
}
}
} else {
let notes = document.querySelector('.canvasrefined-dashboard-notes');
if (notes) notes.style.display = "none";
}
}
/*
Custom font
*/
function loadCustomFont() {
// Quiz safe mode: don't override fonts on quiz pages.
if (quizSafeModeActive()) return;
let link = document.querySelector("#custom_font_link");
let style = document.querySelector("#custom_font");
let load = () => {
if (options.custom_font.link !== "") {
document.head.appendChild(style);
link.href = `https://fonts.googleapis.com/css2?family=${options.custom_font.link}&display=swap`;
link.rel = "stylesheet";
document.head.appendChild(link);
}
style.textContent = options.custom_font.link === "" ? "" : `*, input, a, button, h1, h2, h3, h4, h5, h6, p, span {font-family: ${options.custom_font.family}!important}`;
}
let createEls = () => {
link = document.createElement("link");
link.id = "custom_font_link";
style = document.createElement("style");
style.id = "custom_font";
load();
}
if (link && style) {
load();
} else if (options.custom_font.link !== "") {
if (document.readyState !== 'loading') {
createEls();
} else {
document.addEventListener("DOMContentLoaded", () => {
createEls();
});
}
}
}
/*
Smaller features
*/
// Debounced wrapper around applyAestheticChanges for card-style options that
// can fire many storage onChanged events in quick succession (number inputs).
// See the "cardPadding"/"imageSize"/etc. cases in applyOptionsChanges.
let aestheticDebounceTimer = null;
function debouncedApplyAestheticChanges(delay = 150) {
if (aestheticDebounceTimer) clearTimeout(aestheticDebounceTimer);
aestheticDebounceTimer = setTimeout(() => {
aestheticDebounceTimer = null;
applyAestheticChanges();
}, delay);
}
function applyAestheticChanges() {
// Quiz safe mode: don't inject custom layout/aesthetic CSS on quiz pages.
if (quizSafeModeActive()) return;
let style = document.querySelector("#canvasrefined-aesthetics") || document.createElement('style');
style.id = "canvasrefined-aesthetics";
style.textContent = "";
if (options.condensed_cards === true) style.textContent += ".ic-DashboardCard__header_hero {height:60px!important}.ic-DashboardCard__header-subtitle, .ic-DashboardCard__header-term{display:none}";
if (options.remlogo === true) style.textContent += ".ic-app-header__logomark-container{display:none}";
if (options.disable_color_overlay === true) style.textContent += ".ic-DashboardCard__header_hero{opacity: 0!important} .ic-DashboardCard__header-button-bg{opacity: 1!important}";
if (options.full_width === true) style.textContent += "#wrapper,.ic-Layout-wrapper{max-width:100%!important}";
if (options.center_cards === true) style.textContent += ".ic-DashboardCard__box__container{display:flex!important;flex-wrap:wrap!important;justify-content:center!important;align-items:flex-start!important}";
if (options.customCardStyles === true) {
if (options.imageSize !== undefined && options.imageSize !== 100) style.textContent += `.ic-DashboardCard__header_image {transform: scale(${options.imageSize / 100})!important; }`;
if (options.cardRoundness !== undefined && options.cardRoundness !== 5) style.textContent += `.ic-DashboardCard {border-radius: ${options.cardRoundness}px!important;}`;
if (options.cardSpacing !== undefined && options.cardSpacing !== 0) style.textContent += `.ic-DashboardCard {margin-right: ${options.cardSpacing / 2}px!important; margin-bottom: ${options.cardSpacing / 2}px!important;}`;
if (options.cardWidth !== undefined && options.cardWidth !== 262) style.textContent += `.ic-DashboardCard {width: ${options.cardWidth}px!important;}`;
// Always emit a fixed card height when custom card styles are on. The old
// `!== 250` guard silently dropped the height rule when cardHeight matched
// the default, which is exactly what happens after importing a theme that
// carries the default cardHeight (250) — leaving cards content-sized.
// Content-sized cards plus the dashboard reflow loop trigger Firefox scroll
// anchoring to yank the viewport back up while scrolling. A fixed height
// (even the default 250px) keeps layout stable.
if (options.cardHeight !== undefined && options.cardHeight !== null && options.cardHeight !== "") {
style.textContent += `.ic-DashboardCard {height: ${options.cardHeight}px!important;}`;
// Canvas sets overflow:hidden on .ic-DashboardCard. With a fixed
// height that clips the appended .canvasrefined-card-assignment area
// (the assignment rows live at the bottom of the card), making the
// .canvasrefined-assignment-link anchors unclickable for users with
// custom card styles enabled. Allow overflow so those rows stay
// visible and interactive when card assignments are shown.
if (options.assignments_due === true) style.textContent += `.ic-DashboardCard {overflow: visible!important;}`;
}
// Inner card padding. Applied to the whole .ic-DashboardCard box (the
// element that holds the hero header, title, and action buttons) so the
// colored hero header and its content all get consistent breathing room
// from the card's edges. Guarded by !== 0 (default).
if (options.cardPadding !== undefined && Number(options.cardPadding) > 0) {
style.textContent += `.ic-DashboardCard {padding: ${options.cardPadding}px!important;}`;
}
}
style.textContent += ".ic-app-nav-toggle-and-crumbs{display:none!important}";
if (options.custom_styles !== "") style.textContent += options.custom_styles;
document.documentElement.appendChild(style);
}
/*
Quiz safe mode banner (pre-quiz pages only).
Shows an info box explaining the extension hasn't been approved by all teachers,
with a toggle for Quiz Safe Mode and a "Don't show again" button.
*/
function setupQuizSafeModeBanner() {
if (!isQuizPreTakePage()) return;
if (document.getElementById("canvasrefined-quiz-safe-banner")) return;
chrome.storage.local.get("quiz_safe_mode_reminder_dismissed", local => {
if (local && local.quiz_safe_mode_reminder_dismissed === true) return;
chrome.storage.sync.get("quiz_safe_mode", sync => {
const safeModeOn = sync && sync.quiz_safe_mode === true;
injectQuizSafeModeBanner(safeModeOn);
});
});
}
function injectQuizSafeModeBanner(safeModeOn) {
// Only inject into a real Canvas content container — never , which
// would place the banner outside the content area if it renders too early.
const findContainer = () =>
document.querySelector(".ic-Layout-contentMain") ||
document.querySelector("#content") ||
document.querySelector("#main");
const insertInto = (container) => {
if (!container) return false;
if (document.getElementById("canvasrefined-quiz-safe-banner")) return true;
const banner = makeElement("div", container, {
id: "canvasrefined-quiz-safe-banner",
className: "canvasrefined-quiz-safe-banner",
}, true);
makeElement("div", banner, {
className: "canvasrefined-quiz-safe-title",
textContent: "Canvas Refined — Quiz Safe Mode",
});
makeElement("p", banner, {
className: "canvasrefined-quiz-safe-info",
textContent: "This extension hasn't been 100% approved by all teachers. Quiz Safe Mode turns off most Canvas Refined features that could interfere with this quiz page, giving you the default Canvas quiz experience.",
});
const toggleRow = makeElement("div", banner, { className: "canvasrefined-quiz-safe-row" });
const toggleWrap = makeElement("label", toggleRow, { className: "canvasrefined-quiz-safe-toggle" });
const checkbox = makeElement("input", toggleWrap, { type: "checkbox" });
checkbox.checked = !!safeModeOn;
checkbox.addEventListener("change", () => {
chrome.storage.sync.set({ quiz_safe_mode: checkbox.checked });
// The storage.onChanged listener (applyOptionsChanges) reloads quiz pages.
});
makeElement("span", toggleWrap, {
className: "canvasrefined-quiz-safe-toggle-label",
textContent: "Enable Quiz Safe Mode",
});
const dismissBtn = makeElement("button", toggleRow, {
className: "canvasrefined-quiz-safe-dismiss",
type: "button",
textContent: "Don't show again",
title: "Hides this reminder permanently. You can still toggle Quiz Safe Mode in the extension popup.",
});
dismissBtn.addEventListener("click", () => {
chrome.storage.local.set({ quiz_safe_mode_reminder_dismissed: true });
banner.remove();
});
return true;
};
if (insertInto(findContainer())) return;
// Content container not ready yet; wait for it (never fall back to ).
const obs = new MutationObserver(() => {
if (insertInto(findContainer())) obs.disconnect();
});
obs.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => obs.disconnect(), 15000);
}
// =============================================================================
// Global Canvas Search
// Search across all of the user's courses for modules, module items, and
// assignments, then jump straight to them. Triggered by a floating search
// button (bottom-right) or Ctrl/Cmd+K.
// =============================================================================
let globalSearchIndex = null; // [{type,title,course,courseId,url}]
let globalSearchIndexPromise = null; // in-flight build so concurrent opens share one fetch
let globalSearchIndexAt = 0; // ms timestamp of last successful build
const GLOBAL_SEARCH_INDEX_TTL = 10 * 60 * 1000; // 10 minutes
const GLOBAL_SEARCH_STORAGE_KEY = "canvasrefined_global_search_index";
let _gsShortcutBound = false;
function setupGlobalSearch() {
if (options.global_search !== true) return;
// Rebuild the index fresh on every page load so newly-concluded/hidden
// courses never linger from a previous session's cache.
invalidateGlobalSearchIndex();
ensureGlobalSearchButton();
ensureGlobalSearchShortcut();
}
function removeGlobalSearch() {
document.getElementById("canvasrefined-global-search-header-btn")?.remove();
removeGlobalSearchBetterSidebarButton();
removeGlobalSearchNativeSidebarButton();
closeGlobalSearchModal();
if (_gsPlacementObserver) { _gsPlacementObserver.disconnect(); _gsPlacementObserver = null; }
if (_gsShortcutBound) {
document.removeEventListener("keydown", onGlobalSearchShortcut, true);
_gsShortcutBound = false;
}
}
// Shared search icon used by the sidebar + header triggers.
const GLOBAL_SEARCH_ICON_SVG = ``;
// Placement: a search trigger is injected into whichever left sidebar is
// active — the Better Sidebar (when enabled) or Canvas' native global nav —
// and, on the dashboard, a button is also placed in the header actions row.
// There is no floating button. A rAF-debounced MutationObserver re-evaluates
// placement as Canvas renders/SPA-navigates/rebuilds the sidebar.
let _gsPlacementObserver = null;
let _gsPlacementScheduled = false;
function ensureGlobalSearchButton() {
placeGlobalSearchTrigger();
if (_gsPlacementObserver) return;
_gsPlacementObserver = new MutationObserver(() => {
if (_gsPlacementScheduled) return;
_gsPlacementScheduled = true;
requestAnimationFrame(() => {
_gsPlacementScheduled = false;
placeGlobalSearchTrigger();
});
});
_gsPlacementObserver.observe(document.documentElement, { childList: true, subtree: true });
}
function placeGlobalSearchTrigger() {
if (options.global_search !== true) return;
// Native global nav (the slim icon bar) — always present, so always add.
ensureGlobalSearchNativeSidebarButton();
// Better Sidebar (extra column when the option is enabled) — add when present.
const betterSidebar = document.getElementById("better-sidebar-container");
if (betterSidebar) {
ensureGlobalSearchBetterSidebarButton(betterSidebar);
} else {
removeGlobalSearchBetterSidebarButton();
}
// Dashboard header button (in addition to the sidebar triggers).
const headerActions = document.querySelector(".ic-Dashboard-header__actions");
if (isDashboardPage() && headerActions) {
ensureGlobalSearchHeaderButton(headerActions);
} else {
document.getElementById("canvasrefined-global-search-header-btn")?.remove();
}
}
// --- Better Sidebar trigger --------------------------------------------------
function ensureGlobalSearchBetterSidebarButton(betterSidebar) {
// The first child of #better-sidebar-container is the button list.
const sidebarContent = betterSidebar.querySelector("div");
if (!sidebarContent) return;
if (sidebarContent.querySelector("#canvasrefined-gs-sidebar-btn")) return;
const btn = document.createElement("a");
btn.id = "canvasrefined-gs-sidebar-btn";
btn.className = "canvasrefined-custom-btn better-sidebar-btn canvasrefined-gs-sidebar-btn";
btn.href = "#";
btn.title = "Search Canvas (Ctrl+K)";
btn.setAttribute("role", "button");
btn.setAttribute("aria-label", "Search Canvas");
btn.style.cssText = "width:40%;height:var(--bc-sidebar-btn-height,30px);cursor:pointer;text-align:center;text-decoration:none;display:inline-flex;justify-content:center;align-items:center;gap:var(--bc-sidebar-btn-gap,8px);color:var(--bcsidebar-text) !important;font-weight:bold;position:relative;";
btn.innerHTML = `${GLOBAL_SEARCH_ICON_SVG}Search`;
btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); openGlobalSearchModal(); });
// Append so it sits at the bottom of the sidebar item list.
sidebarContent.appendChild(btn);
// Match the sidebar's current expanded/collapsed mode immediately so the
// button doesn't briefly render in the wrong state (e.g. label visible while
// collapsed) until the next toggle calls updateSidebar().
applyGlobalSearchSidebarButtonMode(btn, betterSidebar.dataset.expanded === "true");
}
function removeGlobalSearchBetterSidebarButton() {
document.getElementById("canvasrefined-gs-sidebar-btn")?.remove();
}
// Apply the Better Sidebar's current expanded/collapsed styling to the search
// button, mirroring updateSidebar()'s button/label/svg rules so the button is
// correct the moment it's inserted (and whenever the sidebar re-renders).
function applyGlobalSearchSidebarButtonMode(btn, expanded) {
if (!btn) return;
btn.style.width = expanded ? "80%" : "40%";
const label = btn.querySelector(".better-sidebar-label");
if (label) label.style.display = expanded ? "block" : "none";
btn.querySelectorAll("svg").forEach(svg => {
svg.style.width = "var(--bc-sidebar-icon-size,20px)";
svg.style.height = "var(--bc-sidebar-icon-size,20px)";
});
}
// --- Native global-nav trigger ----------------------------------------------
function ensureGlobalSearchNativeSidebarButton() {
const navMenu = document.getElementById("menu");
if (!navMenu) return;
if (navMenu.querySelector("#canvasrefined-gs-nav-item")) return;
const li = document.createElement("li");
li.id = "canvasrefined-gs-nav-item";
li.className = "ic-app-header__menu-list-item canvasrefined-gs-nav-item";
const link = document.createElement("a");
link.className = "ic-app-header__menu-list-link";
link.href = "#";
link.setAttribute("role", "button");
link.title = "Search Canvas (Ctrl+K)";
link.setAttribute("aria-label", "Search Canvas");
link.innerHTML = `${GLOBAL_SEARCH_ICON_SVG}Search`;
link.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); openGlobalSearchModal(); });
li.appendChild(link);
// Append so the search item appears at the bottom of the global nav.
navMenu.appendChild(li);
}
function removeGlobalSearchNativeSidebarButton() {
document.getElementById("canvasrefined-gs-nav-item")?.remove();
}
// --- Dashboard header trigger -----------------------------------------------
function ensureGlobalSearchHeaderButton(headerActions) {
if (headerActions.querySelector("#canvasrefined-global-search-header-btn")) return;
const btn = document.createElement("button");
btn.id = "canvasrefined-global-search-header-btn";
btn.type = "button";
btn.className = "canvasrefined-gs-header-btn";
btn.title = "Search Canvas (Ctrl+K)";
btn.setAttribute("aria-label", "Search Canvas");
btn.innerHTML = `Search`;
btn.addEventListener("click", openGlobalSearchModal);
// Insert as the first child of the actions row so it sits just to the left
// of the "Dashboard Options" (⋯) button, right-aligned with it.
headerActions.insertBefore(btn, headerActions.firstChild);
}
function ensureGlobalSearchShortcut() {
if (_gsShortcutBound) return;
document.addEventListener("keydown", onGlobalSearchShortcut, true);
_gsShortcutBound = true;
}
function onGlobalSearchShortcut(e) {
// Ctrl/Cmd+K toggles the search modal. Ignore when a modal is already open
// and the user is typing in its input (handled by the modal's own listener).
if (!(e.ctrlKey || e.metaKey) || !(e.key === "k" || e.key === "K")) return;
// Never activate on quiz pages (intro or take) so we don't interfere with
// the quiz experience or the browser's native Ctrl+K. Read the URL live
// because current_page can be stale after Canvas' client-side navigation.
if (/^\/courses\/\d+\/quizzes\/\d+(?:\/|$)/.test(window.location.pathname)) return;
const modal = document.getElementById("canvasrefined-global-search-modal");
if (modal && modal.dataset.open === "true") {
closeGlobalSearchModal();
} else {
e.preventDefault();
openGlobalSearchModal();
}
}
function openGlobalSearchModal() {
if (document.getElementById("canvasrefined-global-search-modal")) return;
const modal = document.createElement("div");
modal.id = "canvasrefined-global-search-modal";
modal.className = "canvasrefined-gs-modal";
modal.dataset.open = "true";
modal.innerHTML = `
`;
document.body.appendChild(modal);
const input = modal.querySelector("#canvasrefined-gs-input");
const resultsEl = modal.querySelector("#canvasrefined-gs-results");
const closeBtn = modal.querySelector("#canvasrefined-gs-close");
let selected = -1;
let currentResults = [];
closeBtn.addEventListener("click", closeGlobalSearchModal);
modal.addEventListener("mousedown", (e) => { if (e.target === modal) closeGlobalSearchModal(); });
// Escape closes; arrows + enter navigate. Bound on capture so we win over
// the global Ctrl+K toggle.
modal.addEventListener("keydown", (e) => {
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closeGlobalSearchModal(); return; }
if (e.key === "ArrowDown") {
e.preventDefault();
selected = Math.min(selected + 1, currentResults.length - 1);
renderGlobalSearchSelection(resultsEl, selected);
} else if (e.key === "ArrowUp") {
e.preventDefault();
selected = Math.max(selected - 1, 0);
renderGlobalSearchSelection(resultsEl, selected);
} else if (e.key === "Enter") {
e.preventDefault();
const item = currentResults[selected];
if (item) openGlobalSearchResult(item, e.ctrlKey || e.metaKey);
}
});
// Stop the global Ctrl+K handler from closing the modal while typing.
input.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) e.stopPropagation();
});
let debounce = null;
input.addEventListener("input", () => {
clearTimeout(debounce);
debounce = setTimeout(() => runGlobalSearch(input.value.trim(), resultsEl).then(res => {
currentResults = res;
selected = res.length ? 0 : -1;
renderGlobalSearchSelection(resultsEl, selected);
}), 120);
});
requestAnimationFrame(() => input.focus());
// Kick off indexing immediately so the first keystroke is fast.
ensureGlobalSearchIndex();
// Render an initial hint.
resultsEl.innerHTML = `
Start typing to search your modules and assignments.
`;
}
function closeGlobalSearchModal() {
const modal = document.getElementById("canvasrefined-global-search-modal");
if (!modal) return;
modal.remove();
}
function openGlobalSearchResult(item, newTab) {
if (!item || !item.url) return;
if (newTab) {
// Opening in a new tab keeps the search menu open so the user can keep
// searching. Refocus the input for the next keystroke.
window.open(item.url, "_blank", "noopener");
const input = document.getElementById("canvasrefined-gs-input");
if (input) input.focus();
} else {
closeGlobalSearchModal();
window.location.href = item.url;
}
}
function renderGlobalSearchSelection(resultsEl, selected) {
const rows = resultsEl.querySelectorAll(".canvasrefined-gs-row");
rows.forEach((row, i) => {
if (i === selected) { row.classList.add("canvasrefined-gs-selected"); row.scrollIntoView({ block: "nearest" }); }
else row.classList.remove("canvasrefined-gs-selected");
});
}
// --- Indexing ---------------------------------------------------------------
// Drop any cached index so the next access rebuilds it from the API. Called
// at setup time so every page load starts fresh.
function invalidateGlobalSearchIndex() {
globalSearchIndex = null;
globalSearchIndexAt = 0;
globalSearchIndexPromise = null;
try { chrome.storage.local.remove(GLOBAL_SEARCH_STORAGE_KEY); } catch (_) { /* ignore */ }
}
async function ensureGlobalSearchIndex() {
// A per-session in-memory build is shared across opens so we don't refetch
// on every keystroke, but we never serve a persisted cache across reloads.
if (globalSearchIndex && (Date.now() - globalSearchIndexAt) < GLOBAL_SEARCH_INDEX_TTL) {
return globalSearchIndex;
}
if (globalSearchIndexPromise) return globalSearchIndexPromise;
globalSearchIndexPromise = (async () => {
const index = await buildGlobalSearchIndex();
globalSearchIndex = index;
globalSearchIndexAt = Date.now();
return index;
})();
try {
return await globalSearchIndexPromise;
} finally {
globalSearchIndexPromise = null;
}
}
async function buildGlobalSearchIndex() {
let courses = [];
try {
// enrollment_state=active excludes concluded/inactive enrollments at the
// source so we never index (or waste requests on) past-term courses.
courses = await getData(`${domain}/api/v1/courses?enrollment_state=active&per_page=100`);
} catch (e) {
console.warn("[CanvasRefined] global search: failed to load courses", e);
return [];
}
if (!Array.isArray(courses) || !courses.length) return [];
// Skip inactive/concluded/hidden courses. `enrollment_state=active`
// already filters at the source, but some institutions return past-term
// courses as "active", so we double-check here:
// - access_restricted_by_date (locked courses)
// - concluded === true (Canvas marks concluded courses)
// - term end_date in the past (when the API exposes it)
// - courses the user hid from their dashboard (custom_cards.hidden)
const now = Date.now();
courses = courses.filter(c => {
if (!c || !c.name) return false;
if (c.access_restricted_by_date === true) return false;
if (c.concluded === true) return false;
// term end date check (API may return term.end_at)
const endAt = c?.term?.end_at || c?.end_at;
if (endAt) {
const end = new Date(endAt).getTime();
if (!isNaN(end) && end < now) return false;
}
if (isCourseHidden(c.id)) return false;
return true;
});
// Cap to keep request volume sane.
courses = courses.slice(0, 60);
// Canvas can return the same course more than once (multi-role enrollments,
// cross-listed sections). Dedupe by id so we don't double-index or double-fetch.
const seenCourseIds = new Set();
courses = courses.filter(c => {
if (seenCourseIds.has(c.id)) return false;
seenCourseIds.add(c.id);
return true;
});
const index = [];
// Shared dedup state. Keys identify a piece of *content* regardless of where
// it surfaced, so the same assignment (which Canvas exposes both as a module
// item AND a standalone assignment) collapses to a single result.
// - standalone assignment: `asn::`
// - module item with content_id: `::`
// (for type "assignment" this becomes `asn::` —
// the SAME key as the standalone assignment, so whichever is added
// first wins; we add assignments first to keep the direct URL)
// - module item without content_id (external url/tool): `url:`
// - module itself: `module::`
const seenContent = new Set();
await Promise.all(courses.map(async (course) => {
const courseId = course.id;
const courseName = course.name;
const courseCode = course.course_code || courseName;
// Assignments first so their direct URLs win over the module-item
// versions of the same assignment.
try {
const assignments = await getData(`${domain}/api/v1/courses/${courseId}/assignments?per_page=100`);
if (Array.isArray(assignments)) {
for (const a of assignments) {
if (!a || !a.name || !a.html_url) continue;
const key = `asn:${courseId}:${a.id}`;
if (seenContent.has(key)) continue;
seenContent.add(key);
index.push({
type: "Assignment",
title: a.name,
course: courseName,
courseCode,
courseId,
url: a.html_url
});
}
}
} catch (_) { /* non-fatal */ }
// Modules + their items.
try {
const modules = await getData(`${domain}/api/v1/courses/${courseId}/modules?per_page=100`);
if (Array.isArray(modules)) {
for (const m of modules) {
if (!m || !m.name) continue;
const modKey = `module:${courseId}:${m.id}`;
if (!seenContent.has(modKey)) {
seenContent.add(modKey);
index.push({
type: "Module",
title: m.name,
course: courseName,
courseCode,
courseId,
url: `${domain}/courses/${courseId}/modules`
});
}
try {
const items = await getData(`${domain}/api/v1/courses/${courseId}/modules/${m.id}/items?per_page=100`);
if (Array.isArray(items)) {
for (const it of items) {
if (!it || !it.title) continue;
// Skip text headers / dividers — no destination page.
const itype = (it.type || "").toLowerCase();
if (itype === "subheader") continue;
// Prefer the real page link (html_url). External
// URL items expose external_url instead; the bare
// `url` field is the API endpoint, never use it.
const url = it.html_url || it.external_url;
if (!url) continue;
// Build a content-identity key so the same item
// appearing in multiple modules (or mirroring a
// standalone assignment) only produces one result.
let key;
if (itype === "assignment" && it.content_id) {
key = `asn:${courseId}:${it.content_id}`;
} else if (it.content_id) {
key = `${itype}:${courseId}:${it.content_id}`;
} else {
key = `url:${normalizeGlobalSearchUrl(url)}`;
}
if (seenContent.has(key)) continue;
seenContent.add(key);
index.push({
type: prettyModuleItemType(it.type),
title: it.title,
course: courseName,
courseCode,
courseId,
url
});
}
}
} catch (_) { /* per-module failure is non-fatal */ }
}
}
} catch (_) { /* per-course failure is non-fatal */ }
}));
// Final safety net: collapse any remaining normalized-URL duplicates (e.g.
// external links whose content_id differed but resolve to the same page).
const seen = new Set();
return index.filter(item => {
const key = normalizeGlobalSearchUrl(item.url);
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function normalizeGlobalSearchUrl(url) {
if (!url) return "";
try {
const u = new URL(url, domain);
let path = u.pathname.replace(/\/+$/, ""); // strip trailing slashes
// Ignore case + fragment/query for matching purposes.
return (u.host.toLowerCase() + path).toLowerCase();
} catch (_) {
// Non-absolute (shouldn't happen, but be safe) — normalize as-is.
return String(url).replace(/\/+$/, "").toLowerCase();
}
}
function prettyModuleItemType(type) {
switch ((type || "").toLowerCase()) {
case "assignment": return "Assignment";
case "quiz": return "Quiz";
case "discussion": case "discussion_topic": return "Discussion";
case "externalurl": return "Link";
case "externaltool": return "External Tool";
case "file": return "File";
case "page": return "Page";
case "subheader": return "Section";
default: return type || "Item";
}
}
// --- Searching --------------------------------------------------------------
async function runGlobalSearch(query, resultsEl) {
if (!globalSearchIndex && globalSearchIndexPromise) {
resultsEl.innerHTML = `
Building search index\u2026
`;
}
const index = await ensureGlobalSearchIndex();
if (!query) {
resultsEl.innerHTML = `
Start typing to search your modules and assignments.
No modules or assignments found. Open the search again later if your courses are still loading.
`;
return [];
}
const q = query.toLowerCase();
const matches = [];
for (const item of index) {
// Re-check hidden status at search time so a card hidden after the index
// was cached (10-min TTL) never surfaces in results.
if (isCourseHidden(item.courseId)) continue;
const t = (item.title || "").toLowerCase();
const c = (item.course || "").toLowerCase();
let score = -1;
if (t.startsWith(q)) score = 100 - t.indexOf(q);
else if (t.includes(q)) score = 60 - t.indexOf(q);
else if (c.includes(q)) score = 20;
if (score >= 0) { item._score = score + (t === q ? 50 : 0); matches.push(item); }
}
matches.sort((a, b) => b._score - a._score);
const top = matches.slice(0, 50);
if (!top.length) {
resultsEl.innerHTML = `
No results for \u201c${escapeGlobalSearchHtml(query)}\u201d.