From 933a5a506bcec5d0f4e6860be37a5ffb41a1cde2 Mon Sep 17 00:00:00 2001 From: Guy Sandler Date: Wed, 26 Aug 2026 21:31:33 -0700 Subject: [PATCH] cool feature 1 --- README.md | 2 +- _locales/en/messages.json | 3 + css/content.css | 103 ++++++++ html/popup.html | 8 + js/background.js | 1 + js/content.js | 529 ++++++++++++++++++++++++++++++++++++++ js/popup.js | 2 +- 7 files changed, 646 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b5ef572..ef9e3f9 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Canvas Refined adds more with more to come! - Popup UI revamp - NEW Better todo list - Better sidebar +- Global Canvas Search (search modules & assignments across all courses; Ctrl/Cmd+K) - Simplified UI - Some options won't show up when not needed - Markdown in dashboard notes @@ -76,7 +77,6 @@ Canvas Refined adds more with more to come! - mail assistant + ui revamp - better calender (+ calender sync) - better what if grade -- global canvas search - fix darkmode fixer (and automatic) - grade history with graph diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 233ce85..4bad566 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -304,5 +304,8 @@ }, "hide_new_canvas": { "message": "Hide New Dashboard" + }, + "global_search": { + "message": "Global Canvas Search" } } \ No newline at end of file diff --git a/css/content.css b/css/content.css index b6464c4..be25f89 100644 --- a/css/content.css +++ b/css/content.css @@ -266,3 +266,106 @@ color: #000 !important; } .canvasrefined-quiz-safe-dismiss:hover { background: #ececec; } + +/* ===== Global Canvas Search ===== */ +.canvasrefined-gs-fab { + position: fixed; right: 18px; bottom: 18px; z-index: 2147483000; + width: 46px; height: 46px; border-radius: 50%; border: none; cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + color: #fff; background: var(--bclinks, #0072b5); + box-shadow: 0 4px 14px rgba(0,0,0,.28); + transition: transform .12s ease, filter .12s ease; +} +.canvasrefined-gs-fab:hover { transform: translateY(-2px) scale(1.04); filter: brightness(1.08); } +.canvasrefined-gs-fab:active { transform: translateY(0) scale(0.97); } + +/* Dashboard header trigger — sits just left of the \u22ee options button. */ +.canvasrefined-gs-header-btn { + display: inline-flex; align-items: center; gap: 6px; + margin-right: 10px; padding: 6px 12px; + border: 1px solid var(--bcborders, #c7cdd1); border-radius: 6px; + background: var(--bcbackground-2, #f5f7f8); color: var(--bctext-1, #5b6770); + font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; + line-height: 1; white-space: nowrap; + transition: background .12s ease, border-color .12s ease; +} +.canvasrefined-gs-header-btn:hover { background: var(--bcbackground-1, #ececec); border-color: var(--bctext-2, #9aa3ab); } +.canvasrefined-gs-header-btn:active { transform: translateY(1px); } +.canvasrefined-gs-header-btn-label { line-height: 1; } +@media (max-width: 768px) { .canvasrefined-gs-header-btn-label { display: none; } } + +.canvasrefined-gs-modal { + position: fixed; inset: 0; z-index: 2147483100; + background: rgba(0,0,0,.42); + display: flex; align-items: flex-start; justify-content: center; + padding-top: 12vh; animation: canvasrefined-gs-fade .12s ease; +} +@keyframes canvasrefined-gs-fade { from { opacity: 0; } to { opacity: 1; } } +.canvasrefined-gs-card { + width: min(640px, 92vw); max-height: 70vh; display: flex; flex-direction: column; + background: var(--bcbackground-0, #fff); color: var(--bctext-0, #2d3b45); + border: 1px solid var(--bcborders, #c7cdd1); border-radius: 12px; + box-shadow: 0 12px 40px rgba(0,0,0,.35); overflow: hidden; +} +.canvasrefined-gs-input-row { + display: flex; align-items: center; gap: 10px; padding: 12px 14px; + border-bottom: 1px solid var(--bcborders, #e2e6e8); + background: var(--bcbackground-1, #fff); +} +.canvasrefined-gs-input-icon { color: var(--bctext-2, #9aa3ab); flex-shrink: 0; } +.canvasrefined-gs-input { + flex: 1; border: none; outline: none; background: transparent; + font: inherit; font-size: 16px; color: var(--bctext-0, #2d3b45); +} +.canvasrefined-gs-input::placeholder { color: var(--bctext-2, #9aa3ab); } +.canvasrefined-gs-close { + border: 1px solid var(--bcborders, #c7cdd1); border-radius: 6px; + background: var(--bcbackground-2, #f5f7f8); color: var(--bctext-1, #5b6770); + font: inherit; font-size: 12px; font-weight: 600; padding: 3px 8px; cursor: pointer; +} +.canvasrefined-gs-close:hover { background: var(--bcbackground-2, #ececec); } +.canvasrefined-gs-results { flex: 1; overflow-y: auto; padding: 6px; } +.canvasrefined-gs-hint, .canvasrefined-gs-loading { + padding: 18px 14px; text-align: center; color: var(--bctext-2, #9aa3ab); font-size: 14px; +} +.canvasrefined-gs-row { + display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; + cursor: pointer; border: 1px solid transparent; +} +.canvasrefined-gs-row:hover { background: var(--bcbackground-2, rgba(0,0,0,.04)); } +.canvasrefined-gs-row.canvasrefined-gs-selected { + background: color-mix(in srgb, var(--bclinks, #0072b5) 16%, transparent); + border-color: color-mix(in srgb, var(--bclinks, #0072b5) 40%, transparent); +} +.canvasrefined-gs-row-main { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; } +.canvasrefined-gs-title { + font-size: 14px; font-weight: 600; color: var(--bctext-0, #2d3b45); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.canvasrefined-gs-course { + font-size: 12px; color: var(--bctext-2, #9aa3ab); white-space: nowrap; flex-shrink: 0; + max-width: 38%; overflow: hidden; text-overflow: ellipsis; +} +.canvasrefined-gs-type { + font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; + padding: 2px 7px; border-radius: 6px; flex-shrink: 0; + background: var(--bcbackground-2, #eef0f2); color: var(--bctext-1, #5b6770); + border: 1px solid var(--bcborders, #dde1e3); +} +.canvasrefined-gs-type-assignment { background: #e3f1fb; color: #0b5d8c; border-color: #b9d9ef; } +.canvasrefined-gs-type-quiz { background: #fde9d9; color: #9a5300; border-color: #f6cdac; } +.canvasrefined-gs-type-discussion { background: #e6f4ea; color: #1a7a3c; border-color: #c5e6cf; } +.canvasrefined-gs-type-module { background: #efe7fb; color: #5b2a9e; border-color: #d8c6f1; } +.canvasrefined-gs-type-page { background: #f1f3f5; color: #444; border-color: #dde1e3; } +.canvasrefined-gs-type-file { background: #f1f3f5; color: #444; border-color: #dde1e3; } +.canvasrefined-gs-type-link, .canvasrefined-gs-type-external-tool { background: #fff4d6; color: #7a5b00; border-color: #f1dfab; } +.canvasrefined-gs-footer { + display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 14px; font-size: 12px; + color: var(--bctext-2, #9aa3ab); border-top: 1px solid var(--bcborders, #e2e6e8); + background: var(--bcbackground-1, #fff); +} +.canvasrefined-gs-footer kbd { + font: inherit; font-size: 11px; background: var(--bcbackground-2, #eef0f2); + border: 1px solid var(--bcborders, #c7cdd1); border-bottom-width: 2px; border-radius: 4px; + padding: 0 4px; color: var(--bctext-1, #5b6770); +} diff --git a/html/popup.html b/html/popup.html index 50a9d62..f56e6b2 100644 --- a/html/popup.html +++ b/html/popup.html @@ -203,6 +203,14 @@ Turns off most Canvas Refined features on quiz pages so you get the default Canvas quiz experience. +
diff --git a/js/background.js b/js/background.js index d1bfa25..f303b54 100644 --- a/js/background.js +++ b/js/background.js @@ -115,6 +115,7 @@ chrome.runtime.onInstalled.addListener(function () { "sidebar_opacity": 100, "bg_blur": 8, "sidebar_blur": 0, + "global_search": false, } }; diff --git a/js/content.js b/js/content.js index f828908..5c24828 100644 --- a/js/content.js +++ b/js/content.js @@ -685,6 +685,8 @@ function startExtension() { setupQuizSafeModeBanner(); + setupGlobalSearch(); + setTimeout(() => runDarkModeFixer(false), 800); setTimeout(() => runDarkModeFixer(false), 4500); @@ -823,6 +825,13 @@ function applyOptionsChanges(changes) { case "hide_new_canvas": watchNewCanvasButton(); break; + case "global_search": + if (options.global_search) { + setupGlobalSearch(); + } else { + removeGlobalSearch(); + } + break; case "customBackgroundScale": applyCustomBackground(); break; @@ -5522,6 +5531,526 @@ function injectQuizSafeModeBanner(safeModeOn) { } + +// ============================================================================= +// Global Canvas Search +// Search across all of the user's courses for modules, module items, and +// assignments, then jump straight to them. Triggered by a floating search +// button (bottom-right) or Ctrl/Cmd+K. +// ============================================================================= + +let globalSearchIndex = null; // [{type,title,course,courseId,url}] +let globalSearchIndexPromise = null; // in-flight build so concurrent opens share one fetch +let globalSearchIndexAt = 0; // ms timestamp of last successful build +const GLOBAL_SEARCH_INDEX_TTL = 10 * 60 * 1000; // 10 minutes +const GLOBAL_SEARCH_STORAGE_KEY = "canvasrefined_global_search_index"; +let _gsShortcutBound = false; + +function setupGlobalSearch() { + if (options.global_search !== true) return; + // Rebuild the index fresh on every page load so newly-concluded/hidden + // courses never linger from a previous session's cache. + invalidateGlobalSearchIndex(); + ensureGlobalSearchButton(); + ensureGlobalSearchShortcut(); +} + +function removeGlobalSearch() { + document.getElementById("canvasrefined-global-search-btn")?.remove(); + document.getElementById("canvasrefined-global-search-header-btn")?.remove(); + closeGlobalSearchModal(); + if (_gsPlacementObserver) { _gsPlacementObserver.disconnect(); _gsPlacementObserver = null; } + if (_gsShortcutBound) { + document.removeEventListener("keydown", onGlobalSearchShortcut, true); + _gsShortcutBound = false; + } +} + +// Placement: on the dashboard the trigger lives in the header (between the +// "Dashboard" heading and the options menu, right-aligned). Everywhere else it +// falls back to a floating action button. A rAF-debounced MutationObserver +// re-evaluates placement as Canvas renders/SPA-navigates. +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; + const headerActions = document.querySelector(".ic-Dashboard-header__actions"); + if (isDashboardPage() && headerActions) { + ensureGlobalSearchHeaderButton(headerActions); + document.getElementById("canvasrefined-global-search-btn")?.remove(); + } else { + document.getElementById("canvasrefined-global-search-header-btn")?.remove(); + ensureGlobalSearchFab(); + } +} + +function ensureGlobalSearchFab() { + if (document.getElementById("canvasrefined-global-search-btn")) return; + const btn = document.createElement("button"); + btn.id = "canvasrefined-global-search-btn"; + btn.type = "button"; + btn.className = "canvasrefined-gs-fab"; + btn.title = "Search Canvas (Ctrl+K)"; + btn.setAttribute("aria-label", "Search Canvas"); + btn.innerHTML = ``; + btn.addEventListener("click", openGlobalSearchModal); + if (document.body) { + document.body.appendChild(btn); + } else { + const wait = new MutationObserver(() => { + if (document.body) { document.body.appendChild(btn); wait.disconnect(); } + }); + wait.observe(document.documentElement, { childList: true }); + } +} + +function ensureGlobalSearchHeaderButton(headerActions) { + if (headerActions.querySelector("#canvasrefined-global-search-header-btn")) return; + const btn = document.createElement("button"); + btn.id = "canvasrefined-global-search-header-btn"; + btn.type = "button"; + btn.className = "canvasrefined-gs-header-btn"; + btn.title = "Search Canvas (Ctrl+K)"; + btn.setAttribute("aria-label", "Search Canvas"); + btn.innerHTML = `Search`; + btn.addEventListener("click", openGlobalSearchModal); + // Insert as the first child of the actions row so it sits just to the left + // of the "Dashboard Options" (⋯) button, right-aligned with it. + headerActions.insertBefore(btn, headerActions.firstChild); +} + +function ensureGlobalSearchShortcut() { + if (_gsShortcutBound) return; + document.addEventListener("keydown", onGlobalSearchShortcut, true); + _gsShortcutBound = true; +} + +function onGlobalSearchShortcut(e) { + // Ctrl/Cmd+K toggles the search modal. Ignore when a modal is already open + // and the user is typing in its input (handled by the modal's own listener). + if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) { + const modal = document.getElementById("canvasrefined-global-search-modal"); + if (modal && modal.dataset.open === "true") { + closeGlobalSearchModal(); + } else { + e.preventDefault(); + openGlobalSearchModal(); + } + } +} + +function openGlobalSearchModal() { + if (document.getElementById("canvasrefined-global-search-modal")) return; + + const modal = document.createElement("div"); + modal.id = "canvasrefined-global-search-modal"; + modal.className = "canvasrefined-gs-modal"; + modal.dataset.open = "true"; + modal.innerHTML = ` + `; + document.body.appendChild(modal); + + const input = modal.querySelector("#canvasrefined-gs-input"); + const resultsEl = modal.querySelector("#canvasrefined-gs-results"); + const closeBtn = modal.querySelector("#canvasrefined-gs-close"); + let selected = -1; + let currentResults = []; + + closeBtn.addEventListener("click", closeGlobalSearchModal); + modal.addEventListener("mousedown", (e) => { if (e.target === modal) closeGlobalSearchModal(); }); + + // Escape closes; arrows + enter navigate. Bound on capture so we win over + // the global Ctrl+K toggle. + modal.addEventListener("keydown", (e) => { + if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closeGlobalSearchModal(); return; } + if (e.key === "ArrowDown") { + e.preventDefault(); + selected = Math.min(selected + 1, currentResults.length - 1); + renderGlobalSearchSelection(resultsEl, selected); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + selected = Math.max(selected - 1, 0); + renderGlobalSearchSelection(resultsEl, selected); + } else if (e.key === "Enter") { + e.preventDefault(); + const item = currentResults[selected]; + if (item) openGlobalSearchResult(item, e.ctrlKey || e.metaKey); + } + }); + // Stop the global Ctrl+K handler from closing the modal while typing. + input.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) e.stopPropagation(); + }); + + let debounce = null; + input.addEventListener("input", () => { + clearTimeout(debounce); + debounce = setTimeout(() => runGlobalSearch(input.value.trim(), resultsEl).then(res => { + currentResults = res; + selected = res.length ? 0 : -1; + renderGlobalSearchSelection(resultsEl, selected); + }), 120); + }); + + // Block the page scroll while the modal is open. + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + modal._restoreOverflow = () => { document.body.style.overflow = prevOverflow; }; + + requestAnimationFrame(() => input.focus()); + // Kick off indexing immediately so the first keystroke is fast. + ensureGlobalSearchIndex(); + // Render an initial hint. + resultsEl.innerHTML = `
Start typing to search your modules and assignments.
`; +} + +function closeGlobalSearchModal() { + const modal = document.getElementById("canvasrefined-global-search-modal"); + if (!modal) return; + if (typeof modal._restoreOverflow === "function") modal._restoreOverflow(); + modal.remove(); +} + +function openGlobalSearchResult(item, newTab) { + if (!item || !item.url) return; + if (newTab) { + // Opening in a new tab keeps the search menu open so the user can keep + // searching. Refocus the input for the next keystroke. + window.open(item.url, "_blank", "noopener"); + const input = document.getElementById("canvasrefined-gs-input"); + if (input) input.focus(); + } else { + closeGlobalSearchModal(); + window.location.href = item.url; + } +} + +function renderGlobalSearchSelection(resultsEl, selected) { + const rows = resultsEl.querySelectorAll(".canvasrefined-gs-row"); + rows.forEach((row, i) => { + if (i === selected) { row.classList.add("canvasrefined-gs-selected"); row.scrollIntoView({ block: "nearest" }); } + else row.classList.remove("canvasrefined-gs-selected"); + }); +} + +// --- Indexing --------------------------------------------------------------- + +// Drop any cached index so the next access rebuilds it from the API. Called +// at setup time so every page load starts fresh. +function invalidateGlobalSearchIndex() { + globalSearchIndex = null; + globalSearchIndexAt = 0; + globalSearchIndexPromise = null; + try { chrome.storage.local.remove(GLOBAL_SEARCH_STORAGE_KEY); } catch (_) { /* ignore */ } +} + +async function ensureGlobalSearchIndex() { + // A per-session in-memory build is shared across opens so we don't refetch + // on every keystroke, but we never serve a persisted cache across reloads. + if (globalSearchIndex && (Date.now() - globalSearchIndexAt) < GLOBAL_SEARCH_INDEX_TTL) { + return globalSearchIndex; + } + if (globalSearchIndexPromise) return globalSearchIndexPromise; + + globalSearchIndexPromise = (async () => { + const index = await buildGlobalSearchIndex(); + globalSearchIndex = index; + globalSearchIndexAt = Date.now(); + return index; + })(); + + try { + return await globalSearchIndexPromise; + } finally { + globalSearchIndexPromise = null; + } +} + +async function buildGlobalSearchIndex() { + let courses = []; + try { + // enrollment_state=active excludes concluded/inactive enrollments at the + // source so we never index (or waste requests on) past-term courses. + courses = await getData(`${domain}/api/v1/courses?enrollment_state=active&per_page=100`); + } catch (e) { + console.warn("[CanvasRefined] global search: failed to load courses", e); + return []; + } + if (!Array.isArray(courses) || !courses.length) return []; + + // Skip inactive/concluded/hidden courses. `enrollment_state=active` + // already filters at the source, but some institutions return past-term + // courses as "active", so we double-check here: + // - access_restricted_by_date (locked courses) + // - concluded === true (Canvas marks concluded courses) + // - term end_date in the past (when the API exposes it) + // - courses the user hid from their dashboard (custom_cards.hidden) + const now = Date.now(); + courses = courses.filter(c => { + if (!c || !c.name) return false; + if (c.access_restricted_by_date === true) return false; + if (c.concluded === true) return false; + // term end date check (API may return term.end_at) + const endAt = c?.term?.end_at || c?.end_at; + if (endAt) { + const end = new Date(endAt).getTime(); + if (!isNaN(end) && end < now) return false; + } + if (isCourseHidden(c.id)) return false; + return true; + }); + // Cap to keep request volume sane. + courses = courses.slice(0, 60); + + // Canvas can return the same course more than once (multi-role enrollments, + // cross-listed sections). Dedupe by id so we don't double-index or double-fetch. + const seenCourseIds = new Set(); + courses = courses.filter(c => { + if (seenCourseIds.has(c.id)) return false; + seenCourseIds.add(c.id); + return true; + }); + + const index = []; + // Shared dedup state. Keys identify a piece of *content* regardless of where + // it surfaced, so the same assignment (which Canvas exposes both as a module + // item AND a standalone assignment) collapses to a single result. + // - standalone assignment: `asn::` + // - module item with content_id: `::` + // (for type "assignment" this becomes `asn::` — + // the SAME key as the standalone assignment, so whichever is added + // first wins; we add assignments first to keep the direct URL) + // - module item without content_id (external url/tool): `url:` + // - module itself: `module::` + const seenContent = new Set(); + + await Promise.all(courses.map(async (course) => { + const courseId = course.id; + const courseName = course.name; + const courseCode = course.course_code || courseName; + + // Assignments first so their direct URLs win over the module-item + // versions of the same assignment. + try { + const assignments = await getData(`${domain}/api/v1/courses/${courseId}/assignments?per_page=100`); + if (Array.isArray(assignments)) { + for (const a of assignments) { + if (!a || !a.name || !a.html_url) continue; + const key = `asn:${courseId}:${a.id}`; + if (seenContent.has(key)) continue; + seenContent.add(key); + index.push({ + type: "Assignment", + title: a.name, + course: courseName, + courseCode, + courseId, + url: a.html_url + }); + } + } + } catch (_) { /* non-fatal */ } + + // Modules + their items. + try { + const modules = await getData(`${domain}/api/v1/courses/${courseId}/modules?per_page=100`); + if (Array.isArray(modules)) { + for (const m of modules) { + if (!m || !m.name) continue; + const modKey = `module:${courseId}:${m.id}`; + if (!seenContent.has(modKey)) { + seenContent.add(modKey); + index.push({ + type: "Module", + title: m.name, + course: courseName, + courseCode, + courseId, + url: `${domain}/courses/${courseId}/modules` + }); + } + try { + const items = await getData(`${domain}/api/v1/courses/${courseId}/modules/${m.id}/items?per_page=100`); + if (Array.isArray(items)) { + for (const it of items) { + if (!it || !it.title) continue; + // Skip text headers / dividers — no destination page. + const itype = (it.type || "").toLowerCase(); + if (itype === "subheader") continue; + // Prefer the real page link (html_url). External + // URL items expose external_url instead; the bare + // `url` field is the API endpoint, never use it. + const url = it.html_url || it.external_url; + if (!url) continue; + + // Build a content-identity key so the same item + // appearing in multiple modules (or mirroring a + // standalone assignment) only produces one result. + let key; + if (itype === "assignment" && it.content_id) { + key = `asn:${courseId}:${it.content_id}`; + } else if (it.content_id) { + key = `${itype}:${courseId}:${it.content_id}`; + } else { + key = `url:${normalizeGlobalSearchUrl(url)}`; + } + if (seenContent.has(key)) continue; + seenContent.add(key); + + index.push({ + type: prettyModuleItemType(it.type), + title: it.title, + course: courseName, + courseCode, + courseId, + url + }); + } + } + } catch (_) { /* per-module failure is non-fatal */ } + } + } + } catch (_) { /* per-course failure is non-fatal */ } + })); + + // Final safety net: collapse any remaining normalized-URL duplicates (e.g. + // external links whose content_id differed but resolve to the same page). + const seen = new Set(); + return index.filter(item => { + const key = normalizeGlobalSearchUrl(item.url); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function normalizeGlobalSearchUrl(url) { + if (!url) return ""; + try { + const u = new URL(url, domain); + let path = u.pathname.replace(/\/+$/, ""); // strip trailing slashes + // Ignore case + fragment/query for matching purposes. + return (u.host.toLowerCase() + path).toLowerCase(); + } catch (_) { + // Non-absolute (shouldn't happen, but be safe) — normalize as-is. + return String(url).replace(/\/+$/, "").toLowerCase(); + } +} + +function prettyModuleItemType(type) { + switch ((type || "").toLowerCase()) { + case "assignment": return "Assignment"; + case "quiz": return "Quiz"; + case "discussion": case "discussion_topic": return "Discussion"; + case "externalurl": return "Link"; + case "externaltool": return "External Tool"; + case "file": return "File"; + case "page": return "Page"; + case "subheader": return "Section"; + default: return type || "Item"; + } +} + +// --- Searching -------------------------------------------------------------- + +async function runGlobalSearch(query, resultsEl) { + if (!globalSearchIndex && globalSearchIndexPromise) { + resultsEl.innerHTML = `
Building search index\u2026
`; + } + const index = await ensureGlobalSearchIndex(); + if (!query) { + resultsEl.innerHTML = `
Start typing to search your modules and assignments.
`; + return []; + } + if (!index || !index.length) { + resultsEl.innerHTML = `
No modules or assignments found. Open the search again later if your courses are still loading.
`; + return []; + } + + const q = query.toLowerCase(); + const matches = []; + for (const item of index) { + // Re-check hidden status at search time so a card hidden after the index + // was cached (10-min TTL) never surfaces in results. + if (isCourseHidden(item.courseId)) continue; + const t = (item.title || "").toLowerCase(); + const c = (item.course || "").toLowerCase(); + let score = -1; + if (t.startsWith(q)) score = 100 - t.indexOf(q); + else if (t.includes(q)) score = 60 - t.indexOf(q); + else if (c.includes(q)) score = 20; + if (score >= 0) { item._score = score + (t === q ? 50 : 0); matches.push(item); } + } + matches.sort((a, b) => b._score - a._score); + const top = matches.slice(0, 50); + + if (!top.length) { + resultsEl.innerHTML = `
No results for \u201c${escapeGlobalSearchHtml(query)}\u201d.
`; + return []; + } + + resultsEl.innerHTML = top.map((item, i) => ` +
+
+ ${escapeGlobalSearchHtml(item.type || "")} + ${escapeGlobalSearchHtml(item.title || "")} +
+ ${escapeGlobalSearchHtml(item.course || "")} +
`).join(""); + + resultsEl.querySelectorAll(".canvasrefined-gs-row").forEach((row) => { + // Plain click / Ctrl+click: honor modifier for new-tab behavior. + 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)); + }); + // Middle-click opens in a new tab without closing the search. + 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); + }); + }); + return top; +} + +function escapeGlobalSearchHtml(s) { + return String(s).replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch])); +} +function escapeGlobalSearchAttr(s) { + return escapeGlobalSearchHtml(s).replace(/`/g, "`"); +} + function changeGradientCards() { if (options.gradient_cards === true) { let cardheads = document.querySelectorAll('.ic-DashboardCard__header_hero'); diff --git a/js/popup.js b/js/popup.js index c9e4cd3..9a46677 100644 --- a/js/popup.js +++ b/js/popup.js @@ -1,4 +1,4 @@ -const syncedSwitches = ['remind', 'tab_icons', 'dark_mode', 'remlogo', 'full_width', 'auto_dark', 'assignments_due', 'gpa_calc', 'gradient_cards', 'disable_color_overlay', 'dashboard_grades', 'dashboard_notes', 'better_todo', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'center_cards', 'quiz_safe_mode']; +const syncedSwitches = ['remind', 'tab_icons', 'dark_mode', 'remlogo', 'full_width', 'auto_dark', 'assignments_due', 'gpa_calc', 'gradient_cards', 'disable_color_overlay', 'dashboard_grades', 'dashboard_notes', 'better_todo', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'center_cards', 'quiz_safe_mode', 'global_search']; const syncedSubOptions = [ "todo_hide_feedback", "todo_full_height",