diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e8826a3..b8239fe 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -311,6 +311,9 @@ "hide_sequence_footer": { "message": "Hide Next/Previous Buttons" }, + "hide_navbar": { + "message": "Hide Navigation Bar" + }, "global_search": { "message": "Global Canvas Search" }, diff --git a/css/darkmodecss.js b/css/darkmodecss.js index 60a7fab..fd6c07f 100644 --- a/css/darkmodecss.js +++ b/css/darkmodecss.js @@ -1,6 +1,7 @@ const DARKMODE_CSS = ` #announcementWrapper>div>div, #breadcrumbs, +.ic-app-nav-toggle-and-crumbs, #calendar-app .fc-agendaWeek-view .fc-body, #calendar-app .fc-event, #calendar-app .fc-month-view .fc-body, @@ -1286,6 +1287,24 @@ body > span > span[class*="-tray"] { .UpdateItemTray-styles__root { background: var(--bcbackground-0) !important; } +/* Immersive Reader button (in the nav-toggle + breadcrumbs bar): an InstUI + Button whose emotion class hashes change between Canvas releases, so + target the stable mount point and attribute-suffix class names instead. + The button and its inner content span carry a light surface, dark ink + text and a black icon glyph, which are unreadable on the dark theme. */ +#immersive_reader_mount_point button[class$="-baseButton"], +#immersive_reader_mount_point button[class$="-baseButton"] > span:first-child { + background: var(--bcbackground-1) !important; + color: var(--bctext-0) !important; + border: 1px solid var(--bcborders) !important; + box-shadow: none !important; +} +/* The icon glyph is hardcoded black (fill="#000000" attribute); recolor it + to the theme text color. The blue accent paths stay as-is — they read + fine on dark. */ +#immersive_reader_mount_point button[class$="-baseButton"] svg path[fill="#000000"] { + fill: var(--bctext-0) !important; +} /* InstUI TextInput / Select facades (Title, Date, Time, Course fields): white surface, dark ink, gray border. Repaint with the theme surface, text, and border colors — the calendar / arrow icons inside use diff --git a/html/popup.html b/html/popup.html index 41cb077..05293cd 100644 --- a/html/popup.html +++ b/html/popup.html @@ -354,6 +354,10 @@ Remove sidebar logo +
+ + +
Sidebar scale: % diff --git a/js/background.js b/js/background.js index 7a3a5ab..816fe2b 100644 --- a/js/background.js +++ b/js/background.js @@ -77,6 +77,7 @@ chrome.runtime.onInstalled.addListener(function () { "equal_height_cards": false, "hide_new_canvas": true, "hide_sequence_footer": false, + "hide_navbar": false, "quiz_safe_mode": false, "dark_mode_fix": [], "assignment_states": {}, diff --git a/js/content.js b/js/content.js index fb7ad4e..f40ab20 100644 --- a/js/content.js +++ b/js/content.js @@ -602,9 +602,22 @@ let grades = null; let announcements = []; let completed = []; let assignmentsDue = []; +// Render-generation counters. createTodoSections/loadCardAssignments render +// asynchronously (inside a .then), while several code paths re-render by +// calling clearTodoList() + createTodoSections() again. Without a guard, a +// render whose data promise resolves AFTER a newer clear+render started would +// append a second full set of group wrappers/items on top of the fresh render +// — the reported "todo list is all doubled" glitch. Each render captures the +// counter at call time and aborts if a newer render has started by the time +// its data arrives. +let todoRenderGen = 0; +let cardRenderGen = 0; let options = {}; let timeCheck = null; let reminderCheck = null; +// Set while a background planner-cache refresh is in flight so overlapping +// schedules (multiple loads, multiple tabs) can't run concurrently. +let plannerRefreshRunning = false; let betterSidebarLoading = false; let dashboardReadyTimer = null; let sidebarReadyTimer = null; @@ -632,24 +645,34 @@ Todo Reminders const canvas_svg = ` `; async function insertReminders(reminders) { - const toAdd = []; const storage = await chrome.storage.sync.get("reminders"); - // overrides = if theres a item that needs to update, but already exists - let overrides = false; - for (const insert of reminders) { - let found = false; - for (let i = 0; i < storage["reminders"].length; i++) { - // check if item was recently submitted - if (insert.c === -1 && insert.h === storage["reminders"][i].h) { - overrides = true; - storage["reminders"][i] = insert; - } else if (insert.h === storage["reminders"][i].h) { - found = true; - } - } - if (found === false) toAdd.push(insert); + const stored = Array.isArray(storage["reminders"]) ? storage["reminders"] : []; + // Keyed by link ("h"). Dedupes while loading: older versions replaced a + // matching entry in place for submitted items (c === -1) but ALSO appended + // the insert again, so one extra copy accumulated in storage on every + // load/refresh. Rebuilding the map here heals already-duplicated storage. + const byHref = new Map(); + for (const r of stored) { + if (!r || !r.h) continue; + const prev = byHref.get(r.h); + if (!prev || (prev.c !== -1 && r.c === -1)) byHref.set(r.h, r); } - if (toAdd.length > 0 || overrides === true) chrome.storage.sync.set({ "reminders": [...storage["reminders"], ...toAdd] }); + let changed = byHref.size !== stored.length; + for (const insert of reminders) { + if (!insert || !insert.h) continue; + const prev = byHref.get(insert.h); + if (!prev) { + byHref.set(insert.h, insert); + changed = true; + } else if (insert.c === -1 && prev.c !== -1) { + // Recently submitted: update the existing entry in place instead of + // adding a second copy. An unsubmitted insert never overwrites an + // existing entry, and a submitted entry stays submitted. + byHref.set(insert.h, insert); + changed = true; + } + } + if (changed) chrome.storage.sync.set({ "reminders": [...byHref.values()] }); } async function hideReminder(href) { @@ -933,13 +956,14 @@ function applyOptionsChanges(changes) { case "todo_ignore_card_colors": case "todo_remove_icons": case "custom_cards_3": - moreAnnouncementCount = 0; - moreAssignmentCount = 0; - // A new timeframe starts back at the current window. - betterTodoTimeframeOffset = 0; - // loadBetterTodo(); - clearTodoList(); - createTodoSections(document.querySelector("#canvasrefined-todo-list")); + if (options.better_todo && document.getElementById("better-todo-main")) { + moreAnnouncementCount = 0; + moreAssignmentCount = 0; + // A new timeframe starts back at the current window. + betterTodoTimeframeOffset = 0; + clearTodoList(); + createTodoSections(document.querySelector("#canvasrefined-todo-list")); + } break; case "gpa_calc": case "gpa_calc_prepend": @@ -960,7 +984,21 @@ function applyOptionsChanges(changes) { case "full_width": case "center_cards": case "custom_styles": + case "hide_navbar": applyAestheticChanges(); + // Better Sidebar also hides the nav-toggle + breadcrumbs bar with + // an inline style (part of its layout). The "Hide Navigation + // Bar" sub-option is the single source of truth for that + // element, so live-apply/restore the inline style here too — + // setupBetterSidebar only sets it once, on first mount. + if (options.better_sidebar) { + const crumbsBar = document.querySelector(".ic-app-nav-toggle-and-crumbs"); + if (options.hide_navbar === true) { + crumbsBar?.style.setProperty("display", "none"); + } else { + crumbsBar?.style.removeProperty("display"); + } + } break; case "hide_new_canvas": watchNewCanvasButton(); @@ -1614,9 +1652,9 @@ function recieveMessage(request, sender, sendResponse) { switch (request.message) { case ("getCards"): if (options["card_method_dashboard"] === true) { - getCardsFromDashboard().then(() => sendResponse(true)); + getCardsFromDashboard().then(() => sendResponse(true)).catch(() => sendResponse(true)); } else { - getCards().then(() => sendResponse(true)); + getCards().then(() => sendResponse(true)).catch(() => sendResponse(true)); } return true; // keep the message channel open for async sendResponse case ("setcolors"): changeColorPreset(request.options); sendResponse(true); break; @@ -3198,6 +3236,12 @@ function openTaskForEdit(item) { } async function createTodoSections(location) { + if (!location || !assignments || typeof assignments.then !== "function") return; + // Render-generation guard: capture the counter at call time and bail out of + // the async render if a newer call has started by the time our data + // resolves. Incremented only after the guards above, so a bail-out can + // never cancel an in-flight render without scheduling a replacement. + const renderGen = ++todoRenderGen; if (!location.querySelector("#better-todo-header")) { let header = makeElement("div", location, { id: "better-todo-header" }); header.style = "display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--bcbackground-1);padding-bottom:-2px;"; @@ -3322,6 +3366,9 @@ async function createTodoSections(location) { // depends on the current tab and the todo_timeframe option). updateTodoTimeframeNav(); assignments.then(data => { + // A newer render superseded this one — appending now would duplicate + // the whole list on top of it. + if (renderGen !== todoRenderGen) return; const courseId = getCurrentCourseId(); const scopedData = getTodoScopedData(data, courseId); @@ -3470,15 +3517,16 @@ async function createTodoSections(location) { ensureRightSideWrapperScrollbarHidden(); sidebar.style.setProperty("scrollbar-width", "none"); sidebar.style.setProperty("-ms-overflow-style", "none"); + const viewportOffset = todoViewportOffsetPx(); if (options.todo_full_height) { - sidebar.style.minHeight = "100vh"; + sidebar.style.minHeight = viewportOffset > 0 ? `calc(100vh - ${viewportOffset}px)` : "100vh"; } else { sidebar.style.minHeight = ""; } if (options.todo_separate_scrollbar) { sidebar.style.position = "sticky"; sidebar.style.top = "0"; - sidebar.style.height = "100vh"; + sidebar.style.height = viewportOffset > 0 ? `calc(100vh - ${viewportOffset}px)` : "100vh"; sidebar.style.overflowY = "auto"; } else { sidebar.style.position = ""; @@ -3487,9 +3535,43 @@ async function createTodoSections(location) { sidebar.style.overflowY = ""; // maybe invisible scrollbar? } + }).catch(err => { + // A rejected data promise must not leave the todo list half-rendered or + // throw unhandled; the generation guard above keeps renders idempotent. + if (renderGen === todoRenderGen) console.warn("Canvas Refined - todo list render failed", err); }); } +// Height the todo sidebar must shed to fit the viewport: the nav-toggle + +// breadcrumbs bar sits above the content column, so a full-viewport (100vh) +// sidebar forces the page to scroll by exactly the bar's height now that the +// bar is visible again (Hide Navigation Bar defaults to off). Returns 0 when +// the bar is hidden or absent (dashboard pages have no crumbs bar). +function todoViewportOffsetPx() { + if (options.hide_navbar === true) return 0; + const crumbs = document.querySelector(".ic-app-nav-toggle-and-crumbs"); + const h = crumbs ? crumbs.offsetHeight : 0; + return h > 0 ? h : 0; +} + +// The navbar height changes with window size/zoom, so re-apply the height +// styles on resize (without a full todo re-render). +let todoHeightResizeTimer = null; +window.addEventListener("resize", () => { + if (todoHeightResizeTimer) clearTimeout(todoHeightResizeTimer); + todoHeightResizeTimer = setTimeout(() => { + const sidebar = document.getElementById("right-side-wrapper"); + if (!sidebar || !document.getElementById("better-todo-main")) return; + const viewportOffset = todoViewportOffsetPx(); + if (options.todo_full_height) { + sidebar.style.minHeight = viewportOffset > 0 ? `calc(100vh - ${viewportOffset}px)` : "100vh"; + } + if (options.todo_separate_scrollbar) { + sidebar.style.height = viewportOffset > 0 ? `calc(100vh - ${viewportOffset}px)` : "100vh"; + } + }, 150); +}); + function ensureRightSideWrapperScrollbarHidden() { let style = document.getElementById("canvasrefined-hide-right-sidebar-scrollbar") || document.createElement("style"); style.id = "canvasrefined-hide-right-sidebar-scrollbar"; @@ -3508,12 +3590,16 @@ function ensureRightSideWrapperScrollbarHidden() { } function clearTodoList() { + const main = document.getElementById("better-todo-main"); const seeMoreBtn = document.getElementById("better-todo-see-more"); if (seeMoreBtn) { seeMoreBtn.remove(); } + // Called from storage-change handlers on every page; without this guard it + // throws on pages that have no todo list mounted. + if (!main) return; - document.getElementById("better-todo-main").querySelectorAll(".todo-group-list").forEach(list => { + main.querySelectorAll(".todo-group-list").forEach(list => { list.innerHTML = ""; }); document.querySelectorAll(".better-todo-dueheader").forEach(header => { @@ -4219,6 +4305,13 @@ function setupBetterTodo() { if (isQuizPage()) return; if (options.better_todo !== true || isGradesPage()) return; if (document.querySelector('#canvasrefined-todo-list')) return; + // The dashboard MutationObserver can fire before getApiData() has assigned + // the `assignments` promise. Creating the sidebar now would leave it + // permanently empty: createTodoSections would throw on `assignments.then` + // after the shell was built, and the existing-element guard above prevents + // any retry. Bail instead — Canvas keeps mutating the DOM during load, so + // checkDashboardReady calls us again once data is ready. + if (!assignments || typeof assignments.then !== "function") return; let list = document.querySelector("#right-side"); if (!list) return; //if (!list || list.childElementCount === 0 || list.children[0].id === "canvasrefined-todo-list") return; @@ -4344,7 +4437,13 @@ async function setupBetterSidebar(mode = getSidebarLayoutMode()) { leftSide.style.minWidth = "0"; leftSide.style.gap = "0"; } - document.querySelector(".ic-app-nav-toggle-and-crumbs")?.style.setProperty("display", "none"); + // Only hide the nav-toggle + breadcrumbs bar when the Better Sidebar + // "Hide Navigation Bar" sub-option is on — this used to be hidden + // unconditionally. (The global hide lives in applyAestheticChanges; + // live toggling is handled in applyOptionsChanges.) + 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"); } @@ -5313,7 +5412,7 @@ function insertGrades() { } catch (e) { logError(e); } - }); + }).catch(e => logError(e)); } else { document.querySelectorAll('.canvasrefined-card-grade').forEach(grade => { grade.style.display = "none"; @@ -5388,28 +5487,41 @@ window.addEventListener("resize", () => { }); function preloadAssignmentEls() { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { let assignmentEls = {}; const now = new Date(); - assignments.then((data) => { - data = combineAssignments(data); - data.forEach(item => { - let due = new Date(item.plannable_date); - item.overdue = now >= due; - let o = { - "submitted": item.submissions && item.submissions.submitted === true, - "override": item.planner_override && item.planner_override.marked_complete, - "type": item.plannable_type, - "due": due, - "el": createCardAssignment(item) - } - if (assignmentEls[item.course_id]) { - assignmentEls[item.course_id].push(o); - } else { - assignmentEls[item.course_id] = [o]; - } - }); - resolve(assignmentEls); + // Resolve (never reject, never hang): a throw inside the data callback + // previously left this promise pending forever, so every dashboard + // card kept showing its blinking "loading" skeleton indefinitely. + const finish = () => resolve(assignmentEls); + if (!assignments || typeof assignments.then !== "function") { finish(); return; } + assignments.then(data => { + try { + data = combineAssignments(data); + data.forEach(item => { + let due = new Date(item.plannable_date); + item.overdue = now >= due; + let o = { + "submitted": item.submissions && item.submissions.submitted === true, + "override": item.planner_override && item.planner_override.marked_complete, + "type": item.plannable_type, + "due": due, + "el": createCardAssignment(item) + } + if (assignmentEls[item.course_id]) { + assignmentEls[item.course_id].push(o); + } else { + assignmentEls[item.course_id] = [o]; + } + }); + } catch (e) { + logError(e); + } finally { + finish(); + } + }).catch(e => { + logError(e); + finish(); }); }); } @@ -5423,7 +5535,13 @@ function loadCardAssignments() { return; } setupCardAssignments(); + if (!cardAssignments || typeof cardAssignments.then !== "function") return; + // Render-generation guard: cardAssignments is reassigned whenever the + // planner data refreshes; a stale callback re-queried the live cards and + // re-appended outdated rows over the fresh render. + const renderGen = ++cardRenderGen; cardAssignments.then(els => { + if (renderGen !== cardRenderGen) return; try { let cards = document.querySelectorAll('.ic-DashboardCard'); if (cards.length === 0) return; @@ -5841,7 +5959,7 @@ function setupGPACalc() { } catch (e) {} calculateGPA2(); - }); + }).catch(e => logError(e)); } catch (e) { logError(e); } @@ -6270,7 +6388,12 @@ function applyAestheticChanges() { } } - style.textContent += ".ic-app-nav-toggle-and-crumbs{display:none!important}"; + // Hiding the nav-toggle + breadcrumbs bar used to be hardcoded always-on; + // it is now opt-in via the popup toggle (off by default). Its left/right + // margins are always removed so the bar lines up with the content column + // edges (cosmetic, light and dark mode). + 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); } @@ -7024,6 +7147,25 @@ function combineAssignments(data) { } catch (e) { logError(e); } + // Dedupe by planner item identity (same key the cache merge uses), keeping + // the LAST occurrence so locally-stored overflow entries win over fetched + // ones. Without this, an item present in both the planner data and an + // overflow array rendered twice — once on the dashboard cards and once per + // copy in the todo list. + 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()); } @@ -7082,6 +7224,11 @@ function getColors() { }); chrome.storage.sync.set({ "custom_cards_3": cards }); return cards; + }).catch(e => { + // A failed colors fetch (e.g. an expired session) must not throw an + // unhandled rejection or wipe the stored colors — keep the old ones. + console.warn("Canvas Refined - could not load course colors", e); + return options.custom_cards_3; }); } } @@ -7099,6 +7246,14 @@ function getAssignments() { if (options.assignments_due === true || options.better_todo === true) { assignments = loadPlannerItems(); cardAssignments = preloadAssignmentEls(); + // setupBetterTodo bails (instead of mounting a permanently-empty + // shell) when the data promise isn't ready yet — the common case on + // first run or after an expired session, when the initial fetch takes + // seconds. Once data resolves, try mounting the sidebar in case the + // dashboard has gone quiet since the last MutationObserver burst; + // setupBetterTodo's own guards make this a no-op everywhere it + // shouldn't run (wrong page, quiz, already mounted). + assignments.then(() => setupBetterTodo()); } } @@ -7194,18 +7349,32 @@ async function readPlannerCache() { return null; } -function writePlannerCache(items, lastFullRefresh, activeCourseIds) { +function writePlannerCache(items, lastFullRefresh, activeCourseIds, minRefreshedAt = 0) { try { - // Fire-and-forget; guard both sync throws and (in MV3) promise - // rejection, e.g. a quota error — losing the cache is non-fatal. - const p = chrome.storage.local.set({ [PLANNER_CACHE_KEY]: { items, lastFullRefresh, activeCourseIds } }); - if (p && typeof p.catch === "function") p.catch(() => {}); + // Cross-tab last-writer-wins guard: several Canvas tabs can refresh the + // shared cache concurrently, and a slower tab merging from an older + // snapshot must not overwrite a newer write. Skip when storage already + // holds a cache refreshed after our snapshot was taken. Losing the + // write is non-fatal — the other tab's data is fresher. + 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 */ } } // Fetches every page of /api/v1/planner/items with a due date on/after // `startDate`, following the Link "next" headers until exhausted. Uses the // same session/headers as getData. +// Returns null when the fetch failed (network error, non-OK response such as +// a 401 from an expired session, or a redirect to the login page). Callers +// MUST treat null as "unknown", never as "no items" — writing a failed fetch +// into the cache used to wipe real data whenever the Canvas session had +// expired (the reported "reload after SSO login breaks everything" bug). async function fetchPlannerItemsSince(startDate) { const allItems = []; let url = `${domain}/api/v1/planner/items?start_date=${startDate}&per_page=100`; @@ -7222,9 +7391,9 @@ async function fetchPlannerItemsSince(startDate) { }); data = await response.json(); } catch (e) { - break; + return null; } - if (!response.ok || !Array.isArray(data)) break; + if (!response.ok || !Array.isArray(data)) return null; // Deep-clone via JSON to unwrap Firefox Xray objects so nested props // are mutable (same as getData). try { @@ -7251,11 +7420,25 @@ function mergePlannerItems(cached, fetched, windowStartMs) { return sortAndTrimPlannerItems([...byKey.values()]); } -// Drops items older than the lookback window and returns the list sorted by -// due date ascending (the order the rest of the extension expects). +// Drops items older than the lookback window, dedupes them by planner item +// identity, and returns the list sorted by due date ascending (the order the +// rest of the extension expects). Dedupe happens here so EVERY cache-write +// path is idempotent — previously only mergePlannerItems deduped, so a +// duplicated page from the API (or any other double-insert) could be +// persisted verbatim and render the same assignment twice on cards and in +// the todo list until the next successful window merge happened to clean it. function sortAndTrimPlannerItems(items) { const cutoff = Date.now() - PLANNER_LOOKBACK_DAYS * 86400000; - const trimmed = items.filter(item => new Date(item.plannable_date).getTime() >= cutoff); + // Map insertion keeps the LAST occurrence per key: Canvas returns items + // oldest-first, so within a single (possibly self-overlapping) fetch the + // later copy is the newer data. (mergePlannerItems additionally layers the + // fresh fetch over the cache, so fetched data still wins there.) + 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; } @@ -7282,11 +7465,14 @@ async function loadPlannerItems() { return filterPlannerItemsByActiveCourses(cache.items, cache.activeCourseIds); } // First run (no cache yet): fetch the bounded lookback and the active - // enrollment list in parallel, then cache the filtered result. + // enrollment list in parallel, then cache the filtered result. A failed + // fetch (e.g. expired session) must NOT be cached: cache nothing so the + // next load retries the full fetch instead of serving an empty list. const [items, courseIds] = await Promise.all([ fetchPlannerItemsSince(plannerDateDaysAgo(PLANNER_LOOKBACK_DAYS)), fetchActiveCourseIds(), ]); + if (items === null) return []; const filtered = filterPlannerItemsByActiveCourses( sortAndTrimPlannerItems(items), courseIds ); @@ -7304,7 +7490,14 @@ async function loadPlannerItems() { function schedulePlannerRefresh(cache) { const fullRefreshDue = !cache.lastFullRefresh || (Date.now() - cache.lastFullRefresh > PLANNER_FULL_REFRESH_DAYS * 86400000); + // Only one refresh may run at a time. loadPlannerItems schedules a refresh + // on every cache hit (including Add-Task's re-fetch), and every open Canvas + // tab runs its own; concurrent runs could interleave so a slow stale run + // overwrites a newer incremental write in storage. + if (plannerRefreshRunning) return; const run = async () => { + if (plannerRefreshRunning) return; + plannerRefreshRunning = true; try { const before = plannerFingerprint( filterPlannerItemsByActiveCourses(cache.items, cache.activeCourseIds) @@ -7315,25 +7508,47 @@ function schedulePlannerRefresh(cache) { const idsToStore = courseIds ? [...courseIds] : (cache.activeCourseIds ?? null); let merged; if (fullRefreshDue) { + const fetched = await fetchPlannerItemsSince(plannerDateDaysAgo(PLANNER_LOOKBACK_DAYS)); + // Fetch failed (e.g. expired session): keep the existing cache + // untouched and retry on the next load. Writing the empty + // result used to wipe the cache AND stamp lastFullRefresh, + // locking in the data loss for a week. + if (fetched === null) return; merged = filterPlannerItemsByActiveCourses( - sortAndTrimPlannerItems( - await fetchPlannerItemsSince(plannerDateDaysAgo(PLANNER_LOOKBACK_DAYS)) - ), + sortAndTrimPlannerItems(fetched), active ); - writePlannerCache(merged, Date.now(), idsToStore); + writePlannerCache(merged, Date.now(), idsToStore, cache.refreshedAt || 0); } else { const windowStart = plannerDateDaysAgo(PLANNER_WINDOW_DAYS); const fetched = await fetchPlannerItemsSince(windowStart); + // Fetch failed: skip the merge entirely. mergePlannerItems + // drops cached items inside the window before adding the fresh + // ones, so merging an empty/failed fetch would silently delete + // the most recent two weeks from the persisted cache. + if (fetched === null) return; merged = filterPlannerItemsByActiveCourses( - mergePlannerItems(cache.items, fetched, Date.parse(windowStart)), + // Drop threshold = the window start's LOCAL midnight, not + // UTC midnight. The window start is a date-only string + // (Date.parse → UTC midnight) while Canvas evaluates + // start_date in the user's timezone: for a user west of + // UTC, an item due between UTC midnight and local midnight + // counts as "inside the window" by UTC but is NOT returned + // by a fetch filtered from local midnight — dropping it at + // the UTC threshold silently deleted it until the weekly + // full walk. Thresholding at local midnight keeps exactly + // the items the fetch may not return; any harmless overlap + // is removed by mergePlannerItems' key dedupe (fresh wins). + mergePlannerItems(cache.items, fetched, Date.parse(windowStart + "T00:00:00")), active ); - writePlannerCache(merged, cache.lastFullRefresh, idsToStore); + 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") { @@ -9198,6 +9413,13 @@ async function getData(url) { 'Accept': 'application/json' } }); + // Fail loudly on HTTP errors (e.g. an expired session returning 401 or a + // redirect to the login page). Previously non-OK responses were returned + // as-is, so callers received an error object/HTML and crashed later in + // confusing ways (or silently rendered wrong data). + if (!response.ok) { + throw new Error(`Canvas API request failed (${response.status})`); + } let data = await response.json(); // Deep-clone via JSON to unwrap Firefox Xray objects so nested props are mutable. try { diff --git a/js/popup.js b/js/popup.js index 00585d1..0472aef 100644 --- a/js/popup.js +++ b/js/popup.js @@ -57,7 +57,7 @@ const localSwitches = []; const exportDarkSchedule = ["auto_dark", "auto_dark_start", "auto_dark_end", "device_dark"]; const exportCardColorToggles = ["gradient_cards", "disable_color_overlay"]; const exportCardStyles = ["customCardStyles", "imageSize", "cardRoundness", "imageRoundness", "cardSpacing", "cardWidth", "cardHeight", "cardPadding"]; -const exportLayout = ["full_width", "center_cards", "condensed_cards", "equal_height_cards", "remlogo", "hide_new_canvas", "tab_icons"]; +const exportLayout = ["full_width", "center_cards", "condensed_cards", "equal_height_cards", "remlogo", "hide_new_canvas", "hide_navbar", "tab_icons"]; const exportSidebar = ["better_sidebar", "sidebar_scale"]; const exportTodo = ["better_todo", "todo_hide_feedback", "todo_hide_read", "todo_full_height", "todo_confetti", "todo_progress_rings", "todo_timeframe", "todo_hr24", "todo_separate_scrollbar", "todo_alternate_colors", "todo_ignore_card_colors", "todo_remove_icons", "hover_preview"]; const exportGpa = ["gpa_calc", "gpa_calc_prepend", "gpa_calc_cumulative", "gpa_calc_weighted"]; @@ -158,6 +158,7 @@ const defaultOptions = { "equal_height_cards": false, "hide_new_canvas": true, "hide_sequence_footer": false, + "hide_navbar": false, "grade_analytics_zones": false, "quiz_safe_mode": false, "dark_mode_fix": [], @@ -942,6 +943,7 @@ function setup() { const menu = { switches: syncedSwitches, checkboxes: [ + "hide_navbar", "browser_show_likes", "gpa_calc_weighted", "gpa_calc_cumulative", diff --git a/manifest.json b/manifest.json index f0e75f3..dd38f50 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Canvas Refined", "description": "Even More Feature packed extension for Canvas.", - "version": "7.0.3", + "version": "7.0.4", "icons": { "16": "icon/icon-16.png", "32": "icon/icon-32.png",