`;
let restoreWrapper = null;
if (onTasksTab) {
const groupList = todayContainer.wrapper.querySelector(".todo-group-list");
if (groupList) groupList.insertBefore(card, groupList.firstChild);
// Force Due Today visible and remember its state so the empty header doesn't linger.
restoreWrapper = {
wrapper: todayContainer.wrapper,
display: todayContainer.wrapper.style.display,
hadVisible: todayContainer.wrapper.hasAttribute("data-has-visible"),
};
todayContainer.wrapper.style.display = "block";
todayContainer.wrapper.setAttribute("data-has-visible", "true");
} else {
card.style.cssText += "position:fixed;top:70px;right:16px;width:300px;z-index:2147483646;box-shadow:0 2px 10px rgba(0,0,0,.25);";
document.body.appendChild(card);
}
// Mirror a real completion: effect plus matching exit, honouring the lead-in.
const celebration = runCelebration(card);
if (celebration.start) {
const startFn = celebration.start;
setTimeout(() => { if (card.isConnected) startFn(); }, celebration.startDelay || 0);
}
applyTodoExitAnimation(card, celebration.style, celebration.delay);
setTimeout(() => {
try { card.remove(); } catch (e) { /* ignore */ }
if (restoreWrapper) {
try {
restoreWrapper.wrapper.style.display = restoreWrapper.display;
if (!restoreWrapper.hadVisible) restoreWrapper.wrapper.removeAttribute("data-has-visible");
} catch (e) { /* wrapper re-rendered away already */ }
}
}, celebrationExitDuration(celebration.style, celebration.delay) + 120);
return { ok: true };
} catch (e) {
console.error("celebration preview error", e);
return { ok: false, reason: "error" };
}
}
// --- Local "incomplete" pins ---
// Canvas can't un-submit, so submitted items are pinned back into Tasks locally.
function todoPinKey(item) {
return item.plannable_type + "_" + item.plannable_id;
}
function isPinnedIncomplete(item) {
return options.todo_force_incomplete?.[todoPinKey(item)] === true;
}
function setPinnedIncomplete(item, pinned) {
const pins = Object.assign({}, options.todo_force_incomplete || {});
if (pinned) pins[todoPinKey(item)] = true;
else delete pins[todoPinKey(item)];
options.todo_force_incomplete = pins;
chrome.storage.sync.set({ todo_force_incomplete: pins });
}
// opts.celebrate === false suppresses the celebration (announcement "mark as seen").
function markAs(item, element, makeComplete, opts = {}) {
const csrfToken = CSRFtoken();
const completeState = makeComplete;
// Pre-click state, for rollback if the server refuses the change.
const previousComplete = item.planner_override ? item.planner_override.marked_complete === true : false;
const hadPin = isPinnedIncomplete(item);
// Checking off clears a pin; unchecking a submitted item sets one.
if (completeState) {
if (isPinnedIncomplete(item)) setPinnedIncomplete(item, false);
} else if (item.submissions?.submitted) {
setPinnedIncomplete(item, true);
}
// --- Optimistic UI ---
// Canvas sometimes 400s even though the change lands: update the UI now, persist later.
item.planner_override = item.planner_override || {};
item.planner_override.marked_complete = completeState;
// Cancel timers left by a previous markAs on this element (fast double-click).
element.__crCelebrationCancelled = true;
if (element.__crExitTimer) { clearTimeout(element.__crExitTimer); element.__crExitTimer = null; }
if (element.__crCelebTimer) { clearTimeout(element.__crCelebTimer); element.__crCelebTimer = null; }
element.__crCelebrationCancelled = false;
// Celebrate before the exit moves the element: effects measure its rect.
let exitStyle = "slide";
let exitDelay = 0;
if (completeState && opts.celebrate !== false) {
try {
const celebration = runCelebration(element);
exitStyle = celebration.style;
exitDelay = celebration.delay || 0;
if (celebration.start) {
const startFn = celebration.start;
const fire = () => {
if (element.__crCelebrationCancelled === true || !element.isConnected) return;
startFn();
};
if (celebration.startDelay > 0) {
if (element.__crCelebTimer) clearTimeout(element.__crCelebTimer);
element.__crCelebTimer = setTimeout(() => {
element.__crCelebTimer = null;
fire();
}, celebration.startDelay);
} else {
fire();
}
}
} catch (e) { console.error('celebration trigger error', e); }
}
applyTodoExitAnimation(element, exitStyle, exitDelay);
// Update progress rings now so they animate along with the exit.
const refreshProgressRings = () => {
const progressPlaceholder = document.getElementById("better-todo-progress-placeholder");
if (!progressPlaceholder || typeof assignments?.then !== 'function' || !progressRingsEnabled()) return;
assignments.then(data => {
const courseId = getCurrentCourseId();
const scopedData = getTodoScopedData(data.map(d => Object.assign({}, d)), courseId);
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;
}
}
if (options.hide_personal_details === true && options.anon_fake_todo === true) {
scopedData.push(...buildFakeTodoItems());
}
renderProgressRings(progressPlaceholder, scopedData);
});
};
refreshProgressRings();
// --- Failure rollback ---
// Restore the pre-click state when the server refuses: soft-deleted override rows are
// invisible to the read endpoints yet still block the POST with 400 "already exists".
const revertOptimisticUI = (reason) => {
item.planner_override = item.planner_override || {};
item.planner_override.marked_complete = previousComplete;
if (completeState) {
if (hadPin) setPinnedIncomplete(item, true);
} else if (item.submissions?.submitted && !hadPin) {
setPinnedIncomplete(item, false);
}
console.warn("planner override not persisted, reverting UI:", reason);
// Snap the card back and undo any exit animation still applied to it.
element.style.transform = "";
element.style.opacity = "";
element.style.transition = "";
element.style.visibility = "";
element.classList.remove("canvasrefined-rocket-exit");
element.__crCelebrationCancelled = true;
if (element.__crExitTimer) {
clearTimeout(element.__crExitTimer);
element.__crExitTimer = null;
}
if (element.__crCelebTimer) {
clearTimeout(element.__crCelebTimer);
element.__crCelebTimer = null;
}
if (element.__crRocketClone) {
try { element.__crRocketClone.remove(); } catch (e) { /* ignore */ }
element.__crRocketClone = null;
}
const loc = document.querySelector("#canvasrefined-todo-list");
if (loc) { clearTodoList(); createTodoSections(loc); }
refreshProgressRings();
};
setTimeout(() => {
clearTodoList();
createTodoSections(document.querySelector("#canvasrefined-todo-list"));
}, celebrationExitDuration(exitStyle, exitDelay));
// --- Persistence (background, best-effort) ---
// Store the override id so later toggles PUT instead of POSTing a duplicate.
const sendOverride = (override) => pageFetch(domain + "/api/v1/planner/overrides" + (override && override.id ? "/" + override.id : ""), {
method: override && override.id ? "PUT" : "POST",
headers: {
"content-type": "application/json",
"accept": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({
id: override && override.id ? override.id : null,
marked_complete: completeState,
plannable_id: item.plannable_id,
plannable_type: item.plannable_type
})
});
// Every active override, following the Link header. Soft-deleted rows are unlistable
// but still block POST with 400, hence the PUT retry. Returns null on failure.
const fetchAllOverrides = async () => {
const all = [];
let url = domain + "/api/v1/planner/overrides?per_page=100";
for (let page = 0; page < 10 && url; page++) {
const pageResp = await pageFetch(url, { headers: { "accept": "application/json" } });
if (!pageResp.ok) return null;
const data = await pageResp.json();
if (!Array.isArray(data)) return null;
all.push(...data);
url = getNextPageUrl(pageResp.headers.get("Link"));
}
return all;
};
// Canvas re-points overrides on save, so fall back to matching assignment_id.
const matchesItem = (o) => {
if (!o) return false;
if (String(o.plannable_id) === String(item.plannable_id) &&
String(o.plannable_type).toLowerCase() === String(item.plannable_type).toLowerCase()) return true;
const assignmentId = item.plannable && item.plannable.assignment_id != null
? item.plannable.assignment_id
: (item.plannable_type === "assignment" ? item.plannable_id : null);
return assignmentId != null && o.assignment_id != null && String(o.assignment_id) === String(assignmentId);
};
sendOverride(item.planner_override)
.then(async resp => {
if (resp.ok) {
// Keep the id: without it every later toggle POSTs a duplicate and 400s.
try {
const saved = await resp.json();
if (saved && saved.id) {
item.planner_override = item.planner_override || {};
item.planner_override.id = saved.id;
}
} catch (e) { /* no JSON body; id stays unknown */ }
console.log("marked as complete");
return;
}
// No known id: look the item's override up and retry as a PUT.
if (!(item.planner_override && item.planner_override.id)) {
try {
const overrides = await fetchAllOverrides();
const match = overrides && overrides.find(matchesItem);
if (match && match.id) {
item.planner_override = item.planner_override || {};
item.planner_override.id = match.id;
const retry = await sendOverride(item.planner_override);
if (retry.ok) {
try {
const saved = await retry.json();
if (saved && saved.id) item.planner_override.id = saved.id;
} catch (e) { /* id stays unknown */ }
console.log("marked as complete (recovered override id)");
return;
}
console.warn("planner override PUT retry returned", retry.status);
return revertOptimisticUI("PUT retry returned " + retry.status);
}
return revertOptimisticUI("POST returned " + resp.status + " and no matching override was found");
} catch (e) {
console.error("error recovering planner override id", e);
return revertOptimisticUI("override recovery failed: " + e);
}
}
return revertOptimisticUI("request returned " + resp.status);
})
.catch(err => {
console.error("error marking as complete", err);
revertOptimisticUI(err);
});
}
function createTodoViewMore(location, type) {
let viewMoreButton = makeElement("button", location, { "className": "canvasrefined-custom-btn canvasrefined-viewmore-btn", "textContent": chrome.i18n.getMessage("view_more") || "View More" });
const showMoreCount = 3;
viewMoreButton.addEventListener("click", function (e) {
if (type === "announcement") {
moreAnnouncementCount += showMoreCount;
} else {
moreAssignmentCount += showMoreCount;
}
loadBetterTodo();
});
}
function setupBetterTodo() {
if (isQuizPage()) return;
// Only the dashboard and course homepages get the todo sidebar.
if (!isTodoAllowedPage()) return;
if (options.better_todo !== true || isGradesPage()) return;
if (document.querySelector('#canvasrefined-todo-list')) return;
// `assignments` may not be set yet; bail and let checkDashboardReady call us again.
if (!assignments || typeof assignments.then !== "function") return;
let list = document.querySelector("#right-side");
if (!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`);
}
// --- Mobile global-nav tray (Better Sidebar styling) ---
// Below Canvas' breakpoint the nav is an InstUI tray dialog, so restyle it to match the
// rail. Selectors use stable attributes, not emotion hashes.
function setupMobileNavStyle() {
let style = document.getElementById("canvasrefined-mobile-nav") || document.createElement("style");
style.id = "canvasrefined-mobile-nav";
if (!options.better_sidebar) {
style.remove();
return;
}
const sidebarOpacity = Math.max(0, Math.min(100, Number(options.sidebar_opacity ?? 100)));
const sidebarBlur = Math.max(0, Math.min(30, Number(options.sidebar_blur ?? 0)));
const sidebarTransparent = 100 - sidebarOpacity;
const dialog = '[role="dialog"][aria-label="Global Navigation"]';
style.textContent = `
/* tray wrapper: fixed drawer width instead of InstUI's inline width */
span:has(> ${dialog}) {
width: min(320px, 90vw) !important;
max-width: 90vw !important;
}
/* same glass surface as the Better Sidebar rail */
${dialog} {
background: color-mix(in srgb, var(--bcsidebar), transparent ${sidebarTransparent}%) !important;
backdrop-filter: blur(${sidebarBlur}px) !important;
-webkit-backdrop-filter: blur(${sidebarBlur}px) !important;
border-right: 1px solid color-mix(in srgb, var(--bcsidebar-text) 10%, transparent) !important;
border-radius: 0 16px 16px 0 !important;
box-shadow: 8px 0 40px rgba(0, 0, 0, .28) !important;
color: var(--bcsidebar-text) !important;
}
/* recolor labels/icons/arrows to sidebar text; fill + currentColor both */
${dialog} svg {
fill: var(--bcsidebar-text) !important;
}
${dialog} a,
${dialog} span[class*="-text"],
${dialog} h2[class*="-heading"],
${dialog} h2[class*="-heading"] a {
color: var(--bcsidebar-text) !important;
text-decoration: none !important;
}
/* hide the big institution logo so the header collapses to the close button */
${dialog} .ic-brand-mobile-global-nav-logo {
display: none !important;
}
/* rows: padded, rounded, same hover tint as the rail */
${dialog} li[data-cid="ListItem"] > a,
${dialog} li[data-cid="ListItem"] > div[data-cid="ToggleDetails"] > button {
padding: 12px 14px !important;
border-radius: 10px !important;
transition: background-color .15s ease !important;
}
${dialog} li[data-cid="ListItem"] > a:hover,
${dialog} li[data-cid="ListItem"] > a:focus-visible,
${dialog} li[data-cid="ListItem"] > div[data-cid="ToggleDetails"] > button:hover,
${dialog} li[data-cid="ListItem"] > div[data-cid="ToggleDetails"] > button:focus-visible {
background: #0000004f !important;
}
/* expanded sub-items, indented like the rail's section links */
${dialog} div[id^="Expandable"] a {
padding: 10px 14px 10px 40px !important;
border-radius: 10px !important;
opacity: .92;
transition: background-color .15s ease !important;
}
${dialog} div[id^="Expandable"] a:hover,
${dialog} div[id^="Expandable"] a:focus-visible {
background: #0000004f !important;
}
/* close button: bare glass circle (data-cid holds several tokens) */
${dialog} button[data-cid*="BaseButton"] {
background: color-mix(in srgb, var(--bcsidebar-text) 12%, transparent) !important;
border-radius: 999px !important;
color: var(--bcsidebar-text) !important;
transition: background-color .15s ease !important;
}
${dialog} button[data-cid*="BaseButton"]:hover {
background: color-mix(in srgb, var(--bcsidebar-text) 22%, transparent) !important;
}
/* avatar ring so it reads on the glass */
${dialog} [class*="-avatar"] {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bcsidebar-text) 30%, transparent) !important;
}
/* theme-tinted thin scrollbar */
${dialog} ::-webkit-scrollbar { width: 8px; height: 8px; }
${dialog} ::-webkit-scrollbar-track { background: transparent; }
${dialog} ::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--bcsidebar-text) 30%, transparent);
border-radius: 999px;
}
`;
// Hide the mobile context nav with the same "Hide Navigation Bar" gate as the rail.
if (options.hide_navbar === true) {
style.textContent += `
#mobileContextNavContainer,
button.mobile-header-arrow {
display: none !important;
}
`;
}
if (!style.isConnected) document.documentElement.append(style);
}
// Re-apply the tinted content panel when the opacity or blur slider changes.
function applyBetterSidebarContentPanel() {
if (!options.better_sidebar) return;
if (getSidebarLayoutMode() !== "course") return;
const contentMain = getContentMainEl();
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");
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 = getContentMainEl();
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");
}
// Prepending keeps the order [rail][native nav], so the native column can't render left of it.
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";
}
// Only when "Hide Navigation Bar" is on.
if (options.hide_navbar === true) {
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;
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 nav badges: Canvas loads unread counts after render, so build-time dots go 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);
}
// Keep the sidebar dots in sync with the nav badges.
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;
// Nav buttons go in ahead of the search trigger so Search stays last.
const searchBtn = sidebarContent.querySelector("#canvasrefined-gs-sidebar-btn");
const insertNavButton = (text, href, icon) => {
const button = createSidebarButton(text, href, sidebarContent, icon);
if (searchBtn && searchBtn.parentNode === sidebarContent) {
sidebarContent.insertBefore(button, searchBtn);
}
return button;
};
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) {
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;
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 (width > 32 || height > 32) {
if (icon.includes('style="')) {
icon = icon.replace(/style="([^"]*)"/, `style="$1 width:20px;height:20px;flex-shrink:0;fill:var(--bcsidebar-text);stroke:var(--bcsidebar-text);"`);
} else {
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();
};
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 (ordinal skips 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++;
}
}
function dashboardNotesHtml() { return `
`; }
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) => {
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);
});
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
});
}
// Mount at the Widgets-tab position: top / cards_start / cards_end / below.
function placeDashboardNotes(notes, container) {
const pos = options.dashboard_notes_position || "top";
crApplyCardHeight("dashboard_notes", notes, pos === "cards_start" || pos === "cards_end");
if (pos === "top" || pos === "below") {
notes.classList.remove("in-cards");
const slot = notes.closest && notes.closest(".canvasrefined-widget-slot");
const row = slot && slot.parentNode;
if (notes.dataset.crPos === pos && row && row.dataset.area === pos && slot.dataset.widget === "dashboard_notes") return;
placeWidgetInRow("dashboard_notes", pos, [notes], container);
} else {
notes.classList.add("in-cards");
// Same card-grid container the GPA uses, resolved here for every entry path.
const grid = document.querySelector(".ic-DashboardCard__box__container") || container;
if (notes.dataset.crPos === pos && notes.parentNode === grid) return;
releaseWidgetFromRow("dashboard_notes", [notes], container);
if (pos === "cards_start") {
grid.prepend(notes);
} else {
grid.appendChild(notes);
}
applyCardWidgetOrder(grid);
try {
if (grid !== container && window.jQuery && window.jQuery.fn && window.jQuery.fn.sortable) {
window.jQuery(grid).sortable('refresh');
}
} catch (e) {}
}
notes.dataset.crPos = pos;
}
function loadDashboardNotes() {
const container = document.querySelector("#DashboardCard_Container");
if (options.dashboard_notes === true) {
if (!container) return;
let notes = document.querySelector('.canvasrefined-dashboard-notes');
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 = dashboardNotesHtml();
wireDashboardNotes(notes);
} else {
notes.style.display = "";
const editor = notes.querySelector(".canvasrefined-notes-editor");
const rendered = notes.querySelector(".canvasrefined-notes-rendered");
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 : "");
}
}
placeDashboardNotes(notes, container);
} else {
let notes = document.querySelector('.canvasrefined-dashboard-notes');
if (notes) {
notes.style.display = "none";
releaseWidgetFromRow("dashboard_notes", [notes]);
}
}
}
/*
Generic dashboard widgets (Focus Timer, Breathing Breaks, Daily Quote)
These share the placement/ordering machinery built for Dashboard Notes and the
GPA calculator, but render from one element each (see CR_SIMPLE_WIDGET_IDS).
*/
const CR_WIDGET_DEFAULT_POSITION = {
dashboard_notes: "top",
gpa_calc: "cards_end",
focus_timer: "cards_end",
breathing: "cards_end",
daily_quotes: "top",
quick_links: "cards_end",
heatmap: "below",
};
function crWidgetPosition(id) {
const v = options[id + "_position"];
if (v === "top" || v === "below" || v === "cards_start" || v === "cards_end") return v;
return CR_WIDGET_DEFAULT_POSITION[id] || "cards_end";
}
// Per-widget height in px (0 = auto) for cards_* placement; container mode uses row height.
function crApplyCardHeight(id, el, inCards) {
if (!el) return;
const h = parseInt(options[id + "_height"], 10);
if (inCards && Number.isFinite(h) && h > 0) {
el.style.height = h + "px";
el.style.minHeight = h + "px";
} else {
el.style.height = "";
el.style.minHeight = "";
}
}
function placeSimpleWidget(id, el, container) {
const pos = crWidgetPosition(id);
crApplyCardHeight(id, el, pos === "cards_start" || pos === "cards_end");
if (pos === "top" || pos === "below") {
el.classList.remove("in-cards");
el.classList.add("container-mode");
const slot = el.closest && el.closest(".canvasrefined-widget-slot");
const row = slot && slot.parentNode;
if (el.dataset.crPos === pos && row && row.dataset.area === pos && slot.dataset.widget === id) return;
placeWidgetInRow(id, pos, [el], container);
} else {
el.classList.add("in-cards");
el.classList.remove("container-mode");
const grid = document.querySelector(".ic-DashboardCard__box__container") || container;
if (el.dataset.crPos === pos && el.parentNode === grid) return;
releaseWidgetFromRow(id, [el], container);
if (pos === "cards_start") grid.prepend(el);
else grid.appendChild(el);
applyCardWidgetOrder(grid);
try {
if (grid !== container && window.jQuery && window.jQuery.fn && window.jQuery.fn.sortable) {
window.jQuery(grid).sortable('refresh');
}
} catch (e) {}
}
el.dataset.crPos = pos;
}
function crLoadWidget(id, spec) {
const existing = document.querySelector("." + spec.className);
if (options[id] !== true) {
if (existing) {
existing.style.display = "none";
releaseWidgetFromRow(id, [existing]);
}
return;
}
const container = document.querySelector("#DashboardCard_Container");
if (!container) return;
let el = existing;
if (!el) {
el = document.createElement("div");
el.className = "canvasrefined-widget-card " + spec.className;
el.dataset.crWidget = id;
el.innerHTML = spec.html;
try { if (spec.wire) spec.wire(el); } catch (e) { logError(e); }
} else {
el.style.display = "";
try { if (spec.update) spec.update(el); } catch (e) { logError(e); }
}
placeSimpleWidget(id, el, container);
}
/* ---- Shared timer engine (Focus Timer + Breathing Breaks) ---- */
const crTimers = {};
function crTimer(id) {
if (!crTimers[id]) crTimers[id] = { total: 0, remaining: 0, running: false, raf: null, endTime: 0 };
return crTimers[id];
}
// ceil, so a fresh 25:00 shows 24:59 only after a full second.
function crTimerFormat(sec) {
const s = Math.max(0, Math.ceil(sec - 0.001));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const r = s % 60;
if (h > 0) return h + ":" + String(m).padStart(2, "0") + ":" + String(r).padStart(2, "0");
return String(m).padStart(2, "0") + ":" + String(r).padStart(2, "0");
}
function crFormatDuration(sec) {
const s = Math.max(0, Math.round(sec));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const r = s % 60;
if (h && m) return h + getMsg("timer_unit_h", "h") + " " + m + getMsg("timer_unit_m", "m");
if (h) return h + getMsg("timer_unit_h", "h") + (r ? " " + r + getMsg("timer_unit_s", "s") : "");
if (m && r) return m + getMsg("timer_unit_m", "m") + " " + r + getMsg("timer_unit_s", "s");
if (m) return m + " " + getMsg("timer_unit_min", "min");
return r + getMsg("timer_unit_s", "s");
}
function crTimerCancel(st) {
if (st && st.raf) { cancelAnimationFrame(st.raf); st.raf = null; }
}
function crTimerRender(id, el) {
const st = crTimer(id);
const disp = el.querySelector(".cr-timer-time") || el.querySelector(".cr-timer-display");
if (disp) {
const txt = crTimerFormat(st.remaining);
if (disp.textContent !== txt) disp.textContent = txt;
}
const start = el.querySelector(".cr-timer-start");
if (start) {
const label = st.running ? getMsg("timer_pause", "Pause") : getMsg("timer_start", "Start");
if (start.textContent !== label) start.textContent = label;
}
el.classList.toggle("is-running", st.running);
const bar = el.querySelector(".cr-timer-progress-bar");
if (bar) bar.style.width = (st.total ? ((st.total - st.remaining) / st.total) * 100 : 0) + "%";
const presets = [...el.querySelectorAll(".cr-timer-preset[data-slot]")];
const totals = presets.map(b => parseInt(b.dataset.sec, 10));
const matched = totals.indexOf(st.total) !== -1;
presets.forEach((btn, i) => btn.classList.toggle("active", !st.running && totals[i] === st.total));
const customBtn = el.querySelector(".cr-timer-custom");
if (customBtn) customBtn.classList.toggle("active", !st.running && !matched && st.total > 0);
}
// rAF loop off real elapsed time, so the fill moves continuously.
function crTimerFrame(id, el) {
const st = crTimer(id);
if (!st.running) return;
st.remaining = Math.max(0, (st.endTime - Date.now()) / 1000);
if (st.remaining <= 0) {
st.remaining = 0;
st.running = false;
st.raf = null;
}
crTimerRender(id, el);
if (st.running) st.raf = requestAnimationFrame(() => crTimerFrame(id, el));
}
function crTimerStart(id, el) {
const st = crTimer(id);
if (st.running || st.remaining <= 0) return;
st.running = true;
st.endTime = Date.now() + st.remaining * 1000;
crTimerCancel(st);
st.raf = requestAnimationFrame(() => crTimerFrame(id, el));
crTimerRender(id, el);
}
function crTimerPause(id, el) {
const st = crTimer(id);
if (st.running) st.remaining = Math.max(0, (st.endTime - Date.now()) / 1000);
st.running = false;
crTimerCancel(st);
crTimerRender(id, el);
}
function crTimerSet(id, el, seconds) {
const st = crTimer(id);
st.running = false;
crTimerCancel(st);
st.total = Math.max(1, Math.round(seconds));
st.remaining = st.total;
crTimerRender(id, el);
}
function crTimerReset(id, el) {
const st = crTimer(id);
st.running = false;
crTimerCancel(st);
st.remaining = st.total;
crTimerRender(id, el);
}
function crTimerApplyCfg(el, cfg) {
el.querySelectorAll(".cr-timer-preset[data-slot]").forEach(btn => {
const slot = parseInt(btn.dataset.slot, 10);
const sec = cfg.presets[slot - 1];
btn.dataset.sec = sec;
btn.textContent = crFormatDuration(sec);
});
}
function crTimerHideCustom(el) {
const row = el.querySelector(".cr-timer-custom-row");
if (row) row.hidden = true;
}
function crTimerCustomSeconds(el) {
const num = (sel) => {
const v = parseInt((el.querySelector(sel) || {}).value, 10);
return Number.isFinite(v) && v > 0 ? v : 0;
};
return Math.min(99, num(".cr-tf-h")) * 3600
+ Math.min(59, num(".cr-tf-m")) * 60
+ Math.min(59, num(".cr-tf-s"));
}
function crTimerFillCustom(el, totalSec) {
const secs = Math.max(0, Math.round(totalSec || 0));
const put = (sel, v) => {
const inp = el.querySelector(sel);
if (inp) inp.value = String(v).padStart(2, "0");
};
put(".cr-tf-h", Math.min(99, Math.floor(secs / 3600)));
put(".cr-tf-m", Math.floor((secs % 3600) / 60));
put(".cr-tf-s", secs % 60);
}
// Listeners are wired once (update passes only refresh config labels).
function crTimerWireControls(id, el) {
el.querySelectorAll(".cr-timer-preset[data-slot]").forEach(btn => {
btn.addEventListener("click", () => {
const sec = parseInt(btn.dataset.sec, 10);
if (Number.isFinite(sec) && sec > 0) crTimerSet(id, el, sec);
crTimerHideCustom(el);
});
});
const customBtn = el.querySelector(".cr-timer-custom");
const row = el.querySelector(".cr-timer-custom-row");
const fields = [...el.querySelectorAll(".cr-timer-custom-row .cr-tf")];
const focusField = (i) => {
const inp = fields[i];
if (inp) { inp.focus(); inp.select(); }
};
const applyCustom = () => {
const sec = crTimerCustomSeconds(el);
if (sec <= 0) {
focusField(fields.length - 1);
return;
}
crTimerSet(id, el, sec);
crTimerHideCustom(el);
};
const openCustom = () => {
crTimerFillCustom(el, crTimer(id).total);
row.hidden = false;
const mi = fields.findIndex(f => f.classList.contains("cr-tf-m"));
focusField(mi === -1 ? 0 : mi);
};
if (customBtn && row) customBtn.addEventListener("click", () => {
if (row.hidden) openCustom();
else crTimerHideCustom(el);
});
fields.forEach((inp, i) => {
inp.addEventListener("focus", () => inp.select());
inp.addEventListener("input", () => {
inp.value = inp.value.replace(/[^0-9]/g, "").slice(0, 2);
if (inp.value.length >= 2 && i < fields.length - 1) focusField(i + 1);
});
inp.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); applyCustom(); }
else if (e.key === "Escape") { e.preventDefault(); crTimerHideCustom(el); }
else if ((e.key === "ArrowRight" || e.key === "Tab" && !e.shiftKey) && i < fields.length - 1) { e.preventDefault(); focusField(i + 1); }
else if ((e.key === "ArrowLeft" || e.key === "Tab" && e.shiftKey) && i > 0) { e.preventDefault(); focusField(i - 1); }
else if (e.key === "Backspace" && inp.value === "" && i > 0) { e.preventDefault(); focusField(i - 1); }
});
});
const go = el.querySelector(".cr-timer-custom-go");
if (go) go.addEventListener("click", applyCustom);
const start = el.querySelector(".cr-timer-start");
if (start) start.addEventListener("click", () => {
const st = crTimer(id);
if (st.running) crTimerPause(id, el);
else crTimerStart(id, el);
});
const reset = el.querySelector(".cr-timer-reset");
if (reset) reset.addEventListener("click", () => crTimerReset(id, el));
}
function crTimerInitial(id, el, cfg) {
const st = crTimer(id);
if (!st.total) { st.total = cfg.presets[1]; st.remaining = st.total; }
crTimerRender(id, el);
}
// Presets store {h,m,s}; a plain number is legacy minutes. `fallback` is seconds.
function crTimerReadSlots(prefix, fallback) {
return [1, 2, 3].map(i => {
const v = options[prefix + "_preset_" + i];
if (v && typeof v === "object") {
const h = Math.max(0, parseInt(v.h, 10) || 0);
const m = Math.max(0, parseInt(v.m, 10) || 0);
const s = Math.max(0, parseInt(v.s, 10) || 0);
const total = h * 3600 + m * 60 + s;
if (total > 0) return total;
} else {
const n = parseInt(v, 10);
if (Number.isFinite(n) && n > 0) return n * 60;
}
return fallback[i - 1];
});
}
/* ---- Focus Timer ---- */
const FOCUS_TIMER_DEFAULT_PRESETS = [15 * 60, 25 * 60, 45 * 60];
function focusTimerCfg() {
return { presets: crTimerReadSlots("focus_timer", FOCUS_TIMER_DEFAULT_PRESETS) };
}
function focusTimerHtml() { return `
${getMsg("focus_timer", "Focus Timer")}
25:00
::
`; }
function wireFocusTimer(el) {
crTimerApplyCfg(el, focusTimerCfg());
crTimerWireControls("focus_timer", el);
crTimerInitial("focus_timer", el, focusTimerCfg());
}
function updateFocusTimer(el) {
crTimerApplyCfg(el, focusTimerCfg());
crTimerRender("focus_timer", el);
}
function loadFocusTimer() {
crLoadWidget("focus_timer", {
className: "canvasrefined-focus-timer",
html: focusTimerHtml(),
wire: wireFocusTimer,
update: updateFocusTimer,
});
}
/* ---- Breathing Breaks (a zen timer) ---- */
const BREATHING_DEFAULT_PRESETS = [3 * 60, 5 * 60, 10 * 60];
function breathingCfg() {
return { presets: crTimerReadSlots("breathing", BREATHING_DEFAULT_PRESETS) };
}
function breathingHtml() { return `
`; }
function wireBreathingWidget(el) {
crTimerApplyCfg(el, breathingCfg());
crTimerWireControls("breathing", el);
crTimerInitial("breathing", el, breathingCfg());
}
function updateBreathingWidget(el) {
crTimerApplyCfg(el, breathingCfg());
crTimerRender("breathing", el);
}
function loadBreathingWidget() {
crLoadWidget("breathing", {
className: "canvasrefined-breathing",
html: breathingHtml(),
wire: wireBreathingWidget,
update: updateBreathingWidget,
});
}
/* ---- Daily Quote (stoic.tekloon.net, refreshed once a day) ---- */
function dailyQuoteDefault() {
return {
quote: getMsg("daily_quote_default", "The secret of getting ahead is getting started."),
author: "Mark Twain",
};
}
function dailyQuoteHtml() { return `
${getMsg("daily_quote", "Daily Quote")}
“
${dailyQuoteDefault().quote}
— ${dailyQuoteDefault().author}
`; }
function renderDailyQuote(el, data) {
if (!el) return;
const fallback = dailyQuoteDefault();
const quote = data && data.quote ? data.quote : fallback.quote;
const author = data && data.author ? data.author : fallback.author;
const textEl = el.querySelector(".cr-quote-text");
const authorEl = el.querySelector(".cr-quote-author");
if (textEl) textEl.textContent = quote;
if (authorEl) authorEl.textContent = author ? "\u2014 " + author : "";
}
// Ask the background worker to refresh; it caches per calendar day.
let dailyQuoteRequested = false;
function ensureDailyQuote() {
if (dailyQuoteRequested) return;
dailyQuoteRequested = true;
try {
chrome.runtime.sendMessage({ type: "getDailyQuote" }, () => {
// Reading lastError suppresses the warning; a later pass retries on failure.
if (chrome.runtime.lastError) dailyQuoteRequested = false;
});
} catch (e) {
dailyQuoteRequested = false;
}
}
function refreshDailyQuoteWidget(el) {
chrome.storage.local.get("daily_quote", st => {
if (st && st.daily_quote) renderDailyQuote(el, st.daily_quote);
});
ensureDailyQuote();
}
function loadDailyQuotesWidget() {
crLoadWidget("daily_quotes", {
className: "canvasrefined-quotes",
html: dailyQuoteHtml(),
wire: refreshDailyQuoteWidget,
update: refreshDailyQuoteWidget,
});
}
/* ---- Heatmap (every class combined: average grade / submissions per day) ---- */
// Same GitHub-style calendar as Grade Analytics' heatmap tab, but pooled across
// all active courses. Data comes from one assignments call per course with the
// submission inlined (due date, points, score and submitted_at in one response).
const CR_HEATMAP_CACHE_KEY = "heatmap_widget_cache_v1";
const CR_HEATMAP_REFRESH_MS = 30 * 60 * 1000; // refetch once the cache is older than this
const CR_HEATMAP_MAX_PAGES = 5; // 500 assignments per course, safety net
const CR_HEATMAP_PARALLEL = 4; // concurrent course requests (Canvas rate limits)
const CR_HM_CELL_MIN = 7; // cell size range: the grid scales to the card height
const CR_HM_CELL_MAX = 30;
const CR_HM_CELL_DEFAULT = 11;
const CR_HM_GAP = 3;
const CR_HM_MONTH_H = 15; // room for the month labels above the grid
const CR_HM_EMPTY = "color-mix(in srgb, var(--bctext-1,#e2e2e2) 20%, transparent)";
// Submission cells are the dark-mode link color at an opacity that grows with the
// day's count, over the same neutral box the grade tab uses for "nothing here".
const CR_HM_SUB_BASE = CR_HM_EMPTY;
const CR_HM_MUTED = "var(--bctext-2,#9aa3ab)";
const CR_HM_TIP_ROWS = 8;
let crHeatmapTab = "grades"; // active tab: "grades" | "subs"
let crHeatmapData = null; // built day buckets for all courses
let crHeatmapLoadedAt = 0; // when the shown data was fetched
let crHeatmapLoading = false;
let crHeatmapStarted = false; // cache read kicked off (once per page view)
function heatmapWidgetHtml() { return `
`; }
function wireHeatmapWidget(el) {
el.querySelectorAll("[data-cr-hm-tab]").forEach(btn => {
btn.addEventListener("click", () => {
const tab = btn.dataset.crHmTab;
if (!tab || tab === crHeatmapTab) return;
crHeatmapTab = tab === "subs" ? "subs" : "grades";
crHeatmapRender(el);
});
});
// The grid is sized from the card's height, so a resize means a rebuild.
crHeatmapObserver().observe(el);
updateHeatmapWidget(el);
}
let crHeatmapResizeObserver = null;
function crHeatmapObserver() {
if (!crHeatmapResizeObserver) {
crHeatmapResizeObserver = new ResizeObserver(entries => {
for (const entry of entries) crHeatmapRender(entry.target);
});
}
return crHeatmapResizeObserver;
}
function updateHeatmapWidget(el) {
crHeatmapEnsureData();
crHeatmapRender(el);
}
function loadHeatmapWidget() {
crLoadWidget("heatmap", {
className: "canvasrefined-heatmap",
html: heatmapWidgetHtml(),
wire: wireHeatmapWidget,
update: updateHeatmapWidget,
});
}
function crHeatmapDayStart(iso) {
if (!iso) return null;
const d = new Date(iso);
if (isNaN(d.getTime())) return null;
d.setHours(0, 0, 0, 0);
return d;
}
// Per-course submissions, only used when the assignments include is ignored.
async function crHeatmapFetchSubmissions(course) {
const url = `${domain}/api/v1/courses/${course.id}/students/submissions?student_ids[]=self&per_page=100`;
const list = await gsGetJsonPages(url, { maxPages: CR_HEATMAP_MAX_PAGES });
const map = new Map();
for (const sub of list) {
if (sub && sub.assignment_id != null) map.set(String(sub.assignment_id), sub);
}
return map;
}
// The name the user actually sees for a class: their Canvas Refined custom card name,
// then the Canvas nickname (the dashboard card title / API `name`), then the code. This
// mirrors the dashboard card title and the popup's card list. "Hide personal details"
// wins, like the GPA calculator does with course names.
function crCourseDisplayName(courseId, fallbackName) {
if (options.hide_personal_details === true && courseId != null && /^\d+$/.test(String(courseId))) {
return fakeCourseLabel("id:" + courseId);
}
const cards = options.custom_cards || {};
const card = courseId != null ? (cards[String(courseId)] || cards[courseId]) : null;
const custom = typeof card?.name === "string" ? card.name.trim() : "";
if (custom) return custom;
const full = typeof card?.fullName === "string" ? card.fullName.trim() : "";
if (full) return full;
return String(fallbackName || "").trim();
}
async function crHeatmapFetchCourse(course) {
const url = `${domain}/api/v1/courses/${course.id}/assignments?include[]=submission&per_page=100`;
const list = await gsGetJsonPages(url, { maxPages: CR_HEATMAP_MAX_PAGES });
// The label the user knows the class by, not its course code.
const label = crCourseDisplayName(course.id, course.name || course.course_code || "");
// `include[]=submission` is documented, but a few instances ignore it: when no
// assignment carries a submission key at all, ask the submissions endpoint.
let subs = null;
if (list.length && !list.some(a => a && Object.prototype.hasOwnProperty.call(a, "submission"))) {
subs = await crHeatmapFetchSubmissions(course).catch(() => null);
}
const out = [];
for (const a of list) {
if (!a || a.name == null) continue;
const sub = a.submission || (subs && subs.get(String(a.id))) || null;
out.push({
course: label,
// kept so cached items can be relabelled after a rename or an
// anonymity toggle, without refetching every course
courseId: String(course.id),
title: String(a.name),
due: a.due_at || null,
points: typeof a.points_possible === "number" && a.points_possible > 0 ? a.points_possible : 0,
score: sub && typeof sub.score === "number" ? sub.score : null,
excused: !!(sub && sub.excused === true),
submitted: sub && sub.submitted_at ? sub.submitted_at : null,
});
}
return out;
}
// Courses ticked in the widget's course filter (stored as ids; may be numbers).
function crHeatmapExcludedIds() {
const raw = options.heatmap_excluded_courses;
return new Set(Array.isArray(raw) ? raw.map(String) : []);
}
async function crHeatmapFetchAll() {
if (crHeatmapLoading) return;
crHeatmapLoading = true;
crHeatmapRenderAll();
try {
const courses = await gsFetchCourseList();
// Excluded courses are skipped here, so they cost no requests either.
const excluded = crHeatmapExcludedIds();
const items = [];
let next = 0;
const worker = async () => {
while (next < courses.length) {
const course = courses[next++];
if (excluded.has(String(course.id))) continue;
try {
const list = await crHeatmapFetchCourse(course);
for (const item of list) items.push(item);
} catch (e) {
// One unreachable course must not blank the whole widget.
}
}
};
const workers = [];
for (let i = 0; i < Math.min(CR_HEATMAP_PARALLEL, courses.length); i++) workers.push(worker());
await Promise.all(workers);
// Keep the previous data rather than drawing an empty grid on a dead session.
if (items.length) {
crHeatmapData = crHeatmapBuild(items);
crHeatmapLoadedAt = Date.now();
try {
chrome.storage.local.set({ [CR_HEATMAP_CACHE_KEY]: { at: crHeatmapLoadedAt, items } });
} catch (e) { /* cache write failure is non-fatal */ }
}
} catch (e) {
logError(e);
}
crHeatmapLoading = false;
crHeatmapRenderAll();
}
// Cache first (instant paint), then refresh in the background when stale.
function crHeatmapEnsureData() {
if (crHeatmapStarted) {
if (!crHeatmapLoading && Date.now() - crHeatmapLoadedAt > CR_HEATMAP_REFRESH_MS) crHeatmapFetchAll();
return;
}
crHeatmapStarted = true;
chrome.storage.local.get(CR_HEATMAP_CACHE_KEY, result => {
const cache = result && result[CR_HEATMAP_CACHE_KEY];
if (cache && Array.isArray(cache.items) && cache.items.length) {
crHeatmapData = crHeatmapBuild(cache.items);
crHeatmapLoadedAt = Number(cache.at) || 0;
}
crHeatmapRenderAll();
if (Date.now() - crHeatmapLoadedAt > CR_HEATMAP_REFRESH_MS) crHeatmapFetchAll();
});
}
// Buckets every course's work by day: grades by due date (like Grade Analytics),
// submissions by the day they were turned in.
function crHeatmapBuild(items) {
const byDay = new Map();
const range = { grades: { min: null, max: null }, subs: { min: null, max: null } };
// Cached items keep their course id, so a course excluded from the widget drops out
// of the grid right away instead of waiting for the next fetch.
const excluded = crHeatmapExcludedIds();
let maxCount = 0, subTotal = 0, undated = 0;
const cellFor = (d, kind) => {
const key = d.getTime();
let cell = byDay.get(key);
if (!cell) {
cell = { date: d, scores: [], gradeItems: [], subs: [] };
byDay.set(key, cell);
}
const r = range[kind];
if (!r.min || key < r.min.getTime()) r.min = d;
if (!r.max || key > r.max.getTime()) r.max = d;
return cell;
};
for (const item of items) {
if (item.courseId != null && excluded.has(String(item.courseId))) continue;
if (item.score != null && !item.excused && item.points > 0) {
const d = crHeatmapDayStart(item.due);
if (d) {
const pct = (item.score / item.points) * 100;
const cell = cellFor(d, "grades");
cell.scores.push(pct);
cell.gradeItems.push({ title: item.title, course: crCourseDisplayName(item.courseId, item.course), pct });
} else {
undated++;
}
}
if (item.submitted) {
const d = crHeatmapDayStart(item.submitted);
if (d) {
cellFor(d, "subs").subs.push({ title: item.title, course: crCourseDisplayName(item.courseId, item.course) });
subTotal++;
}
}
}
if (!byDay.size) return null;
for (const cell of byDay.values()) {
cell.avg = cell.scores.length ? cell.scores.reduce((a, b) => a + b, 0) / cell.scores.length : null;
cell.count = cell.subs.length;
if (cell.count > maxCount) maxCount = cell.count;
}
return { byDay, range, maxCount, subTotal, undated };
}
function crHeatmapTabRange(data) {
const r = data.range[crHeatmapTab === "subs" ? "subs" : "grades"];
return r && r.min && r.max ? r : null;
}
function crHeatmapRenderAll() {
document.querySelectorAll(".canvasrefined-heatmap").forEach(el => crHeatmapRender(el));
}
// Grades reuse the Grade Analytics zone palette; submissions paint the dark-mode
// link color, more opaque the more was turned in that day.
function crHeatmapCellStyle(cell, data) {
if (crHeatmapTab === "subs") {
const max = Math.max(1, data.maxCount);
const ratio = cell ? cell.count / max : 0;
const pct = Math.round(Math.max(0, Math.min(1, ratio)) * 1000) / 10;
const tint = `color-mix(in srgb, var(--bclinks,#56caf0) ${pct}%, transparent)`;
return `background-color:${CR_HM_SUB_BASE};background-image:linear-gradient(${tint},${tint});`;
}
return `background-color:${cell && cell.avg != null ? gaHeatmapColor(cell.avg) : CR_HM_EMPTY};`;
}
// Cell size that makes the seven weekday rows use the card's free height. Cells
// grow into surplus space, and only shrink when the card's height is actually fixed
// (a set widget/row height) — in a content-sized card the free height follows the
// grid, so shrinking would ratchet the map smaller on every pass.
function crHeatmapDefiniteHeight(el) {
if (el.style && el.style.height) return true;
const row = el.closest ? el.closest(".canvasrefined-widget-row") : null;
return !!(row && row.style.height);
}
function crHeatmapMetrics(el) {
const gap = CR_HM_GAP, monthH = CR_HM_MONTH_H;
const grid = el.querySelector(".cr-hm-grid");
const scroll = el.querySelector(".cr-hm-scroll");
const current = parseInt(grid && grid.dataset.crHmCell, 10) || CR_HM_CELL_DEFAULT;
// The scroll box is exactly the grid's available box: its clientHeight already
// drops padding and any horizontal scrollbar, so one measurement covers it all.
const avail = scroll ? scroll.clientHeight : 0;
const need = (c) => c * 7 + gap * 6 + monthH;
const slack = 2; // keep a hair of breathing room so rounding never clips a row
const fit = Math.floor((avail - slack - monthH - gap * 6) / 7);
const clamped = Number.isFinite(fit) ? Math.min(CR_HM_CELL_MAX, Math.max(CR_HM_CELL_MIN, fit)) : CR_HM_CELL_DEFAULT;
let cell = current;
// 0 = not laid out yet (or hidden); keep the default until there is a real box.
if (avail > 0) {
if (need(current) < avail - slack && clamped > current) cell = clamped;
else if (crHeatmapDefiniteHeight(el) && need(current) > avail - slack && clamped < current) cell = clamped;
}
return { cell, gap, monthH };
}
function crHeatmapShowTip(tip, e, cell) {
const date = cell.date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
const rows = [];
let head = "";
if (crHeatmapTab === "subs") {
head = `${date} — ${replaceCountPlaceholder(getMsg("cr_hm_tip_subs", "{{COUNT}} submitted"), cell.count)}`;
// One row per class, most submissions first.
const byCourse = new Map();
for (const s of cell.subs) {
const key = s.course || "—";
byCourse.set(key, (byCourse.get(key) || 0) + 1);
}
const entries = [...byCourse.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
entries.slice(0, CR_HM_TIP_ROWS).forEach(([course, n]) => {
rows.push(`
${gaEscHtml(course)}${n}
`);
});
if (entries.length > CR_HM_TIP_ROWS) rows.push(`
`; }
function updateQuickLinksWidget() {
const el = document.querySelector(".canvasrefined-quick-links");
if (!el) return;
// Don't rebuild while the user is typing in the editor (e.g. our own storage echo).
if (quickLinksUI.mode && el.querySelector(".cr-ql-editor")) return;
renderQuickLinks(el);
}
function loadQuickLinksWidget() {
if (options.quick_links !== true) quickLinksCloseEditor(false);
crLoadWidget("quick_links", {
className: "canvasrefined-quick-links",
html: quickLinksHtml(),
wire: wireQuickLinks,
update: updateQuickLinksWidget,
});
}
/*
Custom font
*/
// "Keep original assignment font": the custom rule's `*` selector hits
too, so
// carve
out and pin it to body's pre-custom font (`inherit` would still inherit).
let originalPageFontFamily = null;
const CANVAS_DEFAULT_FONT_STACK = '"Lato Extended", Lato, "Helvetica Neue", Helvetica, Arial, sans-serif';
function captureOriginalPageFont() {
if (originalPageFontFamily) return;
if (document.querySelector("#custom_font")) return;
try {
const family = getComputedStyle(document.body || document.documentElement).fontFamily;
if (family) originalPageFontFamily = family;
} catch (_) { /* keep fallback */ }
}
function originalPageFontOrFallback() {
return originalPageFontFamily || CANVAS_DEFAULT_FONT_STACK;
}
function customFontRules() {
if (options.custom_font_skip_p !== true) {
return `${customFontSelector()} {font-family: ${options.custom_font.family}!important}`;
}
return `${customFontSelector()} {font-family: ${options.custom_font.family}!important}\n`
+ `p, p * {font-family: ${originalPageFontOrFallback()}!important}`;
}
function customFontSelector() {
return options.custom_font_skip_p === true
? "*:not(p):not(p *), input, a, button, h1, h2, h3, h4, h5, h6, span"
: "*, input, a, button, h1, h2, h3, h4, h5, h6, p, span";
}
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 !== "") {
captureOriginalPageFont();
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 === "" ? "" : customFontRules();
}
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
*/
let aestheticDebounceTimer = null;
function debouncedApplyAestheticChanges(delay = 150) {
if (aestheticDebounceTimer) clearTimeout(aestheticDebounceTimer);
aestheticDebounceTimer = setTimeout(() => {
aestheticDebounceTimer = null;
applyAestheticChanges();
}, delay);
}
// In-card widgets take grid cells and rows, so the template has to be rebuilt when
// one is toggled, moved or resized.
function refreshCardGridLayout() {
if (options.card_grid === true) debouncedApplyAestheticChanges();
}
/*
Card Grid geometry (shared by the layout CSS and centerUnevenGridRows)
The grid has to know about every child that takes a cell or a row: the course
cards, the widgets placed Before/After cards (notes, focus timer, breathing,
quotes, quick links, heatmap) and the GPA summary card. The calculator itself
spans a full row when it is open inside the grid.
*/
const CARD_GRID_HOST = ".ic-DashboardCard__box__container";
// One card-sized cell: course cards, in-card widgets and the GPA summary card.
// (The GPA calculator `.canvasrefined-gpa` is not one: it spans a whole row.)
// `:not(.container-mode)` on the GPA keeps these rules above the card-style margins
// that follow them in the sheet.
const CARD_GRID_CELLS = [
`${CARD_GRID_HOST} > .ic-DashboardCard`,
`${CARD_GRID_HOST} > .canvasrefined-widget-card.in-cards`,
`${CARD_GRID_HOST} > .canvasrefined-dashboard-notes.in-cards`,
`${CARD_GRID_HOST} > .canvasrefined-gpa-card:not(.container-mode)`,
].join(",");
function isCardGridCell(el) {
if (!el || !el.classList) return false;
if (el.classList.contains("ic-DashboardCard") || el.classList.contains("canvasrefined-gpa-card")) return true;
return el.classList.contains("in-cards")
&& (el.classList.contains("canvasrefined-widget-card") || el.classList.contains("canvasrefined-dashboard-notes"));
}
// Enabled widgets rendered before/after the cards, i.e. inside the grid.
function cardGridWidgets() {
return CR_SIMPLE_WIDGET_IDS.map(id => ({ id, position: crWidgetPosition(id) }))
.concat([{ id: "dashboard_notes", position: crWidgetPosition("dashboard_notes") }])
.filter(w => options[w.id] === true
&& (w.position === "cards_start" || w.position === "cards_end"));
}
function cardGridMetrics() {
const cols = Math.max(1, parseInt(options.card_grid_columns, 10) || 4);
const rows = Math.max(1, parseInt(options.card_grid_rows, 10) || 3);
const colGap = Math.max(0, !isNaN(parseInt(options.card_grid_column_gap, 10)) ? parseInt(options.card_grid_column_gap, 10) : 12);
const rowGap = Math.max(0, !isNaN(parseInt(options.card_grid_row_gap, 10)) ? parseInt(options.card_grid_row_gap, 10) : 12);
const gpaPos = gpaCalcPosition();
const gpaInGrid = options.gpa_calc === true && (gpaPos === "cards_start" || gpaPos === "cards_end");
const widgets = cardGridWidgets();
// Every widget and the GPA card need a cell, and the calculator a whole row.
// Nothing may rely on implicit rows: the template clips them to zero height.
const extraRows = Math.ceil((widgets.length + (gpaInGrid ? 1 : 0)) / cols) + (gpaInGrid ? 1 : 0);
return {
cols,
rows,
colGap,
rowGap,
widgets,
startWidgets: widgets.filter(w => w.position === "cards_start"),
gpaInGrid,
gpaFirst: gpaPos === "cards_start",
extraRows,
totalRows: rows + extraRows,
};
}
function applyAestheticChanges() {
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}";
// Card Grid: strict columns x rows layout, emitted after center_cards to win the
// !important tie-break. Columns are doubled into sub-columns (each cell spans 2) so
// "center uneven rows" can shift a partial row by half a card; spare rows keep the
// in-grid widgets (notes, timers, quotes, links, heatmap, GPA card/calculator) out
// of the clipped implicit rows.
if (options.card_grid === true) {
const grid = cardGridMetrics();
const gridCols = grid.cols;
const gridColGap = grid.colGap;
const gridRowGap = grid.rowGap;
const centerGrid = options.center_cards === true;
const flexGrid = options.card_grid_flex === true;
// content.css keeps this container a flex row whenever an in-card widget exists
// (so widgets line up with Canvas' loading skeletons). Those selectors carry more
// specificity than a bare class, so they are repeated here: grid mode has to
// out-specify that fallback instead of silently losing to it.
const gridHost = [
CARD_GRID_HOST,
`${CARD_GRID_HOST}:has(> .canvasrefined-widget-card.in-cards)`,
`${CARD_GRID_HOST}:has(> .canvasrefined-dashboard-notes.in-cards)`,
`${CARD_GRID_HOST}:has(> .canvasrefined-gpa-card:not(.container-mode))`,
].join(",");
// strip Canvas' natural top/left card margins; the grid supplies its own gaps
style.textContent += `${CARD_GRID_HOST}{margin-top:0!important;margin-left:0!important}`;
// CanvasRefined's own widgets carry 36px outer margins meant for the flex
// card flow (plus the card-style spacing); in the grid every side is spacing
// the gaps already provide
style.textContent += `${CARD_GRID_CELLS}{margin:0!important}`;
style.textContent += `${CARD_GRID_HOST} > .canvasrefined-gpa{margin:0!important}`;
if (flexGrid) {
// flexible grid: fixed-width tracks; @container queries step the column count
// down when the next column stops fitting (no window-width guessing)
// content-box card padding grows the cards past the set width, so the track
// (and the widget cards in it) has to follow
const cardPad = options.customCardStyles === true ? Math.max(0, parseInt(options.cardPadding, 10) || 0) : 0;
const cardW = (options.customCardStyles === true && parseInt(options.cardWidth, 10) > 0
? parseInt(options.cardWidth, 10)
: 262) + 2 * cardPad;
// container queries need a size container; the card wrapper is full-width
style.textContent += `#DashboardCard_Container{container-type:inline-size}`;
style.textContent += `${gridHost}{display:grid!important;grid-template-columns:repeat(${gridCols},${cardW}px)!important;grid-auto-rows:auto!important;column-gap:${gridColGap}px!important;row-gap:${gridRowGap}px!important;align-items:start!important${centerGrid ? ";justify-content:center!important" : ""}}`;
// grid gaps alone control spacing (beats the later customCardStyles margins)
style.textContent += `${CARD_GRID_CELLS}{margin-right:0!important;margin-bottom:0!important}`;
if (grid.gpaInGrid) {
// Pin only while the GPA is alone in that slot: with another before-card
// widget present, auto-placement in DOM order follows the saved widget
// order, which the pin would override.
if (grid.gpaFirst && !grid.startWidgets.length) {
style.textContent += `${CARD_GRID_HOST} > .canvasrefined-gpa-card{grid-row:1!important;grid-column:1!important}`;
style.textContent += `${CARD_GRID_HOST} > .canvasrefined-gpa{grid-row:2!important;grid-column:1/-1!important}`;
} else {
// auto-placed: flows into the next open slot after the last card
style.textContent += `${CARD_GRID_HOST} > .canvasrefined-gpa{grid-column:1/-1!important}`;
}
}
// step down to m columns once m+1 no longer fit; widest-first so the
// narrowest matching rule wins the cascade
for (let m = gridCols - 1; m >= 1; m--) {
const tooWideFor = (m + 1) * cardW + m * gridColGap;
if (m === 1) {
style.textContent += `@container (max-width:${tooWideFor - 1}px){${gridHost}{grid-template-columns:minmax(0,1fr)!important}${CARD_GRID_CELLS}{width:100%!important;min-width:0!important}}`;
} else {
style.textContent += `@container (max-width:${tooWideFor - 1}px){${gridHost}{grid-template-columns:repeat(${m},${cardW}px)!important}}`;
}
}
} else {
style.textContent += `${gridHost}{display:grid!important;grid-template-columns:repeat(${gridCols * 2},minmax(0,max-content))!important;grid-template-rows:repeat(${grid.totalRows},minmax(0,auto))!important;grid-auto-rows:0!important;column-gap:${gridColGap}px!important;row-gap:${gridRowGap}px!important;align-items:start!important;overflow:hidden!important${centerGrid ? ";justify-content:center!important" : ""}}`;
// every cell-sized child spans one card (two sub-columns)
style.textContent += `${CARD_GRID_CELLS}{grid-column:span 2!important}`;
if (grid.gpaInGrid) {
// GPA card pinned to the first slot, or auto-placed after the cards; the
// expanded calculator always takes a full-width row
if (grid.gpaFirst && !grid.startWidgets.length) {
style.textContent += `${CARD_GRID_HOST} > .canvasrefined-gpa-card{grid-row:1!important;grid-column:1/span 2!important}`;
style.textContent += `${CARD_GRID_HOST} > .canvasrefined-gpa{grid-row:2!important;grid-column:1/-1!important}`;
} else {
style.textContent += `${CARD_GRID_HOST} > .canvasrefined-gpa{grid-column:1/-1!important}`;
}
}
}
}
if (options.customCardStyles === true) {
// CanvasRefined's own widget cards are card faces too, so the card width, padding,
// roundness and spacing settings reach them as well. Only the faces that sit in
// the card flow are resized/spaced: a Top/Bottom row widget is sized by the
// Widgets tab, so it just inherits the face styling.
const widgetFace = ".canvasrefined-widget-card, .canvasrefined-dashboard-notes, .canvasrefined-gpa-card";
const widgetCard = ".canvasrefined-widget-card.in-cards, .canvasrefined-dashboard-notes.in-cards, .canvasrefined-gpa-card:not(.container-mode)";
const cardRoundness = parseInt(options.cardRoundness, 10);
const cardSpacing = parseInt(options.cardSpacing, 10);
const cardWidth = parseInt(options.cardWidth, 10);
const cardPadding = parseInt(options.cardPadding, 10);
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;}`;
// widgets default to 4px, cards to 5px: follow the setting instead of the stock value
if (Number.isFinite(cardRoundness) && cardRoundness !== 5) style.textContent += `${widgetFace} {border-radius: ${cardRoundness}px!important;}`;
// the hero overlay sits on top of the photo, so both need the radius — rounding
// one alone leaves the other's square corners showing
if (options.imageRoundness !== undefined && options.imageRoundness !== 0) style.textContent += `.ic-DashboardCard__header_image, .ic-DashboardCard__header_hero {border-radius: ${options.imageRoundness}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 (Number.isFinite(cardSpacing) && cardSpacing !== 0) style.textContent += `${widgetCard} {margin-right: ${cardSpacing / 2}px!important; margin-bottom: ${cardSpacing / 2}px!important;}`;
if (options.cardWidth !== undefined && options.cardWidth !== 262) style.textContent += `.ic-DashboardCard {width: ${options.cardWidth}px!important;}`;
if (Number.isFinite(cardWidth) && cardWidth > 0 && cardWidth !== 262) style.textContent += `${widgetCard} {width: ${cardWidth}px!important;}`;
// card height goes on the hero band (146px stock): the card and header are
// content-sized, so a height on either just clips or adds dead space
if (options.condensed_cards !== true && options.cardHeight !== undefined && options.cardHeight !== null && options.cardHeight !== "") {
style.textContent += `.ic-DashboardCard__header_hero {height: ${options.cardHeight}px!important;}`;
}
// padding on the card box; content-box so it grows the card instead of squishing
// the content (Canvas cards are border-box with a fixed width)
if (options.cardPadding !== undefined && Number(options.cardPadding) > 0) {
style.textContent += `.ic-DashboardCard {padding: ${options.cardPadding}px!important; box-sizing: content-box!important;}`;
}
if (Number.isFinite(cardPadding) && cardPadding > 0) {
style.textContent += `${widgetFace} {padding: ${cardPadding}px!important; box-sizing: border-box!important;}`;
// fixed-width faces grow like Canvas cards; a row widget must stay inside its slot
style.textContent += `${widgetCard} {box-sizing: content-box!important;}`;
}
}
// margins always cleared so the bar lines up with the content column; hiding it is opt-in
style.textContent += ".ic-app-nav-toggle-and-crumbs{margin-left:0!important;margin-right:0!important}";
if (options.hide_navbar === true) style.textContent += ".ic-app-nav-toggle-and-crumbs{display:none!important}";
if (options.custom_styles !== "") style.textContent += options.custom_styles;
document.documentElement.appendChild(style);
// after the style lands; clears stale offsets so turning the grid off needs no refresh
requestAnimationFrame(centerUnevenGridRows);
}
/*
Center uneven grid rows.
CSS grid always packs rows from the left, so a last row with fewer cards
than the chosen column count sits flush-left while the rows above are full.
Pure CSS can't center a partial row, so we offset the first cell of the last
partial row with grid-column-start.
The grid runs on double sub-columns (each cell spans 2 — see
applyAestheticChanges), which is what makes this possible at all: with 3
cards under a 4-card row the leftover space is one column, and centering
needs a HALF-column shift — impossible on plain column lines. On sub-columns
the shift is a whole sub-column (half a card + half a gap), so every
remainder can be centered exactly.
In-card widgets are cells just like cards, so they count here too (a grid
filled by cards plus widgets has a partial last row all the same). Rows are
detected from the rendered boxes, not from a flat index: a full-width
calculator row (or a hidden card) leaves holes that index math would read as
the wrong row.
Only runs while Card Grid is on. Skips when the cards overflow the explicit
template (those extra rows are clipped anyway, and the last visible row is
full). Any previously applied offset is cleared first so re-runs (card
hidden/unhidden, dashboard re-render, setting changes) start clean.
*/
function centerUnevenGridRows() {
const container = document.querySelector(".ic-DashboardCard__box__container");
if (!container) return;
// DOM order matches auto-placement order, so this list is in visual order
const items = Array.from(container.children).filter(isCardGridCell);
items.forEach(el => el.style.removeProperty("grid-column"));
if (options.card_grid !== true || options.card_grid_center_rows !== true) return;
// flexible grid changes the column count live, so fixed offsets would be wrong
if (options.card_grid_flex === true) return;
const grid = cardGridMetrics();
const cols = grid.cols;
const visible = items.filter(el => getComputedStyle(el).display !== "none");
const count = visible.length;
// overflow past the template is clipped and the last visible row is full — nothing to center
const gpaExpanded = options.gpa_calc === true && (() => {
const expanded = container.querySelector(":scope > .canvasrefined-gpa");
return !!expanded && getComputedStyle(expanded).display !== "none";
})();
const capacity = cols * grid.totalRows - (gpaExpanded ? cols : 0);
if (count === 0 || count > capacity) return;
// Group by rendered row: cells of one row share a top edge.
const rows = [];
visible.forEach(el => {
const top = Math.round(el.getBoundingClientRect().top);
const row = rows.find(r => r.top === top);
if (row) row.els.push(el);
else rows.push({ top, els: [el] });
});
const lastRow = rows[rows.length - 1];
const inLastRow = lastRow ? lastRow.els.length : 0;
if (inLastRow >= cols) return;
// half the leftover, in sub-columns, paired with an explicit span; !important because
// the stylesheet span rule is !important too
const offset = cols - inLastRow;
if (offset > 0) lastRow.els[0].style.setProperty("grid-column", `${offset + 1} / span 2`, "important");
}
/*
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) {
// never inject into
: it would land outside the content area
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: chrome.i18n.getMessage("quiz_safe_title") || "Canvas Refined — Quiz Safe Mode",
});
makeElement("p", banner, {
className: "canvasrefined-quiz-safe-info",
textContent: chrome.i18n.getMessage("quiz_safe_desc") || "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: chrome.i18n.getMessage("quiz_enable") || "Enable Quiz Safe Mode",
});
const dismissBtn = makeElement("button", toggleRow, {
className: "canvasrefined-quiz-safe-dismiss",
type: "button",
textContent: chrome.i18n.getMessage("quiz_dont_show") || "Don't show again",
title: chrome.i18n.getMessage("quiz_dont_show_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;
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 every course's modules, items and assignments, then jump
// to them. Triggered by the floating button or Ctrl/Cmd+K.
// Cost model: Canvas has no cross-course search API, so the index is assembled from
// /courses -> (/assignments + /modules?include[]=items). Items are returned inline by
// Canvas where it supports it, which keeps a full build at ~2 requests per course
// instead of one per module. The result is cached per host in chrome.storage.local,
// so reopening search costs zero requests; stale courses are topped up a few at a
// time while the tab idles, and "Refresh" forces a full network rebuild.
const GS_CACHE_VERSION = 2;
const GS_CACHE_KEY_PREFIX = "canvasrefined_gs_cache_";
const GS_CACHE_MAX_AGE = 14 * 24 * 60 * 60 * 1000; // drop a cache untouched for 2 weeks
const GS_COURSE_TTL = 30 * 60 * 1000; // course content is fresh for 30 min
const GS_COURSELIST_TTL = 15 * 60 * 1000; // enrollment list is fresh for 15 min
const GS_REFRESH_PER_LOAD = 6; // stale courses topped up per page load
const GS_CONCURRENCY = 12; // parallel requests while indexing
const GS_MAX_COURSES = 60;
const GS_MAX_PAGES = 10; // pagination safety net
const GS_BUILD_LOCK = "canvasrefined-gs-build";
// Persisted shape (keyed per host):
// { v, updatedAt, courses: { "": { n: name, c: code, at: fetchedAt,
// e: [[kind, title, urlPath], ...] } } }
let gsCache = null;
let gsCacheLoaded = false;
let gsCachePromise = null;
let gsEntries = []; // live flat index, appended per course as it lands
let gsEntriesByCourse = new Map(); // "" -> its entries, so a course can be replaced
let gsSeenUrls = new Set(); // dedupe across courses by normalized URL
let gsBuildPromise = null;
let gsTopUpPromise = null; // background stale-course refresh, if any
let gsBuiltOnce = false;
let gsRequests = 0; // request counter for the debug summary
let gsRateLimitedUntil = 0; // throttle gate shared by all workers
let gsOnIndexUpdated = null; // modal hook: rerun the current query
let _gsPrewarmScheduled = false;
let _gsShortcutBound = false;
function setupGlobalSearch() {
if (options.global_search !== true) return;
ensureGlobalSearchButton();
ensureGlobalSearchShortcut();
gsSchedulePrewarm();
}
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;
}
}
const GLOBAL_SEARCH_ICON_SVG = ``;
// One trigger per active left sidebar (Better Sidebar or native nav), plus a
// dashboard header button; a rAF-debounced observer re-places them.
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;
ensureGlobalSearchNativeSidebarButton();
const betterSidebar = document.getElementById("better-sidebar-container");
if (betterSidebar) {
ensureGlobalSearchBetterSidebarButton(betterSidebar);
} else {
removeGlobalSearchBetterSidebarButton();
}
const headerActions = document.querySelector(".ic-Dashboard-header__actions");
if (isDashboardPage() && headerActions) {
ensureGlobalSearchHeaderButton(headerActions);
} else {
document.getElementById("canvasrefined-global-search-header-btn")?.remove();
}
}
function ensureGlobalSearchBetterSidebarButton(betterSidebar) {
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 = getMsg("search_canvas_ctrlk", "Search Canvas (Ctrl+K)");
btn.setAttribute("role", "button");
btn.setAttribute("aria-label", getMsg("search_canvas", "Search Canvas"));
const searchLabel = getMsg("search", "Search"); 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}${searchLabel}`;
btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); openGlobalSearchModal(); });
sidebarContent.appendChild(btn);
// Match the sidebar's current state now so the button doesn't render wrong
// until the next updateSidebar().
applyGlobalSearchSidebarButtonMode(btn, betterSidebar.dataset.expanded === "true");
}
function removeGlobalSearchBetterSidebarButton() {
document.getElementById("canvasrefined-gs-sidebar-btn")?.remove();
}
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)";
});
}
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 = chrome.i18n.getMessage("search_canvas_ctrlk") || "Search Canvas (Ctrl+K)";
link.setAttribute("aria-label", chrome.i18n.getMessage("search_canvas") || "Search Canvas");
link.innerHTML = `${GLOBAL_SEARCH_ICON_SVG}${chrome.i18n.getMessage("search") || "Search"}`;
link.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); openGlobalSearchModal(); });
li.appendChild(link);
navMenu.appendChild(li);
}
function removeGlobalSearchNativeSidebarButton() {
document.getElementById("canvasrefined-gs-nav-item")?.remove();
}
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 = chrome.i18n.getMessage("search_canvas_ctrlk") || "Search Canvas (Ctrl+K)";
btn.setAttribute("aria-label", chrome.i18n.getMessage("search_canvas") || "Search Canvas");
btn.innerHTML = `${chrome.i18n.getMessage("search") || "Search"}`;
btn.addEventListener("click", openGlobalSearchModal);
// First child of the actions row, just left of the ⋯ button.
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 modal.
if (!(e.ctrlKey || e.metaKey) || !(e.key === "k" || e.key === "K")) return;
// Not on quiz pages. Read the URL live: 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 modKey = /Mac|iPhone|iPad/.test(navigator.platform) ? "\u2318" : "Ctrl";
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(); });
// Capture phase so we win over the global Ctrl+K handler.
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);
}
});
input.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) e.stopPropagation();
});
const applyResults = (res) => {
currentResults = res;
selected = res.length ? 0 : -1;
renderGlobalSearchSelection(resultsEl, selected);
};
let debounce = null;
const rerun = () => {
if (document.getElementById("canvasrefined-global-search-modal") !== modal) return;
clearTimeout(debounce);
runGlobalSearch(input.value.trim(), resultsEl).then(applyResults);
};
input.addEventListener("input", () => {
clearTimeout(debounce);
debounce = setTimeout(rerun, 120);
});
modal.querySelector("#canvasrefined-gs-refresh")?.addEventListener("click", () => {
gsForceRefresh();
gsUpdateStatus(true);
});
// Results that land late (cold build, background top-up) should show up without
// the user retyping.
gsOnIndexUpdated = () => {
if (input.value.trim()) rerun();
gsUpdateStatus(true);
};
requestAnimationFrame(() => input.focus());
ensureGlobalSearchIndex();
gsUpdateStatus(true);
resultsEl.innerHTML = `
${getMsg("search_hint", "Start typing to search your modules and assignments. Use @coursename to search a specific course, or @grades to open a course's grades page.")}
`;
}
function closeGlobalSearchModal() {
const modal = document.getElementById("canvasrefined-global-search-modal");
gsOnIndexUpdated = null;
if (!modal) return;
modal.remove();
}
function openGlobalSearchResult(item, newTab) {
if (!item || !item.url) return;
if (newTab) {
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");
});
}
function gsCacheKey() {
return GS_CACHE_KEY_PREFIX + location.host;
}
function gsSleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// chrome.storage supports both callbacks and promises depending on browser and
// version; wrap either shape so the cache path can't hang.
function gsStorageGet(key) {
return new Promise(resolve => {
try {
const maybe = chrome.storage.local.get(key, result => resolve(result || {}));
if (maybe && typeof maybe.then === "function") {
maybe.then(r => resolve(r || {}), () => resolve({}));
}
} catch (_) { resolve({}); }
});
}
function gsStorageSet(value) {
return new Promise(resolve => {
try {
const maybe = chrome.storage.local.set(value, () => resolve());
if (maybe && typeof maybe.then === "function") maybe.then(() => resolve(), () => resolve());
} catch (_) { resolve(); }
});
}
async function gsReadCache() {
const key = gsCacheKey();
const stored = (await gsStorageGet(key))[key];
if (!stored || stored.v !== GS_CACHE_VERSION || !stored.courses) return null;
const now = Date.now();
const courses = {};
let kept = 0;
for (const [cid, course] of Object.entries(stored.courses)) {
if (!course || !course.n || !Array.isArray(course.e)) continue;
if (now - (course.at || 0) > GS_CACHE_MAX_AGE) continue;
courses[cid] = course;
kept++;
}
if (!kept) return null;
return {
v: GS_CACHE_VERSION,
updatedAt: stored.updatedAt || 0,
courseList: stored.courseList && Array.isArray(stored.courseList.items) ? stored.courseList : null,
courses
};
}
async function gsEnsureCacheLoaded() {
if (gsCacheLoaded) return gsCache;
if (gsCachePromise) return gsCachePromise;
gsCachePromise = (async () => {
gsCache = await gsReadCache();
gsCacheLoaded = true;
return gsCache;
})();
try {
return await gsCachePromise;
} finally {
gsCachePromise = null;
}
}
// Merge rather than overwrite: Canvas is often open in several tabs and every tab
// shares this cache, so a per-course entry is only ever replaced by a newer one.
async function gsPersistCache() {
if (!gsCache || !gsCache.courses) return;
try {
const key = gsCacheKey();
const stored = (await gsStorageGet(key))[key];
if (stored && stored.v === GS_CACHE_VERSION && stored.courses) {
for (const [cid, course] of Object.entries(stored.courses)) {
const mine = gsCache.courses[cid];
if (!mine || (course.at || 0) > (mine.at || 0)) gsCache.courses[cid] = course;
}
const theirs = stored.courseList;
if (theirs && (!gsCache.courseList || (theirs.at || 0) > (gsCache.courseList.at || 0))) {
gsCache.courseList = theirs;
}
}
gsCache.updatedAt = Date.now();
await gsStorageSet({ [key]: gsCache });
} catch (_) { /* cache write is best-effort */ }
}
async function gsMergeFromStorage() {
const fresh = await gsReadCache();
if (!fresh || !gsCache) return;
for (const [cid, course] of Object.entries(fresh.courses)) {
const mine = gsCache.courses[cid];
if (!mine || (course.at || 0) > (mine.at || 0)) gsCache.courses[cid] = course;
}
if (fresh.courseList && (!gsCache.courseList || (fresh.courseList.at || 0) > (gsCache.courseList.at || 0))) {
gsCache.courseList = fresh.courseList;
}
}
// Cached URLs are stored as paths so the cache is small and host-independent
// within one Canvas instance.
function gsRelUrl(url) {
try {
const u = new URL(url, domain);
if (u.origin === domain) return u.pathname + u.search;
return u.href;
} catch (_) {
return String(url || "");
}
}
function gsAbsUrl(url) {
const s = String(url || "");
if (!s) return "";
if (/^https?:/i.test(s)) return s;
return domain + (s.charAt(0) === "/" ? s : "/" + s);
}
const GS_KIND_CLASS = {
course: "course",
module: "module",
assignment: "assignment",
quiz: "quiz",
discussion_topic: "discussion",
discussion: "discussion",
page: "page",
file: "file",
externalurl: "link",
externaltool: "external-tool",
grades: "grades",
home: "home",
};
function gsKindClass(kind) {
return GS_KIND_CLASS[kind] || String(kind || "item").toLowerCase().replace(/[^a-z0-9-]/g, "");
}
function gsKindLabel(kind) {
if (kind === "course") return getMsg("search_type_course", "Course");
if (kind === "module") return getMsg("search_type_module", "Module");
if (kind === "grades") return getMsg("search_type_grades", "Grades");
if (kind === "home") return getMsg("search_type_home", "Home");
return prettyModuleItemType(kind);
}
function gsKindFromItemType(type) {
const t = (type || "").toLowerCase();
if (t === "discussion") return "discussion_topic";
return t || "item";
}
// Entries carry their lowercase forms and display label so a keystroke only has to
// compare strings (the index is rebuilt in memory once per page, not per keypress).
function gsMakeEntry(kind, title, course, courseCode, courseId, url) {
const t = String(title || "");
const c = String(course || "");
return {
kind,
cls: gsKindClass(kind),
type: gsKindLabel(kind),
title: t,
course: c,
courseCode: courseCode || c,
courseId,
url,
_t: t.toLowerCase(),
_c: c.toLowerCase()
};
}
function gsRemoveCourseEntries(courseId) {
const old = gsEntriesByCourse.get(courseId);
if (!old || !old.length) return;
const drop = new Set(old);
gsEntries = gsEntries.filter(entry => !drop.has(entry));
for (const entry of old) gsSeenUrls.delete(normalizeGlobalSearchUrl(entry.url));
gsEntriesByCourse.delete(courseId);
}
function gsAddCourseEntries(courseId, name, courseCode, tuples) {
gsRemoveCourseEntries(courseId);
const list = [];
const push = (kind, title, url) => {
const absolute = gsAbsUrl(url);
const key = normalizeGlobalSearchUrl(absolute);
if (!key || gsSeenUrls.has(key)) return;
gsSeenUrls.add(key);
const entry = gsMakeEntry(kind, title, name, courseCode, courseId, absolute);
gsEntries.push(entry);
list.push(entry);
};
if (name) push("course", name, `/courses/${courseId}`);
for (const tuple of tuples || []) {
if (Array.isArray(tuple)) push(tuple[0], tuple[1], tuple[2]);
}
gsEntriesByCourse.set(courseId, list);
}
// courses === null rebuilds from everything cached (used before the course list
// arrives, so search works while that request is still in flight).
function gsLoadFromCache(courses) {
gsEntries = [];
gsEntriesByCourse = new Map();
gsSeenUrls = new Set();
const cachedCourses = (gsCache && gsCache.courses) || {};
if (courses) {
for (const course of courses) {
const cached = cachedCourses[String(course.id)];
if (cached) gsAddCourseEntries(String(course.id), cached.n, cached.c || cached.n, cached.e);
}
} else {
for (const [cid, cached] of Object.entries(cachedCourses)) {
gsAddCourseEntries(cid, cached.n, cached.c || cached.n, cached.e);
}
}
}
// One request with retry. Canvas throttles with 429 — or 403 "Rate Limit Exceeded"
// — and costs are per token, so a burst of 60 courses would otherwise lose courses.
async function gsFetchOnce(url, attempt) {
const gate = gsRateLimitedUntil - Date.now();
if (gate > 0) await gsSleep(Math.min(gate, 4000));
let response;
try {
response = await pageFetch(url, {
method: "GET",
headers: { "Accept": "application/json" }
});
} catch (e) {
if (attempt < 2) {
await gsSleep(300 * (attempt + 1));
return gsFetchOnce(url, attempt + 1);
}
throw e;
}
gsRequests++;
const remainingHeader = response.headers.get("X-Rate-Limit-Remaining");
const remaining = remainingHeader === null ? NaN : Number(remainingHeader);
if (Number.isFinite(remaining) && remaining <= 50) gsRateLimitedUntil = Date.now() + 1500;
if (response.ok) {
const data = await response.json();
return { data, response };
}
let retryable = response.status === 429 || response.status >= 500;
if (response.status === 403) {
let body = "";
try { body = await response.text(); } catch (_) { /* ignore */ }
retryable = /rate.?limit/i.test(body);
}
if (retryable && attempt < 2) {
const retryAfter = Number(response.headers.get("Retry-After"));
const delay = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: 400 * Math.pow(2, attempt);
gsRateLimitedUntil = Math.max(gsRateLimitedUntil, Date.now() + delay);
await gsSleep(Math.min(delay, 8000));
return gsFetchOnce(url, attempt + 1);
}
throw new Error(`Canvas API request failed (${response.status})`);
}
async function gsGetJsonPages(url, options_) {
const maxPages = (options_ && options_.maxPages) || GS_MAX_PAGES;
const out = [];
let next = url;
for (let page = 0; page < maxPages && next; page++) {
const { data, response } = await gsFetchOnce(next, 0);
if (Array.isArray(data)) out.push(...data);
next = response && response.headers ? getNextPageUrl(response.headers.get("Link")) : null;
}
return out;
}
async function gsFetchCourseList() {
const raw = await gsGetJsonPages(`${domain}/api/v1/courses?enrollment_state=active&per_page=100`, { maxPages: 3 });
const now = Date.now();
const seen = new Set();
const courses = [];
for (const course of raw) {
if (!course || !course.id || !course.name) continue;
// Some institutions report past-term courses as active.
if (course.access_restricted_by_date === true) continue;
if (course.concluded === true) continue;
const endAt = (course.term && course.term.end_at) || course.end_at;
if (endAt) {
const end = new Date(endAt).getTime();
if (!isNaN(end) && end < now) continue;
}
// Canvas can repeat a course (multi-role, cross-listed) — dedupe by id.
const id = String(course.id);
if (seen.has(id)) continue;
seen.add(id);
courses.push(course);
if (courses.length >= GS_MAX_COURSES) break;
}
return courses;
}
// Two requests per course in the common case: assignments, and modules with their
// items inlined. Canvas may omit `items` (it is explicitly best-effort), and only
// then do we pay for per-module requests.
async function gsFetchCourse(course) {
const courseId = String(course.id);
const name = course.name;
const courseCode = course.course_code || name;
const tuples = [];
const seen = new Set();
const add = (kind, title, url, dedupeKey) => {
if (!title || !url) return;
const key = dedupeKey || `url:${normalizeGlobalSearchUrl(gsAbsUrl(url))}`;
if (seen.has(key)) return;
seen.add(key);
tuples.push([kind, String(title), gsRelUrl(url)]);
};
const settle = (url) => gsGetJsonPages(url).then(data => ({ ok: true, data }), () => ({ ok: false, data: [] }));
const [assignmentsResult, modulesResult] = await Promise.all([
settle(`${domain}/api/v1/courses/${courseId}/assignments?per_page=100`),
settle(`${domain}/api/v1/courses/${courseId}/modules?include[]=items&per_page=100`)
]);
const assignments = assignmentsResult.data;
const modules = modulesResult.data;
// Nothing came back (expired session, outage): keep the previous cache entry
// rather than overwrite it with an empty course.
if (!assignmentsResult.ok && !modulesResult.ok) {
throw new Error(`course ${courseId} unavailable`);
}
// A half-fetched course is still useful but must not look fresh.
const complete = assignmentsResult.ok && modulesResult.ok;
// Assignments first so their direct URL wins over a module-item copy.
for (const assignment of assignments) {
if (!assignment || !assignment.name || !assignment.html_url) continue;
add("assignment", assignment.name, assignment.html_url, `asn:${assignment.id}`);
}
const missingInline = [];
const addItems = (items) => {
for (const item of items) {
if (!item || !item.title) continue;
const itemType = (item.type || "").toLowerCase();
if (itemType === "subheader") continue;
// Prefer html_url/external_url; bare `url` is the API endpoint.
const url = item.html_url || item.external_url;
if (!url) continue;
let key;
if (itemType === "assignment" && item.content_id) key = `asn:${item.content_id}`;
else if (item.content_id) key = `${itemType}:${item.content_id}`;
else key = `url:${normalizeGlobalSearchUrl(gsAbsUrl(url))}`;
add(gsKindFromItemType(itemType), item.title, url, key);
}
};
for (const module of modules) {
if (!module || !module.name) continue;
add("module", module.name, `/courses/${courseId}/modules`, `module:${module.id}`);
if (Array.isArray(module.items)) addItems(module.items);
else missingInline.push(module);
}
if (missingInline.length) {
await gsRunPool(missingInline, 4, async (module) => {
const items = await gsGetJsonPages(`${domain}/api/v1/courses/${courseId}/modules/${module.id}/items?per_page=100`).catch(() => []);
addItems(items);
});
}
// A half-fetched course is retried sooner (half a TTL) instead of on every load.
const at = complete ? Date.now() : Date.now() - Math.floor(GS_COURSE_TTL / 2);
return { courseId, name, code: courseCode, at, tuples };
}
async function gsRefreshCourse(course) {
try {
const result = await gsFetchCourse(course);
if (!gsCache) gsCache = { v: GS_CACHE_VERSION, updatedAt: 0, courses: {} };
gsCache.courses[result.courseId] = {
n: result.name,
c: result.code,
at: result.at,
e: result.tuples
};
// Publish progressively so typing during a cold build already finds things.
gsAddCourseEntries(result.courseId, result.name, result.code, result.tuples);
gsUpdateStatus();
} catch (e) {
// Keep the previously cached copy; this course retries on the next load.
console.debug("[CanvasRefined] global search: course failed", course && course.id, e && e.message);
}
}
async function gsRunPool(items, limit, worker) {
let next = 0;
const runners = [];
const count = Math.max(1, Math.min(limit, items.length));
for (let i = 0; i < count; i++) {
runners.push((async () => {
while (next < items.length) {
const item = items[next++];
try {
await worker(item);
} catch (_) { /* per-item failure is non-fatal */ }
}
})());
}
await Promise.all(runners);
}
// Web Locks are origin-scoped, so all Canvas tabs of this instance coordinate: the
// first tab builds, the rest pick the result up from storage instead of refetching.
async function gsWithBuildLock(fn) {
let entered = false;
if (typeof navigator !== "undefined" && navigator.locks && typeof navigator.locks.request === "function") {
try {
return await navigator.locks.request(GS_BUILD_LOCK, async () => {
entered = true;
return await fn();
});
} catch (e) {
if (entered) throw e;
}
}
return await fn();
}
function gsIsIndexing() {
return !!(gsBuildPromise || gsTopUpPromise);
}
function gsNewestCourseAt() {
let newest = 0;
const courses = (gsCache && gsCache.courses) || {};
for (const course of Object.values(courses)) {
if ((course.at || 0) > newest) newest = course.at;
}
return newest;
}
function gsFormatAge(ms) {
if (ms < 60000) return getMsg("search_age_now", "just now");
const minutes = Math.floor(ms / 60000);
if (minutes < 60) return getMsg("search_age_minutes", "{{COUNT}}m ago").replace("{{COUNT}}", String(minutes));
const hours = Math.floor(minutes / 60);
if (hours < 24) return getMsg("search_age_hours", "{{COUNT}}h ago").replace("{{COUNT}}", String(hours));
return getMsg("search_age_days", "{{COUNT}}d ago").replace("{{COUNT}}", String(Math.floor(hours / 24)));
}
let _gsStatusAt = 0;
function gsUpdateStatus(force) {
const textEl = document.getElementById("canvasrefined-gs-status-text");
if (!textEl) return;
const now = Date.now();
if (!force && now - _gsStatusAt < 120) return;
_gsStatusAt = now;
const count = gsEntries.length;
let text = "";
if (count) {
const parts = [getMsg("search_index_count", "{{COUNT}} items").replace("{{COUNT}}", count.toLocaleString())];
const newest = gsNewestCourseAt();
if (newest) parts.push(getMsg("search_index_updated", "updated {{AGE}}").replace("{{AGE}}", gsFormatAge(now - newest)));
if (gsIsIndexing()) parts.push(getMsg("search_index_working", "updating\u2026"));
text = parts.join(" \u00b7 ");
} else if (gsIsIndexing()) {
text = getMsg("search_indexing", "Indexing your courses\u2026");
}
textEl.textContent = text;
const row = textEl.parentElement;
if (row) row.style.display = text ? "" : "none";
const refreshBtn = document.getElementById("canvasrefined-gs-refresh");
if (refreshBtn) refreshBtn.disabled = !!gsBuildPromise;
}
function gsIndexUpdated() {
try {
if (typeof gsOnIndexUpdated === "function") gsOnIndexUpdated();
} catch (_) { /* ignore */ }
}
function gsDebugSummary(startedAt, requestsBefore) {
try {
console.debug(`[CanvasRefined] global search index: ${gsEntries.length} items, ` +
`${gsRequests - requestsBefore} requests, ${Date.now() - startedAt}ms`);
} catch (_) { /* ignore */ }
}
async function gsBuildIndex(options_) {
const force = !!(options_ && options_.force);
const startedAt = Date.now();
const requestsBefore = gsRequests;
try {
await gsEnsureCacheLoaded();
// Serve whatever is on disk before touching the network: a warm open shows
// results in milliseconds and updates behind the user's back.
if (gsCache && !gsEntries.length) {
gsLoadFromCache(null);
gsUpdateStatus();
}
if (!gsCache) gsCache = { v: GS_CACHE_VERSION, updatedAt: 0, courses: {} };
// A fresh enrollment list (or a failed refresh with a list on hand) means a
// warm open costs literally zero requests.
const cachedList = gsCache.courseList;
const listFresh = cachedList && (Date.now() - (cachedList.at || 0)) < GS_COURSELIST_TTL;
let courses = (!force && listFresh) ? cachedList.items : [];
if (!courses.length) {
try {
courses = await gsFetchCourseList();
if (courses.length) {
gsCache.courseList = {
at: Date.now(),
items: courses.map(c => ({ id: c.id, name: c.name, course_code: c.course_code }))
};
}
} catch (e) {
console.warn("[CanvasRefined] global search: failed to load courses", e);
courses = (cachedList && cachedList.items) || [];
if (!courses.length) return gsEntries;
}
}
if (!courses.length) {
if (!gsEntries.length) gsLoadFromCache(null);
return gsEntries;
}
const activeIds = new Set(courses.map(course => String(course.id)));
for (const cid of Object.keys(gsCache.courses)) {
if (!activeIds.has(cid)) delete gsCache.courses[cid];
}
// Ignore cached courses that are no longer in the active enrollment list.
gsLoadFromCache(courses);
const now = Date.now();
const missing = force ? courses : courses.filter(course => !gsCache.courses[String(course.id)]);
const stale = force ? [] : courses.filter(course => {
const cached = gsCache.courses[String(course.id)];
return cached && (now - (cached.at || 0)) > GS_COURSE_TTL;
});
if (missing.length) {
await gsWithBuildLock(async () => {
// Another tab may have finished the same build while we waited.
await gsMergeFromStorage();
gsLoadFromCache(courses);
// A forced rebuild refetches everything even if storage already has it
// (another tab may have built while we queued for the lock).
const stillMissing = force
? courses
: missing.filter(course => !gsCache.courses[String(course.id)]);
await gsRunPool(stillMissing, GS_CONCURRENCY, course => gsRefreshCourse(course));
});
gsLoadFromCache(courses);
await gsPersistCache();
}
gsUpdateStatus();
// Warm start: refresh the stalest courses a few per page load instead of
// rebuilding everything. Never blocks the modal.
if (stale.length) {
const batch = stale.slice(0, GS_REFRESH_PER_LOAD);
gsTopUpPromise = gsRunPool(batch, Math.min(GS_CONCURRENCY, 3), course => gsRefreshCourse(course));
gsTopUpPromise
.then(() => gsPersistCache())
.then(() => gsIndexUpdated())
.catch(() => { /* background top-up is best-effort */ })
.finally(() => {
gsTopUpPromise = null;
gsUpdateStatus(true);
});
}
return gsEntries;
} finally {
gsDebugSummary(startedAt, requestsBefore);
}
}
function gsStartBuild(force) {
if (gsBuildPromise) return gsBuildPromise;
if (!force && gsBuiltOnce) return Promise.resolve(gsEntries);
gsBuiltOnce = true;
let resolveDone;
const done = new Promise(resolve => { resolveDone = resolve; });
gsBuildPromise = done;
gsBuildIndex({ force: !!force })
.catch(e => console.warn("[CanvasRefined] global search index failed", e))
.then(() => {
gsBuildPromise = null;
gsUpdateStatus(true);
gsIndexUpdated();
resolveDone(gsEntries);
});
gsUpdateStatus(true);
return done;
}
function gsForceRefresh() {
if (gsBuildPromise) return;
gsBuiltOnce = false;
// Let an in-flight top-up finish first: the full rebuild supersedes it anyway.
if (gsTopUpPromise) {
gsTopUpPromise.then(() => {
gsTopUpPromise = null;
gsStartBuild(true);
});
return;
}
gsStartBuild(true);
}
function ensureGlobalSearchIndex() {
return gsStartBuild(false);
}
// Warm the index on dashboard loads so Ctrl+K has something to show instantly. Only
// runs once a cache exists (i.e. the user has searched before): a cold build costs
// ~120 requests and shouldn't happen for a feature nobody has opened. Once warm, a
// page load only tops up a handful of stale courses at idle.
function gsSchedulePrewarm() {
if (_gsPrewarmScheduled || options.global_search !== true) return;
if (!isDashboardPage()) return;
_gsPrewarmScheduled = true;
const run = async () => {
if (options.global_search !== true) return;
try {
await gsEnsureCacheLoaded();
if (gsCache) gsStartBuild(false);
} catch (_) { /* prewarm is best-effort */ }
};
if (typeof requestIdleCallback === "function") requestIdleCallback(run, { timeout: 4000 });
else setTimeout(run, 2500);
}
function normalizeGlobalSearchUrl(url) {
if (!url) return "";
try {
const u = new URL(url, domain);
let path = u.pathname.replace(/\/+$/, ""); // strip trailing slashes
return (u.host.toLowerCase() + path).toLowerCase();
} catch (_) {
return String(url).replace(/\/+$/, "").toLowerCase();
}
}
function prettyModuleItemType(type) {
switch ((type || "").toLowerCase()) {
case "assignment": return getMsg("search_type_assignment", "Assignment");
case "quiz": return getMsg("search_type_quiz", "Quiz");
case "discussion": case "discussion_topic": return getMsg("search_type_discussion", "Discussion");
case "externalurl": return getMsg("search_type_link", "Link");
case "externaltool": return getMsg("search_type_external_tool", "External Tool");
case "file": return getMsg("search_type_file", "File");
case "page": return getMsg("search_type_page", "Page");
case "module": return getMsg("search_type_module", "Module");
case "course": return getMsg("search_type_course", "Course");
case "grades": return getMsg("search_type_grades", "Grades");
case "subheader": return getMsg("search_type_section", "Section");
default: return type || getMsg("search_type_item", "Item");
}
}
// Case-insensitive match on full name or course code; empty filter matches all.
function globalSearchCourseMatches(courseEntry, filter) {
const f = (filter || "").toLowerCase();
if (!f) return true;
return (courseEntry.title || "").toLowerCase().includes(f) ||
(courseEntry.courseCode || "").toLowerCase().includes(f);
}
// Splits "@Math 101 homework 5" into { courseFilter, content }: the filter is the
// longest prefix that matches a course. Null when there is no @token.
function parseGlobalSearchCourseFilter(query, courseEntries) {
const at = query.lastIndexOf("@");
if (at < 0) return null;
// '@' must start the query or follow whitespace, so emails aren't split.
if (at > 0 && !/\s/.test(query[at - 1])) return null;
const before = query.slice(0, at).trim();
const rest = query.slice(at + 1);
// Longest match, ending on a word boundary ("@mathh" is not math + h).
for (let len = rest.length; len > 0; len--) {
if (len < rest.length && rest[len] !== " ") continue;
const candidate = rest.slice(0, len).trim().toLowerCase();
if (!candidate) continue;
if (courseEntries.some(c => globalSearchCourseMatches(c, candidate))) {
return { courseFilter: candidate, content: `${before} ${rest.slice(len)}`.trim() };
}
}
if (!rest.trim()) return { courseFilter: "", content: before }; // bare "@" -> all courses
return null;
}
function globalSearchRowHtml(item, i) {
return `
`;
}
function bindGlobalSearchRows(resultsEl, top) {
resultsEl.querySelectorAll(".canvasrefined-gs-row").forEach((row) => {
row.addEventListener("click", (e) => {
const url = row.getAttribute("data-url");
const item = top.find(x => x.url === url);
if (item) openGlobalSearchResult(item, e.ctrlKey || e.metaKey || (e.button === 1));
});
row.addEventListener("auxclick", (e) => {
if (e.button !== 1) return;
e.preventDefault();
const url = row.getAttribute("data-url");
const item = top.find(x => x.url === url);
if (item) openGlobalSearchResult(item, true);
});
});
}
// Never awaits the network: searches whatever the live index holds right now (the
// persisted cache on a warm load, partially-filled during a cold build) so results
// appear as courses land instead of after the whole index is ready.
async function runGlobalSearch(query, resultsEl) {
gsStartBuild(false);
const index = gsEntries;
const indexing = gsIsIndexing();
if (!query) {
resultsEl.innerHTML = `
${getMsg("search_hint", "Start typing to search your modules and assignments. Use @coursename to search a specific course, or @grades to open a course's grades page.")}
`;
return [];
}
// "@home" opens the dashboard and needs no index, so it answers before the
// empty-index guard below.
if (query.trim().toLowerCase() === "@home") {
const homeRows = [{
kind: "home",
cls: "home",
type: gsKindLabel("home"),
title: getMsg("dashboard", "Dashboard"),
course: "",
courseId: null,
url: `${domain}/`,
}];
resultsEl.innerHTML = homeRows.map((item, i) => globalSearchRowHtml(item, i)).join("");
bindGlobalSearchRows(resultsEl, homeRows);
return homeRows;
}
if (!index.length) {
resultsEl.innerHTML = indexing
? `
${getMsg("search_no_modules_found", "No modules or assignments found. Open the search again later if your courses are still loading.")}
`;
return [];
}
// Hidden courses are filtered here (not at build time) so hiding a course never
// needs an index rebuild.
const hiddenCourseIds = gsHiddenCourseIds();
// "@grades [filter]" lists grade pages instead. Checked before the @course
// parse so a course actually named "Grades" isn't eaten.
const gradesMatch = query.match(/(?:^|\s)@grades(?:\s+(.+))?$/i);
if (gradesMatch) {
const courseEntries = index.filter(entry => entry.kind === "course" && !hiddenCourseIds.has(String(entry.courseId)));
const filter = (gradesMatch[1] || "").trim();
const matching = courseEntries.filter(c => globalSearchCourseMatches(c, filter));
if (!matching.length) {
resultsEl.innerHTML = `
${filter ? getMsg("search_no_course", "No course matches \"{{QUERY}}\".").replace("{{QUERY}}", escapeGlobalSearchHtml(filter)) : getMsg("search_no_courses_grades", "No courses found to open grades for.")}
${getMsg("search_no_course", "No course matches \"{{QUERY}}\".").replace("{{QUERY}}", escapeGlobalSearchHtml(parsed.courseFilter))}
`;
return [];
}
resultsEl.innerHTML = matching.map((item, i) => globalSearchRowHtml(item, i)).join("");
bindGlobalSearchRows(resultsEl, matching);
return matching;
}
}
const q = effectiveQuery.toLowerCase();
const matches = [];
for (const item of index) {
if (allowedCourseIds && !allowedCourseIds.has(String(item.courseId))) continue;
if (hiddenCourseIds.has(String(item.courseId))) continue;
let score = -1;
const at = item._t.indexOf(q);
if (at === 0) score = 100;
else if (at > 0) score = 60 - at;
else if (item._c.includes(q)) score = 20;
if (score >= 0) {
if (item.kind === "course" && at === 0) score += 40;
item._score = score + (item._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 = `
${getMsg("search_no_match", "No results for \"{{QUERY}}\".").replace("{{QUERY}}", escapeGlobalSearchHtml(query))}${indexing ? ` ${getMsg("search_partial", "Still indexing your courses…")}` : ""}
`;
return [];
}
resultsEl.innerHTML = top.map((item, i) => globalSearchRowHtml(item, i)).join("");
bindGlobalSearchRows(resultsEl, top);
return top;
}
// One pass over the card settings rather than an options lookup per index entry.
function gsHiddenCourseIds() {
const hidden = new Set();
const cards = options.custom_cards || {};
for (const [courseId, card] of Object.entries(cards)) {
if (card && card.hidden === true) hidden.add(String(courseId));
}
return hidden;
}
function escapeGlobalSearchHtml(s) {
return String(s).replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]));
}
function escapeGlobalSearchAttr(s) {
return escapeGlobalSearchHtml(s).replace(/`/g, "`");
}
function changeGradientCards() {
// Inline per-hero gradient rather than index-based CSS: it always uses that
// hero's current color, so it survives reordering and recoloring.
const heroes = document.querySelectorAll('.ic-DashboardCard__header_hero');
heroes.forEach(hero => {
if (options.gradient_cards !== true) {
hero.style.removeProperty("background-image");
return;
}
const rgb = hero.style.backgroundColor;
const parts = rgb.match(/\d+(\.\d+)?/g);
if (!parts || parts.length < 3) return;
const [r, g, b] = [parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2])];
let [h, s, l] = rgbToHsl(r, g, b);
let degree = ((h % 60) / 60) >= .66 ? 30 : ((h % 60) / 60) <= .33 ? -30 : 15;
let newh = h > 300 ? (360 - (h + 65)) + (65 + degree) : h + 65 + degree;
hero.style.backgroundImage = `linear-gradient(115deg, hsl(${h}deg,${s}%,${l}%) 5%, hsl(${newh}deg,${s}%,${l}%) 100%)`;
});
const legacy = document.querySelector("#gradientcss");
if (legacy) legacy.remove();
}
function showUpdateMsg() {
const el = document.getElementById("announcementWrapper");
if (!el) return;
let div = document.getElementById("canvasrefined-update-msg");
if (options.show_updates !== true || options.update_msg === "") {
if (div) div.style.display = "none";
return;
} else if (div) {
div.style.display = "flex";
return;
}
div = makeElement("div", el, { "id": "canvasrefined-update-msg" });
makeElement("p", div, { "textContent": options.update_msg });
const close = makeElement("button", div, { "id": "canvasrefined-update-close", "textContent": "Close" });
close.addEventListener("click", () => {
readUpdate();
div.remove();
});
}
function readUpdate() {
chrome.storage.sync.set({ "update_msg": "" });
}
function combineAssignments(data) {
let combined = data;
try {
options.custom_assignments_overflow.forEach(overflow => {
combined = combined.concat(options[overflow]);
});
} catch (e) {
logError(e);
}
// Dedupe by planner item identity, keeping the LAST occurrence so locally
// stored overflow entries win over fetched ones.
if (Array.isArray(combined)) {
const seen = new Set();
const deduped = [];
for (let i = combined.length - 1; i >= 0; i--) {
const item = combined[i];
if (!item) continue;
const key = `${item.plannable_type}|${item.plannable_id}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(item);
}
deduped.reverse();
combined = deduped;
}
return combined.sort((a, b) => new Date(a.plannable_date).getTime() - new Date(b.plannable_date).getTime());
}
function cleanCustomAssignments() {
chrome.storage.sync.get("custom_assignments_overflow", overflows => {
chrome.storage.sync.get(overflows["custom_assignments_overflow"], storage => {
const now = new Date();
overflows["custom_assignments_overflow"].forEach(overflow => {
let changed = false;
for (let i = 0; i < storage[overflow].length; i++) {
let assignmentDate = new Date(storage[overflow][i].plannable_date);
if (!assignmentDate.getTime() || assignmentDate < now) {
storage[overflow].splice(i, 1);
changed = true;
}
}
if (changed) chrome.storage.sync.set({ [overflow]: storage[overflow] });
});
});
});
}
function getGrades() {
if (options.gpa_calc === true || options.dashboard_grades === true) {
grades = getData(`${domain}/api/v1/courses?${/*enrollment_state=active&*/""}include[]=concluded&include[]=total_scores&include[]=computed_current_score&include[]=current_grading_period_scores&per_page=100`);
if (options.gpa_calc === true) {
grades.then(() => setupGPACalc()).catch(() => {});
}
}
}
function getColors() {
if (options.tab_icons || options.better_todo || options.better_sidebar) {
return getData(`${domain}/api/v1/users/self/colors`).then(data => {
let cards = options.custom_cards_3;
Object.keys(cards).forEach(key => {
cards[key] = { ...cards[key], "color": data["custom_colors"]["course_" + key] ? data["custom_colors"]["course_" + key] : null };
});
chrome.storage.sync.set({ "custom_cards_3": cards });
return cards;
}).catch(e => {
// Keep the stored colors on failure; don't leak an unhandled rejection.
console.warn("Canvas Refined - could not load course colors", e);
return options.custom_cards_3;
});
}
}
function changeFavicon() {
if (options.tab_icons !== true) return;
let match = current_page.match(/courses\/(?\d*)/);
if (match && match.groups.id && options.custom_cards_3[match.groups.id]?.color) {
document.querySelector('link[rel="icon"').href = `data:image/svg+xml;utf8,`;
}
}
function getAssignments() {
if (options.assignments_due === true || options.better_todo === true) {
assignments = loadPlannerItems();
cardAssignments = preloadAssignmentEls();
// setupBetterTodo bails rather than mounting a permanent empty shell while
// data loads, so retry once it resolves (the dashboard may have gone quiet).
assignments.then(() => setupBetterTodo());
}
}
// ===================== Planner items (cached) =====================
// Cached in chrome.storage.local: instant load, a 14-day refresh window per page
// load, and a full 1-year lookback at most weekly while the tab is idle.
const PLANNER_CACHE_KEY = "planner_cache_v1";
const PLANNER_LOOKBACK_DAYS = 365; // max history fetched from Canvas
const PLANNER_WINDOW_DAYS = 14; // per-load "what changed" refresh window
const PLANNER_FULL_REFRESH_DAYS = 7; // min time between full lookback walks
// 50 pages × 100 items — a safety net against a malformed next link.
const PLANNER_MAX_PAGES = 50;
function plannerDateDaysAgo(days) {
return new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
}
function plannerItemKey(item) {
return `${item.plannable_type}|${item.plannable_id}`;
}
// ===================== Active-enrollment filter =====================
// The planner API excludes concluded *courses* but not items whose enrollment
// concluded (common at term boundaries), so filter by active enrollment ids.
// Returns null on failure so callers skip filtering instead of hiding everything.
async function fetchActiveCourseIds() {
const ids = new Set();
let url = `${domain}/api/v1/courses?enrollment_state=active&per_page=100`;
for (let page = 0; page < 10 && url; page++) {
let response;
let data;
try {
response = await pageFetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
data = await response.json();
} catch (e) {
return null;
}
if (!response.ok || !Array.isArray(data)) return null;
for (const course of data) {
if (course && course.id != null) ids.add(String(course.id));
}
url = getNextPageUrl(response.headers.get("Link"));
}
return ids;
}
// null courseIds = unknown (fetch failed), so no filtering; items with no course
// context (planner notes, groups) are always kept.
function filterPlannerItemsByActiveCourses(items, courseIds) {
if (!courseIds) return items;
const active = (courseIds instanceof Set) ? courseIds : new Set(courseIds.map(String));
return items.filter(item => {
const cid = item.course_id ?? (item.context_type === "course" ? item.context_id : null);
return cid == null || active.has(String(cid));
});
}
async function readPlannerCache() {
try {
const result = await chrome.storage.local.get(PLANNER_CACHE_KEY);
const cache = result && result[PLANNER_CACHE_KEY];
if (cache && Array.isArray(cache.items)) return cache;
} catch (e) { /* storage unavailable; fall back to live fetch */ }
return null;
}
function writePlannerCache(items, lastFullRefresh, activeCourseIds, minRefreshedAt = 0) {
try {
// Cross-tab guard: never overwrite a cache newer than our snapshot.
chrome.storage.local.get(PLANNER_CACHE_KEY, result => {
try {
const current = result && result[PLANNER_CACHE_KEY];
if (current && ((current.refreshedAt || 0) > minRefreshedAt)) return;
const p = chrome.storage.local.set({ [PLANNER_CACHE_KEY]: { items, lastFullRefresh, activeCourseIds, refreshedAt: Date.now() } });
if (p && typeof p.catch === "function") p.catch(() => {});
} catch (e) { /* cache write failure is non-fatal */ }
});
} catch (e) { /* cache write failure is non-fatal */ }
}
// Pages through /api/v1/planner/items from `startDate`. Returns null when the
// fetch failed — callers must treat null as "unknown", never as "no items".
async function fetchPlannerItemsSince(startDate) {
const allItems = [];
let url = `${domain}/api/v1/planner/items?start_date=${startDate}&per_page=100`;
for (let page = 0; page < PLANNER_MAX_PAGES && url; page++) {
let response;
let data;
try {
response = await pageFetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
data = await response.json();
} catch (e) {
return null;
}
if (!response.ok || !Array.isArray(data)) return null;
try {
allItems.push(...JSON.parse(JSON.stringify(data)));
} catch (_) {
allItems.push(...data);
}
url = getNextPageUrl(response.headers.get("Link"));
}
return allItems;
}
// Cached items inside the window are replaced wholesale; older ones are kept.
function mergePlannerItems(cached, fetched, windowStartMs) {
const byKey = new Map();
for (const item of cached) {
const due = new Date(item.plannable_date).getTime();
if (due >= windowStartMs) continue; // superseded by the fresh fetch
byKey.set(plannerItemKey(item), item);
}
for (const item of fetched) byKey.set(plannerItemKey(item), item);
return sortAndTrimPlannerItems([...byKey.values()]);
}
// Trim to the lookback, dedupe by item identity and sort by due date, so every
// cache-write path is idempotent.
function sortAndTrimPlannerItems(items) {
const cutoff = Date.now() - PLANNER_LOOKBACK_DAYS * 86400000;
// Last occurrence per key wins: Canvas returns items oldest-first.
const byKey = new Map();
for (const item of items) {
if (new Date(item.plannable_date).getTime() < cutoff) continue;
byKey.set(plannerItemKey(item), item);
}
const trimmed = [...byKey.values()];
trimmed.sort((a, b) => new Date(a.plannable_date) - new Date(b.plannable_date));
return trimmed;
}
// Cheap change signature so consumers only re-render on real changes.
function plannerFingerprint(items) {
return items.map(item =>
`${plannerItemKey(item)}:${item.plannable_date}:${item.submissions?.submitted ? 1 : 0}:${item.submissions?.graded ? 1 : 0}:${item.planner_override?.marked_complete ? 1 : 0}:${item.plannable?.read_state ?? ""}`
).join("|");
}
// Cache first; on first run a single bounded (1-year) fetch is on the critical
// path. Items are always limited to the active courses.
async function loadPlannerItems() {
const cache = await readPlannerCache();
if (cache) {
schedulePlannerRefresh(cache);
return filterPlannerItemsByActiveCourses(cache.items, cache.activeCourseIds);
}
// First run: fetch the lookback and the enrollment list in parallel, then
// cache. A failed fetch caches nothing, so the next load retries.
const [items, courseIds] = await Promise.all([
fetchPlannerItemsSince(plannerDateDaysAgo(PLANNER_LOOKBACK_DAYS)),
fetchActiveCourseIds(),
]);
if (items === null) return [];
const filtered = filterPlannerItemsByActiveCourses(
sortAndTrimPlannerItems(items), courseIds
);
writePlannerCache(filtered, Date.now(), courseIds ? [...courseIds] : null);
return filtered;
}
// Recent-window refresh on every load, a full lookback walk at most weekly, and
// the enrollment list refreshed each pass. Runs while the tab is idle.
function schedulePlannerRefresh(cache) {
const fullRefreshDue = !cache.lastFullRefresh ||
(Date.now() - cache.lastFullRefresh > PLANNER_FULL_REFRESH_DAYS * 86400000);
// One refresh at a time: concurrent runs (other tabs, Add-Task) could let a
// slow stale run overwrite a newer write.
if (plannerRefreshRunning) return;
const run = async () => {
if (plannerRefreshRunning) return;
plannerRefreshRunning = true;
try {
const before = plannerFingerprint(
filterPlannerItemsByActiveCourses(cache.items, cache.activeCourseIds)
);
const courseIds = await fetchActiveCourseIds();
const active = courseIds ?? (cache.activeCourseIds ?? null);
const idsToStore = courseIds ? [...courseIds] : (cache.activeCourseIds ?? null);
let merged;
if (fullRefreshDue) {
const fetched = await fetchPlannerItemsSince(plannerDateDaysAgo(PLANNER_LOOKBACK_DAYS));
// Fetch failed: keep the cache untouched and retry next load.
if (fetched === null) return;
merged = filterPlannerItemsByActiveCourses(
sortAndTrimPlannerItems(fetched),
active
);
writePlannerCache(merged, Date.now(), idsToStore, cache.refreshedAt || 0);
} else {
const windowStart = plannerDateDaysAgo(PLANNER_WINDOW_DAYS);
const fetched = await fetchPlannerItemsSince(windowStart);
// Fetch failed: merging would delete the last two weeks from the cache.
if (fetched === null) return;
merged = filterPlannerItemsByActiveCourses(
// Threshold at the window start's LOCAL midnight: Canvas reads
// start_date in the user's timezone, so a UTC threshold deleted items.
mergePlannerItems(cache.items, fetched, Date.parse(windowStart + "T00:00:00")),
active
);
writePlannerCache(merged, cache.lastFullRefresh, idsToStore, cache.refreshedAt || 0);
}
if (plannerFingerprint(merged) !== before) refreshPlannerConsumers(merged);
} catch (e) {
console.warn("planner refresh failed", e);
} finally {
plannerRefreshRunning = false;
}
};
if (typeof requestIdleCallback === "function") {
requestIdleCallback(() => run(), { timeout: 20000 });
} else {
setTimeout(run, 5000);
}
}
// Re-render every planner consumer after a background refresh.
function refreshPlannerConsumers(items) {
assignments = Promise.resolve(items);
updateReminders();
if (options.assignments_due === true || options.better_todo === true) {
cardAssignments = preloadAssignmentEls();
loadCardAssignments();
}
if (options.better_todo && document.getElementById("better-todo-main")) {
moreAnnouncementCount = 0;
moreAssignmentCount = 0;
moreCompletedCount = 0;
clearTodoList();
createTodoSections(document.querySelector("#canvasrefined-todo-list"));
}
}
function getNextPageUrl(linkHeader) {
if (!linkHeader) return null;
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
return match ? match[1] : null;
}
// ===================== Grade Analytics =====================
// Grades-page panel: score doughnut, grade-over-time line, GitHub-style heatmap
// and a final-grade calculator, all hand-drawn on