mirror of
https://github.com/GuySandler/CanvasRefined.git
synced 2026-09-20 12:24:52 +02:00
caching todo list in hopes of better performance
This commit is contained in:
parent
16ed09ff43
commit
b7a819b9fc
@ -1183,6 +1183,22 @@
|
||||
<span>This will reset all settings to the default. You will need to refresh the page after.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-container">
|
||||
<h3 class="header-small">Clear planner cache</h3>
|
||||
<div style="display: flex; gap: 10px; align-items: center;">
|
||||
<button class="big-button" id="planner-cache-clear-btn">Go</button>
|
||||
<span>This deletes the cached to-do list data (assignments, announcements, reminders) so it is re-fetched fresh from Canvas. Open Canvas tabs update automatically.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-container">
|
||||
<h3 class="header-small">Storage usage</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 3px;">
|
||||
<span class="sub-text">Settings (sync): <span id="storage-usage-sync">…</span></span>
|
||||
<span class="sub-text">Planner cache: <span id="storage-usage-cache">…</span></span>
|
||||
<span class="sub-text">Other local data: <span id="storage-usage-local">…</span></span>
|
||||
<span class="sub-text">Local quota: <span id="storage-usage-quota">…</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-container">
|
||||
<h3 class="header-small">Storage Dump</h3>
|
||||
<div style="margin-bottom: 10px">
|
||||
|
||||
265
js/content.js
265
js/content.js
@ -1623,6 +1623,19 @@ function recieveMessage(request, sender, sendResponse) {
|
||||
case ("inspect"): sendResponse(inspectDarkMode(true)); break;
|
||||
case ("fixdm"): sendResponse(runDarkModeFixer(true)); break;
|
||||
case ("updateBackground"): applyCustomBackground(); sendResponse(true); break;
|
||||
case ("clearPlannerCache"):
|
||||
// "Clear planner cache" (Report issue tab in the popup): drop
|
||||
// the cache, re-fetch fresh planner data, and re-render every
|
||||
// consumer in this tab.
|
||||
(async () => {
|
||||
try { await chrome.storage.local.remove(PLANNER_CACHE_KEY); } catch (e) { /* nothing stored */ }
|
||||
if (options.assignments_due === true || options.better_todo === true) {
|
||||
const items = await loadPlannerItems();
|
||||
refreshPlannerConsumers(items);
|
||||
}
|
||||
sendResponse(true);
|
||||
})();
|
||||
return true; // keep the message channel open for async sendResponse
|
||||
default: sendResponse(true);
|
||||
}
|
||||
}
|
||||
@ -6991,32 +7004,118 @@ function changeFavicon() {
|
||||
|
||||
function getAssignments() {
|
||||
if (options.assignments_due === true || options.better_todo === true) {
|
||||
// Fetch planner items from as far back as possible so overdue tasks
|
||||
// always appear, no matter how long ago they were due. The planner
|
||||
// API defaults start_date to "now" (which would hide every overdue
|
||||
// item), so a far-past start date is required. Canvas returns planner
|
||||
// items oldest-first in pages, so every page must be followed — a
|
||||
// single request would only return the oldest page and silently drop
|
||||
// all recent items.
|
||||
assignments = getAllPlannerItems();
|
||||
assignments = loadPlannerItems();
|
||||
cardAssignments = preloadAssignmentEls();
|
||||
}
|
||||
}
|
||||
|
||||
// Far-past start date for the planner items fetch. Concluded courses are
|
||||
// excluded by the API by default, so this only pulls history from the user's
|
||||
// currently active courses, which keeps the payload bounded.
|
||||
const PLANNER_START_DATE = "2000-01-01";
|
||||
// ===================== Planner items (cached) =====================
|
||||
// The planner list powers the to-do list, dashboard cards, reminders, and
|
||||
// progress rings. It used to be re-fetched from 2000-01-01 on every page
|
||||
// load — dozens of sequential requests for students with long histories,
|
||||
// which was the main cause of the 1.0.0 slow-load reports. Now:
|
||||
// - items are cached in chrome.storage.local and served instantly,
|
||||
// - each page load only fetches a small recent window (14 days),
|
||||
// - a full lookback walk (max 1 year) runs at most once a week, and only
|
||||
// while the tab is idle, and
|
||||
// - items are limited to courses the student is currently enrolled in.
|
||||
// Canvas returns planner items oldest-first, so any fetch that starts far
|
||||
// in the past must page through everything newer; capping the lookback at
|
||||
// one year bounds both the request count and the cache size. Overdue items
|
||||
// older than a year are intentionally dropped. (Concluded courses are
|
||||
// excluded by the planner API by default, so this is history from the
|
||||
// user's active courses only.)
|
||||
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
|
||||
// Hard cap on pages fetched (50 pages * 100 items = 5000 items) as a safety
|
||||
// net against a malformed/misbehaving next link.
|
||||
const PLANNER_MAX_PAGES = 50;
|
||||
|
||||
// Fetches every page of /api/v1/planner/items since PLANNER_START_DATE.
|
||||
// Uses the same session/headers as getData but follows the Link "next"
|
||||
// headers until exhausted.
|
||||
async function getAllPlannerItems() {
|
||||
function plannerDateDaysAgo(days) {
|
||||
return new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// Stable identity for a planner item (same pair the complete-toggle uses).
|
||||
function plannerItemKey(item) {
|
||||
return `${item.plannable_type}|${item.plannable_id}`;
|
||||
}
|
||||
|
||||
// ===================== Active-enrollment filter =====================
|
||||
// Reported bug: the to-do list showed assignments/announcements from old,
|
||||
// concluded classes. The planner API excludes concluded *courses*, but not
|
||||
// courses where only the student's *enrollment* has concluded (common at
|
||||
// term boundaries), so those items still leak through. This fetches the ids
|
||||
// of the user's currently-active course enrollments so planner items can be
|
||||
// filtered to real, current classes. 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`;
|
||||
// 10 pages * 100 courses is far beyond any real enrollment count; just a
|
||||
// safety net against a malformed next link.
|
||||
for (let page = 0; page < 10 && url; page++) {
|
||||
let response;
|
||||
let data;
|
||||
try {
|
||||
response = await fetch(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;
|
||||
}
|
||||
|
||||
// Keeps only items belonging to courses in `courseIds` (Set or array).
|
||||
// null/undefined means "unknown" (the enrollment fetch failed), so no
|
||||
// filtering is applied. Personal items with no course context (planner
|
||||
// notes, non-course contexts like 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) {
|
||||
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(() => {});
|
||||
} 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.
|
||||
async function fetchPlannerItemsSince(startDate) {
|
||||
const allItems = [];
|
||||
let url = `${domain}/api/v1/planner/items?start_date=${PLANNER_START_DATE}&per_page=100`;
|
||||
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;
|
||||
@ -7036,14 +7135,140 @@ async function getAllPlannerItems() {
|
||||
// Deep-clone via JSON to unwrap Firefox Xray objects so nested props
|
||||
// are mutable (same as getData).
|
||||
try {
|
||||
data = JSON.parse(JSON.stringify(data));
|
||||
} catch (_) { /* keep original */ }
|
||||
allItems.push(...data);
|
||||
allItems.push(...JSON.parse(JSON.stringify(data)));
|
||||
} catch (_) {
|
||||
allItems.push(...data);
|
||||
}
|
||||
url = getNextPageUrl(response.headers.get("Link"));
|
||||
}
|
||||
return allItems;
|
||||
}
|
||||
|
||||
// Merges a fresh window fetch into the cached list. Cached items whose due
|
||||
// date falls inside the fetched window are replaced wholesale (catches new,
|
||||
// changed, and removed items); older cached items are kept as-is.
|
||||
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()]);
|
||||
}
|
||||
|
||||
// Drops items older than the lookback window and returns the list sorted by
|
||||
// due date ascending (the order the rest of the extension expects).
|
||||
function sortAndTrimPlannerItems(items) {
|
||||
const cutoff = Date.now() - PLANNER_LOOKBACK_DAYS * 86400000;
|
||||
const trimmed = items.filter(item => new Date(item.plannable_date).getTime() >= cutoff);
|
||||
trimmed.sort((a, b) => new Date(a.plannable_date) - new Date(b.plannable_date));
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Cheap change signature so consumers only re-render when something they
|
||||
// display actually moved: due date, submitted/graded/complete state, or an
|
||||
// announcement's read state.
|
||||
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("|");
|
||||
}
|
||||
|
||||
// Entry point for the planner data. Resolves instantly from cache when
|
||||
// present; otherwise does one bounded (1-year) fetch on the critical path so
|
||||
// the to-do list isn't empty on first run, then caches the result. In both
|
||||
// cases items are limited to the student's currently-active courses.
|
||||
async function loadPlannerItems() {
|
||||
const cache = await readPlannerCache();
|
||||
if (cache) {
|
||||
schedulePlannerRefresh(cache);
|
||||
// Serve instantly, filtered with the last known enrollment list;
|
||||
// the background refresh below brings that list up to date.
|
||||
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.
|
||||
const [items, courseIds] = await Promise.all([
|
||||
fetchPlannerItemsSince(plannerDateDaysAgo(PLANNER_LOOKBACK_DAYS)),
|
||||
fetchActiveCourseIds(),
|
||||
]);
|
||||
const filtered = filterPlannerItemsByActiveCourses(
|
||||
sortAndTrimPlannerItems(items), courseIds
|
||||
);
|
||||
writePlannerCache(filtered, Date.now(), courseIds ? [...courseIds] : null);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// Background cache refresh: a small recent window on every load (catches new
|
||||
// items and recent changes), plus a full lookback walk at most once every
|
||||
// PLANNER_FULL_REFRESH_DAYS (catches state changes on older items, e.g. a
|
||||
// months-old assignment finally being submitted). The active-enrollment list
|
||||
// is refreshed on every pass so concluded classes drop out promptly rather
|
||||
// than waiting for the weekly walk. Runs while the tab is idle so it never
|
||||
// competes with page load.
|
||||
function schedulePlannerRefresh(cache) {
|
||||
const fullRefreshDue = !cache.lastFullRefresh ||
|
||||
(Date.now() - cache.lastFullRefresh > PLANNER_FULL_REFRESH_DAYS * 86400000);
|
||||
const run = async () => {
|
||||
try {
|
||||
const before = plannerFingerprint(
|
||||
filterPlannerItemsByActiveCourses(cache.items, cache.activeCourseIds)
|
||||
);
|
||||
const courseIds = await fetchActiveCourseIds();
|
||||
// On enrollment-fetch failure, fall back to the cached list.
|
||||
const active = courseIds ?? (cache.activeCourseIds ?? null);
|
||||
const idsToStore = courseIds ? [...courseIds] : (cache.activeCourseIds ?? null);
|
||||
let merged;
|
||||
if (fullRefreshDue) {
|
||||
merged = filterPlannerItemsByActiveCourses(
|
||||
sortAndTrimPlannerItems(
|
||||
await fetchPlannerItemsSince(plannerDateDaysAgo(PLANNER_LOOKBACK_DAYS))
|
||||
),
|
||||
active
|
||||
);
|
||||
writePlannerCache(merged, Date.now(), idsToStore);
|
||||
} else {
|
||||
const windowStart = plannerDateDaysAgo(PLANNER_WINDOW_DAYS);
|
||||
const fetched = await fetchPlannerItemsSince(windowStart);
|
||||
merged = filterPlannerItemsByActiveCourses(
|
||||
mergePlannerItems(cache.items, fetched, Date.parse(windowStart)),
|
||||
active
|
||||
);
|
||||
writePlannerCache(merged, cache.lastFullRefresh, idsToStore);
|
||||
}
|
||||
if (plannerFingerprint(merged) !== before) refreshPlannerConsumers(merged);
|
||||
} catch (e) {
|
||||
console.warn("planner refresh failed", e);
|
||||
}
|
||||
};
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
requestIdleCallback(() => run(), { timeout: 20000 });
|
||||
} else {
|
||||
setTimeout(run, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-renders everything that displays planner items after the background
|
||||
// refresh produced new data. Mirrors the option-change handlers so all the
|
||||
// existing consumers re-attach to the updated promise.
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
// Extracts the rel="next" URL from a Canvas pagination Link header, or
|
||||
// returns null when on the last page.
|
||||
function getNextPageUrl(linkHeader) {
|
||||
|
||||
86
js/popup.js
86
js/popup.js
@ -1249,8 +1249,94 @@ function setup() {
|
||||
// activate storage reset button
|
||||
document.querySelector("#storage-reset-btn").addEventListener("click", () => {
|
||||
chrome.storage.sync.set(defaultOptions["sync"]);
|
||||
updateStorageUsage();
|
||||
});
|
||||
|
||||
// activate planner cache clear button (Report issue tab). Clears the
|
||||
// cached planner items locally, then tells any open Canvas tabs to
|
||||
// re-fetch fresh data so the clear takes effect without a refresh.
|
||||
// Keep the key in sync with PLANNER_CACHE_KEY in content.js.
|
||||
document.querySelector("#planner-cache-clear-btn").addEventListener("click", async () => {
|
||||
displayAlert(false, "Planner cache cleared.");
|
||||
try {
|
||||
await chrome.storage.local.remove("planner_cache_v1");
|
||||
} catch (e) { /* nothing stored yet */ }
|
||||
await sendFromPopup("clearPlannerCache");
|
||||
updateStorageUsage();
|
||||
});
|
||||
|
||||
// ---- Storage usage display (Report issue tab) ----
|
||||
// Shows how much space the extension's settings, planner cache, and
|
||||
// other local data take, plus the storage quota available to it.
|
||||
function formatStorageBytes(bytes) {
|
||||
if (bytes == null) return "unknown";
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / 1048576).toFixed(2) + " MB";
|
||||
}
|
||||
|
||||
// getBytesInUse is exact but not implemented everywhere (older Firefox),
|
||||
// so fall back to measuring the serialized data as a Blob.
|
||||
async function storageBytesInUse(area, keys) {
|
||||
try {
|
||||
const bytes = await area.getBytesInUse(keys ?? null);
|
||||
if (typeof bytes === "number") return bytes;
|
||||
} catch (e) { /* not supported */ }
|
||||
try {
|
||||
const data = await area.get(keys ?? null);
|
||||
return new Blob([JSON.stringify(data)]).size;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStorageUsage() {
|
||||
const set = (id, text) => {
|
||||
const el = document.querySelector(id);
|
||||
if (el) el.textContent = text;
|
||||
};
|
||||
|
||||
// Sync settings vs the 100 KB chrome.storage.sync quota.
|
||||
const syncBytes = await storageBytesInUse(chrome.storage.sync);
|
||||
set("#storage-usage-sync", syncBytes == null ? "unknown" : formatStorageBytes(syncBytes) + " of 100 KB quota");
|
||||
|
||||
// Planner cache: size plus item count, so users can see what the
|
||||
// to-do cache actually costs (and that clearing it worked).
|
||||
const cacheBytes = await storageBytesInUse(chrome.storage.local, "planner_cache_v1");
|
||||
let cacheText = "empty";
|
||||
if (cacheBytes) {
|
||||
try {
|
||||
const result = await chrome.storage.local.get("planner_cache_v1");
|
||||
const count = result?.["planner_cache_v1"]?.items?.length;
|
||||
cacheText = formatStorageBytes(cacheBytes) + (typeof count === "number" ? ` (${count} items)` : "");
|
||||
} catch (e) {
|
||||
cacheText = formatStorageBytes(cacheBytes);
|
||||
}
|
||||
}
|
||||
set("#storage-usage-cache", cacheText);
|
||||
|
||||
// Everything else in local storage (settings the extension keeps
|
||||
// locally, state like the analytics toggle, etc.).
|
||||
const localBytes = await storageBytesInUse(chrome.storage.local);
|
||||
if (localBytes == null) {
|
||||
set("#storage-usage-local", "unknown");
|
||||
} else {
|
||||
const other = cacheBytes != null ? Math.max(localBytes - cacheBytes, 0) : null;
|
||||
set("#storage-usage-local", other == null ? formatStorageBytes(localBytes) : formatStorageBytes(other));
|
||||
}
|
||||
|
||||
// Quota available to the extension. With the unlimitedStorage
|
||||
// permission this is a large slice of free disk space, so it reads
|
||||
// big — that is expected.
|
||||
try {
|
||||
const est = await navigator.storage.estimate();
|
||||
if (est && est.quota) {
|
||||
set("#storage-usage-quota", formatStorageBytes(est.quota) + " available");
|
||||
}
|
||||
} catch (e) { /* estimate unsupported */ }
|
||||
}
|
||||
updateStorageUsage();
|
||||
|
||||
// activate custom url input
|
||||
document.querySelector('#customDomain').addEventListener('input', function () {
|
||||
let domains = this.value.split(",");
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Canvas Refined",
|
||||
"description": "Even More Feature packed extension for Canvas.",
|
||||
"version": "7.0.0",
|
||||
"version": "7.0.1",
|
||||
"icons": {
|
||||
"16": "icon/icon-16.png",
|
||||
"32": "icon/icon-32.png",
|
||||
@ -30,7 +30,7 @@
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"permissions": ["storage"],
|
||||
"permissions": ["storage", "unlimitedStorage"],
|
||||
"default_locale": "en",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user