From e3ec6c80d0a94a74175d41ba6c9501217ea14eae Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Mon, 22 Jun 2026 15:49:21 +0930 Subject: [PATCH 01/36] fix(ui): improve select dropdown contrast --- src/css/injected.scss | 18 +++++++++++------- src/interface/components/Select.svelte | 11 ++++++----- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/css/injected.scss b/src/css/injected.scss index 4e6a1b6e..d0537c87 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -66,6 +66,10 @@ select { border: 1px solid color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 78%, transparent) !important; background: color-mix(in srgb, var(--background-primary) 90%, transparent) !important; color: var(--text-primary) !important; + padding: 0.5rem 1rem !important; + min-height: 2.5rem !important; + font-size: 0.875rem !important; + line-height: 1.25 !important; transition: background-color 180ms ease, border-color 180ms ease, @@ -94,6 +98,10 @@ select[size="1"] { background-repeat: no-repeat !important; background-size: 1rem !important; padding-right: 2.6rem !important; +} + +html:not(.dark) select:not([multiple]):not([size]), +html:not(.dark) select[size="1"] { color-scheme: light; } @@ -101,9 +109,10 @@ select::-ms-expand { display: none; } +/* OS option panels on Windows/Edge are often light even in dark mode */ select option { - background: var(--background-primary) !important; - color: var(--text-primary) !important; + background-color: #ffffff !important; + color: #18181b !important; } .dark select:not([multiple]):not([size]), @@ -111,11 +120,6 @@ select option { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23c9c9c9'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important; color-scheme: dark; } - -.dark select option { - background: var(--background-primary) !important; - color: var(--text-primary) !important; -} #container { background: var(--auto-background) !important; } diff --git a/src/interface/components/Select.svelte b/src/interface/components/Select.svelte index a782b5db..96cd1c69 100644 --- a/src/interface/components/Select.svelte +++ b/src/interface/components/Select.svelte @@ -63,11 +63,6 @@ background: transparent; } - .select-input option { - background: var(--background-primary); - color: var(--text-primary); - } - .select-icon { color: color-mix(in srgb, var(--text-primary) 60%, transparent); } @@ -79,4 +74,10 @@ :global(.dark) .select-input { color-scheme: dark; } + + /* Native option lists on Windows/Edge often stay light regardless of color-scheme */ + .select-input option { + background-color: #ffffff; + color: #18181b; + } From f570e47e27a02cd4a6a16d0a5af589c4545bf4ef Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Mon, 22 Jun 2026 18:02:24 +0930 Subject: [PATCH 02/36] fix(sidebar): hide Analytics via toggle --- lib/extensionChunkUrls.ts | 29 ++++ src/background.ts | 1 + src/css/injected.scss | 48 ++++++ .../built-in/gradeAnalytics/core/index.ts | 23 ++- src/plugins/built-in/gradeAnalytics/lazy.ts | 2 +- src/plugins/monofile.ts | 69 ++++---- src/seqta/ui/AddBetterSEQTAElements.ts | 32 +++- src/seqta/utils/Openers/OpenMenuOptions.ts | 163 ++++++++++-------- src/seqta/utils/SendNewsPage.ts | 18 +- src/seqta/utils/defaultSettings.ts | 1 + src/seqta/utils/listeners/StorageChanges.ts | 8 + src/seqta/utils/menuItemVisibility.ts | 45 +++++ src/types/storage.ts | 2 + vite.config.ts | 2 + 14 files changed, 334 insertions(+), 109 deletions(-) create mode 100644 lib/extensionChunkUrls.ts create mode 100644 src/seqta/utils/menuItemVisibility.ts diff --git a/lib/extensionChunkUrls.ts b/lib/extensionChunkUrls.ts new file mode 100644 index 00000000..6dff51bb --- /dev/null +++ b/lib/extensionChunkUrls.ts @@ -0,0 +1,29 @@ +import type { Plugin } from "vite"; + +/** + * Vite's default base (`/`) emits absolute chunk paths like `/assets/chunk.js`. + * In content scripts those resolve against the SEQTA page origin on Firefox, + * not the extension — causing MIME type / NS_ERROR_CORRUPTED_CONTENT failures. + * + * Use relative base plus `chrome.runtime.getURL` for dynamic import targets. + */ +export function extensionChunkUrls(): Plugin { + return { + name: "extension-chunk-urls", + config() { + return { + base: "./", + experimental: { + renderBuiltUrl(filename, { hostType, type }) { + if (type === "chunk" && hostType === "js") { + const path = filename.replace(/^\//, ""); + return { + runtime: `chrome.runtime.getURL(${JSON.stringify(path)})`, + }; + } + }, + }, + }; + }, + }; +} diff --git a/src/background.ts b/src/background.ts index c1248182..6a6b5970 100644 --- a/src/background.ts +++ b/src/background.ts @@ -626,6 +626,7 @@ browser.runtime.onInstalled.addListener(function (event) { void migrateGlobalSearchDefaultsFor365Upgrade(event.previousVersion); void resetThemeOfTheMonthDisabledFor366Upgrade(event.previousVersion); void resetThemeOfTheMonthDismissalFor370Upgrade(event.previousVersion); + reloadSeqtaPages(); } }); diff --git a/src/css/injected.scss b/src/css/injected.scss index d0537c87..dea7df9a 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -512,6 +512,54 @@ ul.magicDelete > li.deleting { #menu:has(> ul > li.hasChildren.active) > ul > li:not(.hasChildren.active) { pointer-events: none !important; } + +/* Edit Sidebar: every row + toggle must stay clickable (drill stack disables siblings). */ +#menu.bsplus-sidebar-edit-mode li.item, +#menu.bsplus-sidebar-edit-mode section.item, +#menu.bsplus-sidebar-edit-mode .bsplus-sidebar-offscreen, +#menu.bsplus-sidebar-edit-mode .bsplus-sidebar-offscreen * { + pointer-events: auto !important; + user-select: auto !important; +} + +#menu.bsplus-sidebar-edit-mode:has(> ul > li.hasChildren.active) + > ul + > li:not(.hasChildren.active) { + pointer-events: auto !important; +} + +#menu.bsplus-sidebar-edit-mode > ul > .bsplus-sidebar-offscreen:not(.hasChildren.active), +#menu.bsplus-sidebar-edit-mode .sub .bsplus-sidebar-offscreen:not(.hasChildren.active) { + position: relative !important; + left: auto !important; + width: auto !important; + height: auto !important; + margin: inherit !important; + padding: inherit !important; + overflow: visible !important; + clip: auto !important; + opacity: 1 !important; + visibility: visible !important; +} + +#menu.bsplus-sidebar-edit-mode .item.draggable { + display: flex !important; + align-items: center; + gap: 0.5rem; +} + +#menu.bsplus-sidebar-edit-mode .item.draggable > label { + flex: 1; + min-width: 0; +} + +#menu.bsplus-sidebar-edit-mode .onoffswitch { + pointer-events: auto !important; + flex-shrink: 0; + position: relative; + z-index: 2; +} + #menu section > label { align-items: center; box-sizing: border-box; diff --git a/src/plugins/built-in/gradeAnalytics/core/index.ts b/src/plugins/built-in/gradeAnalytics/core/index.ts index 16d36394..43313b88 100644 --- a/src/plugins/built-in/gradeAnalytics/core/index.ts +++ b/src/plugins/built-in/gradeAnalytics/core/index.ts @@ -3,6 +3,11 @@ import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json"; import { waitForElm } from "@/seqta/utils/waitForElm"; import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; import { processMenuItemNode } from "@/seqta/utils/sidebarMenuIcons"; +import { MenuOptionsOpen } from "@/seqta/utils/Openers/OpenMenuOptions"; +import { + applyMenuItemVisibility, + isMenuItemHidden, +} from "@/seqta/utils/menuItemVisibility"; import { loadAnalyticsPage } from "../loadAnalyticsPage"; import styles from "../styles.css?inline"; @@ -17,7 +22,7 @@ const gradeAnalyticsPlugin: Plugin<{}> = { "Adds an analytics page with grade trends, distribution charts, and assessment history", version: "1.0.0", settings: {}, - disableToggle: false, + disableToggle: true, styles, run: async () => { @@ -25,6 +30,10 @@ const gradeAnalyticsPlugin: Plugin<{}> = { return () => {}; } + if (isMenuItemHidden("analytics")) { + return () => {}; + } + const menuList = (await waitForElm("#menu > ul, #menu ul", true, 100, 60)) as HTMLElement; const analyticsItem = document.createElement("li"); @@ -44,8 +53,11 @@ const gradeAnalyticsPlugin: Plugin<{}> = { } processMenuItemNode(analyticsItem); + applyMenuItemVisibility(); const menuObserver = new MutationObserver(() => { + if (MenuOptionsOpen) return; + if (isMenuItemHidden("analytics")) return; if (!menuList.contains(analyticsItem)) { if (homeButton?.parentElement === menuList) { homeButton.insertAdjacentElement("afterend", analyticsItem); @@ -53,11 +65,20 @@ const gradeAnalyticsPlugin: Plugin<{}> = { menuList.insertBefore(analyticsItem, menuList.firstChild); } processMenuItemNode(analyticsItem); + applyMenuItemVisibility(); } }); menuObserver.observe(menuList, { childList: true }); const onClick = (e: Event) => { + const target = e.target as HTMLElement; + if ( + MenuOptionsOpen || + analyticsItem.classList.contains("draggable") || + target.closest(".onoffswitch, .editmenuoption-container") + ) { + return; + } e.preventDefault(); window.history.pushState({}, "", "/#?page=/analytics"); void loadAnalyticsPage(); diff --git a/src/plugins/built-in/gradeAnalytics/lazy.ts b/src/plugins/built-in/gradeAnalytics/lazy.ts index 9b0c84e6..125db7d5 100644 --- a/src/plugins/built-in/gradeAnalytics/lazy.ts +++ b/src/plugins/built-in/gradeAnalytics/lazy.ts @@ -20,7 +20,7 @@ const gradeAnalyticsPluginLazy = defineLazyPlugin({ "Grade trends, distribution charts, and assessment history synced from SEQTA", version: "1.0.0", settings, - disableToggle: false, + disableToggle: true, defaultEnabled: true, styles, loader: () => import("./core/index"), diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index 0783c539..da224f48 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -40,29 +40,7 @@ import IconFamily from "@/resources/fonts/IconFamily.woff"; // Stylesheets import iframeCSS from "@/css/iframe.scss?raw"; -function SetDisplayNone(ElementName: string) { - return `li[data-key=${ElementName}]{display:var(--menuHidden) !important; transition: 1s;}`; -} - -async function HideMenuItems(): Promise { - try { - let stylesheetInnerText: string = ""; - for (const [menuItem, { toggle }] of Object.entries( - settingsState.menuitems, - )) { - if (!toggle) { - stylesheetInnerText += SetDisplayNone(menuItem); - console.info(`[BetterSEQTA+] Hiding ${menuItem} menu item`); - } - } - - const menuItemStyle: HTMLStyleElement = document.createElement("style"); - menuItemStyle.innerText = stylesheetInnerText; - document.head.appendChild(menuItemStyle); - } catch (error) { - console.error("[BetterSEQTA+] An error occurred:", error); - } -} +import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility"; export function hideSideBar() { const sidebar = document.getElementById("menu"); // The sidebar element to be closed @@ -368,11 +346,18 @@ async function handleSublink(sublink: string | undefined): Promise { } async function handleNewsPage(): Promise { - console.info("[BetterSEQTA+] Started Init"); - if (settingsState.onoff) { - SendNewsPage(); + if (!settingsState.onoff) { finishLoad(); + return; } + + console.info("[BetterSEQTA+] Started Init"); + try { + await SendNewsPage(); + } catch (error) { + console.error("[BetterSEQTA+] Failed to load news page:", error); + } + finishLoad(); } async function handleDefault(): Promise { @@ -674,8 +659,24 @@ export function showConflictPopup() { } export function init() { + const tryMountDisabledUi = async () => { + if (document.getElementById("AddedSettings")) return; + + try { + await waitForElm("#content"); + } catch { + try { + await waitForElm("#container"); + } catch { + await waitForElm("body"); + } + } + + AppendElementsToDisabledPage(); + }; + const handleDisabled = () => { - waitForElm(".code", true, 50).then(AppendElementsToDisabledPage); + void tryMountDisabledUi(); }; if (settingsState.onoff) { @@ -705,7 +706,7 @@ export function init() { }); loading(); InjectCustomIcons(); - HideMenuItems(); + applyMenuItemVisibility(); tryLoad(); // Auto-focus WISP direct online submission editor when pane opens @@ -787,7 +788,7 @@ export function init() { } else { handleDisabled(); InjectCustomIcons(); - window.addEventListener("load", handleDisabled); + window.addEventListener("load", handleDisabled, { once: true }); } } @@ -807,6 +808,8 @@ function InjectCustomIcons() { } export function AppendElementsToDisabledPage() { + if (document.getElementById("AddedSettings")) return; + console.info("[BetterSEQTA+] Appending elements to disabled page"); AddBetterSEQTAElements(); @@ -822,7 +825,13 @@ export function AppendElementsToDisabledPage() { border-radius: 50%; margin: 7px !important; cursor: pointer; - color: white !important; + color: #38373d !important; + background: rgba(0, 0, 0, 0.08); + display: flex !important; + align-items: center; + justify-content: center; + visibility: visible !important; + z-index: 1000; } .addedButton svg { margin: 6px; diff --git a/src/seqta/ui/AddBetterSEQTAElements.ts b/src/seqta/ui/AddBetterSEQTAElements.ts index 62007031..ca01d6da 100644 --- a/src/seqta/ui/AddBetterSEQTAElements.ts +++ b/src/seqta/ui/AddBetterSEQTAElements.ts @@ -278,7 +278,13 @@ function setupEventListeners() { } async function createSettingsButton(parent?: Element) { - const target = parent ?? document.getElementById("content")!; + if (document.getElementById("AddedSettings")) return; + + const target = + parent ?? + document.getElementById("content") ?? + document.getElementById("container") ?? + document.body; target.append( stringToHTML(/* html */ ` + + {#if isOpen} +
    + {#each options as option (option.value)} +
  • + +
  • + {/each} +
+ {/if} diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index f9d1bc8b..2f48d6d1 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -249,7 +249,7 @@ id: 10, Component: Select, props: { - state: $settingsState.defaultPage ?? "home", + value: $settingsState.defaultPage ?? "home", onChange: (value: string) => (settingsState.defaultPage = value), options: [ { value: "home", label: "Home" }, @@ -268,7 +268,7 @@ id: 11, Component: Select, props: { - state: $settingsState.newsSource, + value: $settingsState.newsSource, onChange: (value: string) => settingsState.newsSource = value, options: [ { value: "australia", label: "Australia" }, @@ -405,7 +405,7 @@ /> {:else if setting.type === 'select'} - Cover + {/if} diff --git a/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte b/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte index 67a75f59..f63fda71 100644 --- a/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte +++ b/src/plugins/built-in/globalSearch/src/components/SearchBar.svelte @@ -15,6 +15,7 @@ import HighlightedText from '../utils/HighlightedText.svelte'; import { matchesHotkey } from '../utils/hotkeyUtils'; import browser from 'webextension-polyfill'; + import { verboseDebug } from '@/utils/verboseLog'; const { transparencyEffects, @@ -160,7 +161,7 @@ dynamicItems.forEach(item => dynamicIdToItemMap.set(item.id, item)); commands.forEach(item => commandIdToItemMap.set(item.id, item)); - console.debug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`); + verboseDebug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`); } const performSearch = async () => { diff --git a/src/plugins/built-in/globalSearch/src/core/commands.ts b/src/plugins/built-in/globalSearch/src/core/commands.ts index 54a7fc8a..9f83c482 100644 --- a/src/plugins/built-in/globalSearch/src/core/commands.ts +++ b/src/plugins/built-in/globalSearch/src/core/commands.ts @@ -2,6 +2,7 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { waitForElm } from "@/seqta/utils/waitForElm"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; export interface BaseCommandItem { id: string; text: string; @@ -105,7 +106,7 @@ async function navigateToSpecificLesson(lesson: any) { if (lessonDate === todayDateString && lessonPeriod === normalizedLessonPeriod) { // Found the exact matching lesson, click it (lessonElement as HTMLElement).click(); - console.log(`Navigated to exact lesson: ${lessonDate} ${lessonPeriod}`); + verboseLog(`Navigated to exact lesson: ${lessonDate} ${lessonPeriod}`); return true; } } diff --git a/src/plugins/built-in/globalSearch/src/core/index.ts b/src/plugins/built-in/globalSearch/src/core/index.ts index 2ad909fb..3681b649 100644 --- a/src/plugins/built-in/globalSearch/src/core/index.ts +++ b/src/plugins/built-in/globalSearch/src/core/index.ts @@ -7,6 +7,7 @@ import { hotkeySetting, Setting, } from "@/plugins/core/settingsHelpers"; +import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog"; import styles from "./styles.css?inline"; import { waitForElm } from "@/seqta/utils/waitForElm"; import { runIndexing } from "../indexing/indexer"; @@ -70,7 +71,7 @@ const settings = defineSettings({ try { const workerManager = VectorWorkerManager.getInstance(); await workerManager.resetWorker(); - console.log("Vector worker reset successfully"); + verboseLog("Vector worker reset successfully"); } catch (e) { console.warn("Failed to reset vector worker:", e); } @@ -90,7 +91,7 @@ const settings = defineSettings({ return new Promise((resolve, reject) => { const req = indexedDB.deleteDatabase(dbName); req.onsuccess = () => { - console.log(`Successfully deleted database: ${dbName}`); + verboseLog(`Successfully deleted database: ${dbName}`); resolve(); }; req.onerror = () => { @@ -103,7 +104,7 @@ const settings = defineSettings({ setTimeout(() => { const retryReq = indexedDB.deleteDatabase(dbName); retryReq.onsuccess = () => { - console.log(`Successfully deleted database on retry: ${dbName}`); + verboseLog(`Successfully deleted database on retry: ${dbName}`); resolve(); }; retryReq.onerror = () => reject(retryReq.error); @@ -176,7 +177,7 @@ const globalSearchPlugin: Plugin = { try { const wasUpdated = await checkAndHandleUpdate(); if (wasUpdated) { - console.log( + verboseLog( "[Global Search] Extension updated — search index reset; the next indexing pass will repopulate.", ); } @@ -188,7 +189,7 @@ const globalSearchPlugin: Plugin = { error?.message?.includes("MIME type") || error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT") ) { - console.debug( + verboseDebug( "[Global Search] Version check skipped due to asset loading restrictions:", error.message, ); @@ -217,7 +218,7 @@ const globalSearchPlugin: Plugin = { if (isVectorSearchSupported()) { VectorWorkerManager.getInstance(); } else { - console.debug("[Global Search] Skipping vector worker warm-up (Firefox detected - using text search only)"); + verboseDebug("[Global Search] Skipping vector worker warm-up (Firefox detected - using text search only)"); } } catch (error) { console.warn("[Global Search] Vector worker warm-up failed:", error); @@ -230,15 +231,15 @@ const globalSearchPlugin: Plugin = { resetWorker: async () => { const workerManager = VectorWorkerManager.getInstance(); await workerManager.resetWorker(); - console.log("Vector worker reset via debug helper"); + verboseLog("Vector worker reset via debug helper"); }, checkWorkerStatus: () => { const workerManager = VectorWorkerManager.getInstance(); - console.log("Streaming active:", workerManager.isStreamingActive()); + verboseLog("Streaming active:", workerManager.isStreamingActive()); }, passiveItems: async () => { const items = await getStoredPassiveItems(); - console.log(`Captured ${items.length} passive items`); + verboseLog(`Captured ${items.length} passive items`); return items; }, runSelfTests: async () => { @@ -250,7 +251,7 @@ const globalSearchPlugin: Plugin = { checkIndexedDBSize: async () => { try { const estimate = await navigator.storage.estimate(); - console.log("Storage estimate:", estimate); + verboseLog("Storage estimate:", estimate); // Check embeddiaDB size const dbRequest = indexedDB.open("embeddiaDB"); @@ -260,7 +261,7 @@ const globalSearchPlugin: Plugin = { const store = transaction.objectStore("embeddiaObjectStore"); const countRequest = store.count(); countRequest.onsuccess = () => { - console.log("embeddiaDB item count:", countRequest.result); + verboseLog("embeddiaDB item count:", countRequest.result); }; }; } catch (e) { diff --git a/src/plugins/built-in/globalSearch/src/indexing/actions.ts b/src/plugins/built-in/globalSearch/src/indexing/actions.ts index 954b6730..b485b3a5 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/actions.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/actions.ts @@ -3,6 +3,7 @@ import type { IndexItem } from "./types"; import ReactFiber from "@/seqta/utils/ReactFiber"; import { delay } from "@/seqta/utils/delay"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; interface MessageMetadata { messageId: number; author: string; @@ -171,7 +172,7 @@ export const actionMap: Record> = { if ((assessmentId === undefined || assessmentId === null) && itemClone.id && itemClone.id.startsWith('assignment-')) { const extractedId = itemClone.id.replace('assignment-', ''); assessmentId = Number(extractedId) || extractedId; - console.log("[Assessment Action] Extracted assessmentId from item ID:", assessmentId); + verboseLog("[Assessment Action] Extracted assessmentId from item ID:", assessmentId); } // Convert to numbers, but preserve 0 as valid @@ -198,7 +199,7 @@ export const actionMap: Record> = { if (hasProgrammeId && hasMetaclassId && hasAssessmentId) { const url = `#?page=/assessments/${programmeId}:${metaclassId}&item=${assessmentId}`; - console.log("[Assessment Action] ✅ Navigating to:", url); + verboseLog("[Assessment Action] ✅ Navigating to:", url); window.location.hash = url; } else { // Fallback: try to navigate to assessments page if metadata is incomplete diff --git a/src/plugins/built-in/globalSearch/src/indexing/indexer.ts b/src/plugins/built-in/globalSearch/src/indexing/indexer.ts index 70222ca8..57c86830 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/indexer.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/indexer.ts @@ -7,6 +7,7 @@ import { loadDynamicItems } from "../utils/dynamicItems"; import { getVectorizedItemIds } from "./utils"; import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; const META_STORE = "meta"; const LOCK_KEY = "bsq-indexer-lock"; const HEARTBEAT_INTERVAL = 10000; @@ -101,7 +102,7 @@ async function updateLastRunMeta(jobId: string): Promise { async function acquireLock(): Promise { if (isIndexingActive) { - console.debug("[Indexer] Already indexing in this tab"); + verboseDebug("[Indexer] Already indexing in this tab"); return false; } @@ -200,7 +201,7 @@ export async function loadAllStoredItems(): Promise { console.error(`Error loading items for job store ${jobId}:`, error); } } - console.debug( + verboseDebug( `[Indexer] Loaded ${all.length} items from all primary stores.`, ); return all; @@ -210,7 +211,7 @@ export async function runIndexing(): Promise { await ensureSchemaCurrent(); if (!(await acquireLock())) { - console.debug( + verboseDebug( "%c[Indexer] Could not acquire lock - another tab is indexing or this tab is already indexing", "color: gray", ); @@ -218,7 +219,7 @@ export async function runIndexing(): Promise { } startHeartbeat(); - console.debug("%c[Indexer] Starting indexing...", "color: green"); + verboseDebug("%c[Indexer] Starting indexing...", "color: green"); const jobIds = Object.keys(jobs); let completedJobs = 0; @@ -236,7 +237,7 @@ export async function runIndexing(): Promise { const lastRun = await getLastRunMeta(jobId); if (!shouldRun(job, lastRun)) { - console.debug( + verboseDebug( `%c[Indexer] Skipping job "${jobId}" (not due)`, "color: gray", ); @@ -288,7 +289,7 @@ export async function runIndexing(): Promise { setProgress: (p) => saveProgress(jobId, p), }; - console.debug(`%c[Indexer] Running job "${jobId}"...`, "color: #4ea1ff"); + verboseDebug(`%c[Indexer] Running job "${jobId}"...`, "color: #4ea1ff"); try { const newItemsRaw = await job.run(ctx); @@ -300,12 +301,12 @@ export async function runIndexing(): Promise { await setStoredItems(merged); await updateLastRunMeta(jobId); - console.debug( + verboseDebug( `%c[Indexer] ${job.label}: ${newItemsRaw.length} new items reported by run, ${merged.length} total items now in '${jobId}' store.`, "color: #00c46f", ); } catch (err) { - console.debug(`%c[Indexer] Job ${job.label} failed:`, "color: red"); + verboseDebug(`%c[Indexer] Job ${job.label} failed:`, "color: red"); console.error(err); } @@ -321,7 +322,7 @@ export async function runIndexing(): Promise { let allItemsInPrimaryStores = await loadAllStoredItems(); if (allItemsInPrimaryStores.length > 0) { - console.debug( + verboseDebug( `%c[Indexer] Checking ${allItemsInPrimaryStores.length} items for vectorization...`, "color: #4ea1ff", ); @@ -331,7 +332,7 @@ export async function runIndexing(): Promise { const newItemsToVectorize = allItemsInPrimaryStores.filter(item => !vectorizedItemIds.has(item.id)); if (newItemsToVectorize.length > 0) { - console.debug( + verboseDebug( `%c[Indexer] Sending ${newItemsToVectorize.length} new items to worker for vectorization (${allItemsInPrimaryStores.length - newItemsToVectorize.length} already vectorized)`, "color: #4ea1ff", ); @@ -389,7 +390,7 @@ export async function runIndexing(): Promise { ); } }); - console.debug( + verboseDebug( "%c[Indexer] Vectorization task for stored items sent to worker.", "color: green", ); @@ -408,7 +409,7 @@ export async function runIndexing(): Promise { ); } } else { - console.debug( + verboseDebug( `%c[Indexer] All ${allItemsInPrimaryStores.length} items are already vectorized, skipping worker initialization.`, "color: gray", ); @@ -421,7 +422,7 @@ export async function runIndexing(): Promise { ); } } else { - console.debug( + verboseDebug( "%c[Indexer] No items found in primary stores to send for vectorization.", "color: gray", ); diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/assignments.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/assignments.ts index ea0cc470..f04b40e8 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs/assignments.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/assignments.ts @@ -1,5 +1,6 @@ import type { IndexItem, Job } from "../types"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; const fetchJSON = async (url: string, body: any) => { const res = await fetch(`${location.origin}${url}`, { method: "POST", @@ -128,7 +129,7 @@ export const assignmentsJob: Job = { const student = 69; // TODO: Get from context if available - console.debug("[Assignments job] Starting indexing - fetching all assessments (upcoming and past)..."); + verboseDebug("[Assignments job] Starting indexing - fetching all assessments (upcoming and past)..."); // Fetch data in parallel const [upcoming, subjects] = await Promise.all([ @@ -136,12 +137,12 @@ export const assignmentsJob: Job = { fetchSubjects(), ]); - console.debug(`[Assignments job] Fetched ${upcoming.length} upcoming assessments and ${subjects.length} subjects`); + verboseDebug(`[Assignments job] Fetched ${upcoming.length} upcoming assessments and ${subjects.length} subjects`); // Fetch past assessments for ALL subjects to ensure we get all historical assignments const past = await fetchPastAssessments(student, subjects); - console.debug(`[Assignments job] Fetched ${past.length} past assessments`); + verboseDebug(`[Assignments job] Fetched ${past.length} past assessments`); // Create a lookup map from subject code to programme/metaclass const subjectLookup = new Map(); @@ -220,7 +221,7 @@ export const assignmentsJob: Job = { const assessmentArray = Array.from(allAssessments.values()); const pastCount = assessmentArray.filter(a => !a.isUpcoming).length; const upcomingCount = assessmentArray.filter(a => a.isUpcoming).length; - console.debug(`[Assignments job] Processing ${assessmentArray.length} total assessments (${upcomingCount} upcoming, ${pastCount} past)`); + verboseDebug(`[Assignments job] Processing ${assessmentArray.length} total assessments (${upcomingCount} upcoming, ${pastCount} past)`); const batchSize = 15; // Increased batch size for better performance // Skip fetching assessment details - the API endpoint doesn't exist or returns 404 @@ -321,7 +322,7 @@ export const assignmentsJob: Job = { renderComponentId: "assessment", }; - console.debug(`[Assignments job] ✅ Created item for assignment ${assessment.id}:`, { + verboseDebug(`[Assignments job] ✅ Created item for assignment ${assessment.id}:`, { id: item.id, programmeId: item.metadata.programmeId, programmeID: item.metadata.programmeID, @@ -350,7 +351,7 @@ export const assignmentsJob: Job = { const newItemsCount = items.filter(item => !existingIds.has(item.id)).length; const updatedItemsCount = items.length - newItemsCount; - console.debug(`[Assignments job] Indexed ${items.length} assignment items (${newItemsCount} new, ${updatedItemsCount} updated)`); + verboseDebug(`[Assignments job] Indexed ${items.length} assignment items (${newItemsCount} new, ${updatedItemsCount} updated)`); return items; }, diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/courses.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/courses.ts index c5fa959f..ae3fb7b5 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs/courses.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/courses.ts @@ -3,6 +3,7 @@ import { seqtaFetchPayload } from "../api"; import { buildIndexItem } from "../extract"; import { htmlToPlainText } from "../utils"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; /** * Indexes per-subject course content from `/seqta/student/load/courses`. * @@ -106,7 +107,7 @@ export const coursesJob: Job = { run: async (_ctx) => { const subjects = await fetchActiveSubjects(); if (subjects.length === 0) { - console.debug("[Courses job] No active subjects discovered."); + verboseDebug("[Courses job] No active subjects discovered."); return []; } @@ -169,7 +170,7 @@ export const coursesJob: Job = { ); } - console.debug( + verboseDebug( `[Courses job] Indexed ${items.length} courses across ${subjects.length} subjects.`, ); return items; diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/documents.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/documents.ts index a89c7955..22616956 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs/documents.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/documents.ts @@ -1,6 +1,7 @@ import type { IndexItem, Job } from "../types"; import { seqtaFetchPayload } from "../api"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; /** * Indexes file metadata from `/seqta/student/load/documents`. * @@ -131,7 +132,7 @@ export const documentsJob: Job = { } } - console.debug(`[Documents job] Indexed ${items.length} document entries.`); + verboseDebug(`[Documents job] Indexed ${items.length} document entries.`); return items; }, diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/folio.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/folio.ts index 8131441f..fae51e2e 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs/folio.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/folio.ts @@ -3,6 +3,7 @@ import { seqtaFetchPayload } from "../api"; import { htmlToPlainText } from "../utils"; import { delay } from "@/seqta/utils/delay"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; /** * Indexes student folio entries from `/seqta/student/folio`. * @@ -126,7 +127,7 @@ export const folioJob: Job = { await delay(PER_ITEM_DELAY_MS); } - console.debug(`[Folio job] Indexed ${items.length} folio entries.`); + verboseDebug(`[Folio job] Indexed ${items.length} folio entries.`); return items; }, diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/goals.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/goals.ts index 2b8f8265..87cbf309 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs/goals.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/goals.ts @@ -3,6 +3,7 @@ import { seqtaFetchPayload } from "../api"; import { extractTextFromValue } from "../extract"; import { delay } from "@/seqta/utils/delay"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; /** * Indexes student goals from `/seqta/student/load/goals`. * @@ -42,7 +43,7 @@ export const goalsJob: Job = { { mode: "years" }, ); if (!Array.isArray(years) || years.length === 0) { - console.debug("[Goals job] No goal years available; skipping."); + verboseDebug("[Goals job] No goal years available; skipping."); return []; } @@ -101,7 +102,7 @@ export const goalsJob: Job = { await delay(PER_YEAR_DELAY_MS); } - console.debug(`[Goals job] Indexed ${items.length} goal entries.`); + verboseDebug(`[Goals job] Indexed ${items.length} goal entries.`); return items; }, diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/messages.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/messages.ts index 617b3cad..6e13d485 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs/messages.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/messages.ts @@ -7,6 +7,7 @@ import { loadAllStoredItems } from "../indexer"; import { renderComponentMap } from "../renderComponents"; import { jobs } from "../jobs"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; const RATE_LIMIT_CONFIG = { minDelay: 30, maxDelay: 3000, @@ -208,7 +209,7 @@ function checkCircuitBreaker(progress: MessagesProgress): boolean { ) { progress.circuitBreakerOpen = false; progress.consecutiveFailures = 0; - console.info( + verboseInfo( `[Messages job] Circuit breaker closed after ${RATE_LIMIT_CONFIG.circuitBreakerResetTime}ms`, ); return false; @@ -352,7 +353,7 @@ async function processMessagesInParallel( batchResponseTime, ); - console.log( + verboseLog( `[Messages job] Processed parallel batch: ${batchSuccesses} successes, ${batchFailures} failures, ${batchResponseTime}ms total time`, ); } @@ -397,7 +398,7 @@ export const messagesJob: Job = { await vectorWorker.startStreamingSession( progress.totalEstimated, (progressData) => { - console.log( + verboseLog( `[Messages job] Vector streaming progress: ${progressData.processed}/${progressData.total} (${progressData.status})`, ); }, @@ -405,7 +406,7 @@ export const messagesJob: Job = { "messages", ); progress.streamingStarted = true; - console.log( + verboseLog( `[Messages job] Started streaming vectorization session for ~${progress.totalEstimated} items`, ); } catch (error) { @@ -422,7 +423,7 @@ export const messagesJob: Job = { let itemsStreamedToVector = 0; if (progress.retryQueue.length > 0) { - console.log( + verboseLog( `[Messages job] Processing ${Math.min(progress.retryQueue.length, 10)} items from retry queue`, ); @@ -505,7 +506,7 @@ export const messagesJob: Job = { batchResponseTime, ); - console.log( + verboseLog( `[Messages job] Processed retry batch: ${retrySuccesses} successes, ${retryFailures} failures`, ); } @@ -590,7 +591,7 @@ export const messagesJob: Job = { try { await vectorWorker.streamItems(itemsToStream); itemsStreamedToVector += itemsToStream.length; - console.log( + verboseLog( `[Messages job] Streamed ${itemsToStream.length} items to vector worker (total: ${itemsStreamedToVector})`, ); } catch (error) { @@ -659,7 +660,7 @@ export const messagesJob: Job = { await ctx.setProgress(progress); progressUpdateCounter = 0; - console.log( + verboseLog( `[Messages job] Progress: offset=${progress.offset}, batchSize=${progress.currentBatchSize}, delay=${progress.currentDelay}ms, failures=${progress.failedRequests}, retryQueue=${progress.retryQueue.length}, vectorStreamed=${itemsStreamedToVector}, parallelRequests=${RATE_LIMIT_CONFIG.parallelRequests}`, ); } @@ -673,7 +674,7 @@ export const messagesJob: Job = { if (progress.streamingStarted) { try { await vectorWorker.endStreamingSession(); - console.log( + verboseLog( `[Messages job] Ended streaming session. Total items streamed: ${itemsStreamedToVector}`, ); } catch (error) { diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/notices.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/notices.ts index 93e6be15..9ef0f09b 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs/notices.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/notices.ts @@ -3,6 +3,7 @@ import { seqtaFetchPayload } from "../api"; import { htmlToPlainText } from "../utils"; import { delay } from "@/seqta/utils/delay"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; /** * Indexes daily notices from `/seqta/student/load/notices`. * @@ -205,7 +206,7 @@ export const noticesJob: Job = { await ctx.setProgress(progress); const newCount = items.filter((i) => !existingIds.has(i.id)).length; - console.debug( + verboseDebug( `[Notices job] Indexed ${items.length} notices across ${dates.length} dates (${newCount} new).`, ); return items; diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/notifications.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/notifications.ts index f0474bbc..523b00ed 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs/notifications.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/notifications.ts @@ -8,6 +8,7 @@ import { loadAllStoredItems } from "../indexer"; import { renderComponentMap } from "../renderComponents"; import { jobs } from "../jobs"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; const NOTIFICATIONS_RATE_LIMIT = { baseDelay: 150, maxDelay: 3000, @@ -201,7 +202,7 @@ export const notificationsJob: Job = { await vectorWorker.startStreamingSession( estimatedTotal, (progressData) => { - console.log( + verboseLog( `[Notifications job] Vector streaming progress: ${progressData.processed}/${progressData.total} (${progressData.status})`, ); }, @@ -209,7 +210,7 @@ export const notificationsJob: Job = { "notifications", ); progress.streamingStarted = true; - console.log( + verboseLog( `[Notifications job] Started streaming vectorization session for ~${estimatedTotal} items`, ); } catch (error) { @@ -247,7 +248,7 @@ export const notificationsJob: Job = { let itemsStreamedToVector = 0; if (progress.retryQueue.length > 0) { - console.log( + verboseLog( `[Notifications job] Processing ${Math.min(progress.retryQueue.length, 3)} items from retry queue`, ); @@ -352,7 +353,7 @@ export const notificationsJob: Job = { try { await vectorWorker.streamItems([...itemsToStream]); itemsStreamedToVector += itemsToStream.length; - console.log( + verboseLog( `[Notifications job] Streamed ${itemsToStream.length} items to vector worker (total: ${itemsStreamedToVector})`, ); itemsToStream.length = 0; @@ -424,7 +425,7 @@ export const notificationsJob: Job = { try { await vectorWorker.streamItems([...itemsToStream]); itemsStreamedToVector += itemsToStream.length; - console.log( + verboseLog( `[Notifications job] Streamed final ${itemsToStream.length} items to vector worker (total: ${itemsStreamedToVector})`, ); } catch (error) { @@ -438,7 +439,7 @@ export const notificationsJob: Job = { if (progress.streamingStarted) { try { await vectorWorker.endStreamingSession(); - console.log( + verboseLog( `[Notifications job] Ended streaming session. Total items streamed: ${itemsStreamedToVector}`, ); progress.streamingStarted = false; @@ -459,7 +460,7 @@ export const notificationsJob: Job = { } await ctx.setProgress(progress); - console.log( + verboseLog( `[Notifications job] Processed ${processedCount} notifications, ${progress.retryQueue.length} in retry queue, ${progress.failedRequests} failures, ${itemsStreamedToVector} items streamed to vector worker`, ); diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/portals.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/portals.ts index 01d5bd71..0d40501f 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs/portals.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/portals.ts @@ -1,6 +1,7 @@ import type { IndexItem, Job } from "../types"; import { seqtaFetchPayload } from "../api"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; /** * Indexes the user's external portal entries from `/seqta/student/load/portals`. * @@ -82,7 +83,7 @@ export const portalsJob: Job = { }); } - console.debug(`[Portals job] Indexed ${items.length} portal entries.`); + verboseDebug(`[Portals job] Indexed ${items.length} portal entries.`); return items; }, diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/reports.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/reports.ts index 9b3fc973..420aad9b 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs/reports.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/reports.ts @@ -1,6 +1,7 @@ import type { IndexItem, Job } from "../types"; import { seqtaFetchPayload } from "../api"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; /** * Indexes report metadata from `/seqta/student/load/reports`. * @@ -89,7 +90,7 @@ export const reportsJob: Job = { }); } - console.debug(`[Reports job] Indexed ${items.length} reports.`); + verboseDebug(`[Reports job] Indexed ${items.length} reports.`); return items; }, diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/subjects.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/subjects.ts index 342afd46..971b357e 100755 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs/subjects.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/subjects.ts @@ -1,5 +1,6 @@ import type { IndexItem, Job } from "../types"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; const fetchSubjects = async () => { const res = await fetch(`${location.origin}/seqta/student/load/subjects`, { method: "POST", @@ -129,7 +130,7 @@ export const subjectsJob: Job = { } } - console.debug(`[Subjects job] Indexed ${items.length} subject items`); + verboseDebug(`[Subjects job] Indexed ${items.length} subject items`); return items; }, diff --git a/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts b/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts index c183eca6..df02a0d7 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts @@ -6,6 +6,7 @@ import { pickId, pickTitle, } from "./extract"; +import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog"; import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api"; import { loadAllStoredItems } from "./indexer"; import { loadDynamicItems } from "../utils/dynamicItems"; @@ -542,7 +543,7 @@ export function installPassiveObserver(): void { } } catch (e) { // Never let observer errors bubble up to the host page. - console.debug("[Passive Observer] fetch hook error:", e); + verboseDebug("[Passive Observer] fetch hook error:", e); } return response; @@ -605,7 +606,7 @@ export function installPassiveObserver(): void { void persistItems(items); } } catch (e) { - console.debug("[Passive Observer] xhr load error:", e); + verboseDebug("[Passive Observer] xhr load error:", e); } }); } @@ -616,7 +617,7 @@ export function installPassiveObserver(): void { }; } - console.debug("[Passive Observer] Installed."); + verboseDebug("[Passive Observer] Installed."); } /** diff --git a/src/plugins/built-in/globalSearch/src/indexing/selfTests.ts b/src/plugins/built-in/globalSearch/src/indexing/selfTests.ts index 36a7bae1..f85d6abe 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/selfTests.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/selfTests.ts @@ -7,6 +7,7 @@ import { pickId, buildIndexItem, } from "./extract"; +import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog"; import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api"; import { coursesPayload, @@ -320,7 +321,7 @@ export async function runGlobalSearchSelfTests(): Promise { report.failures, ); } else { - console.info( + verboseInfo( `[Global Search Self-Tests] All ${report.passed} cases passed`, ); } diff --git a/src/plugins/built-in/globalSearch/src/indexing/utils.ts b/src/plugins/built-in/globalSearch/src/indexing/utils.ts index a5365dfa..1592c270 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/utils.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/utils.ts @@ -1,3 +1,4 @@ +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; /** * Check which items are already vectorized in embeddia's IndexedDB * Returns a Set of item IDs that are already indexed @@ -7,7 +8,7 @@ export async function getVectorizedItemIds(): Promise> { const request = indexedDB.open("embeddiaDB"); request.onerror = () => { - console.debug("Could not open embeddiaDB, assuming no items are vectorized"); + verboseDebug("Could not open embeddiaDB, assuming no items are vectorized"); resolve(new Set()); }; @@ -15,7 +16,7 @@ export async function getVectorizedItemIds(): Promise> { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains("embeddiaObjectStore")) { - console.debug("embeddiaObjectStore not found, assuming no items are vectorized"); + verboseDebug("embeddiaObjectStore not found, assuming no items are vectorized"); db.close(); resolve(new Set()); return; @@ -34,7 +35,7 @@ export async function getVectorizedItemIds(): Promise> { } }); - console.debug(`Found ${vectorizedIds.size} already vectorized items in embeddia DB`); + verboseDebug(`Found ${vectorizedIds.size} already vectorized items in embeddia DB`); db.close(); resolve(vectorizedIds); }; diff --git a/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorker.ts b/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorker.ts index 64f71f0d..cca5bc45 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorker.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorker.ts @@ -1,6 +1,7 @@ import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia"; import type { IndexItem } from "../types"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; let vectorIndex: EmbeddingIndex | null = null; let isInitialized = false; let initializationFailed = false; @@ -33,27 +34,27 @@ let streamingSession: { async function initWorker() { if (isInitialized) { - console.debug("Vector worker already initialized."); + verboseDebug("Vector worker already initialized."); return; } // Skip initialization in Firefox if (isFirefoxWorker()) { - console.debug("[Vector Worker] Vector search not supported in Firefox - skipping initialization"); + verboseDebug("[Vector Worker] Vector search not supported in Firefox - skipping initialization"); isInitialized = true; initializationFailed = true; vectorIndex = null; return; } - console.debug("Initializing vector worker..."); + verboseDebug("Initializing vector worker..."); try { await initializeModel(); vectorIndex = new EmbeddingIndex([]); const stored = await vectorIndex.getAllObjectsFromIndexedDB(); if (stored.length > 0) { - console.debug(`Found ${stored.length} existing items in IndexedDB`); + verboseDebug(`Found ${stored.length} existing items in IndexedDB`); loadedItemIds.clear(); @@ -64,14 +65,14 @@ async function initWorker() { } }); - console.debug( + verboseDebug( `Vector index loaded ${loadedItemIds.size} unique items from IndexedDB.`, ); } else { - console.debug("No existing vector index found in IndexedDB."); + verboseDebug("No existing vector index found in IndexedDB."); } isInitialized = true; - console.debug("Vector worker initialized successfully."); + verboseDebug("Vector worker initialized successfully."); } catch (e) { console.warn("[Vector Worker] Failed to initialize vector worker (will use text search only):", e); isInitialized = true; @@ -149,7 +150,7 @@ async function startStreamingSession( processingPromise: null, }; - console.debug( + verboseDebug( `Started streaming session for ${totalExpected} items with batch size ${batchSize}`, ); @@ -175,7 +176,7 @@ async function processStreamingBatch( streamingSession.totalReceived += items.length; streamingSession.pendingItems.push(...items); - console.debug( + verboseDebug( `Received streaming batch: ${items.length} items (${streamingSession.totalReceived}/${streamingSession.totalExpected})`, ); @@ -208,7 +209,7 @@ async function processStreamingItems() { if (unprocessedItems.length === 0) { streamingSession.totalProcessed += batchToProcess.length; - console.debug(`Skipped ${batchToProcess.length} already processed items`); + verboseDebug(`Skipped ${batchToProcess.length} already processed items`); continue; } @@ -231,7 +232,7 @@ async function processStreamingItems() { loadedItemIds.size % 200 === 0 ) { await vectorIndex!.saveIndex("indexedDB"); - console.debug( + verboseDebug( `Saved streaming index at ${streamingSession.totalProcessed} processed items (${loadedItemIds.size} total unique items)`, ); } @@ -272,7 +273,7 @@ async function finalizeStreamingSession() { try { if (vectorIndex) { await vectorIndex.saveIndex("indexedDB"); - console.debug("Final save of streaming index completed"); + verboseDebug("Final save of streaming index completed"); } } catch (e) { console.error("Error in final streaming save:", e); @@ -293,7 +294,7 @@ async function finalizeStreamingSession() { }, }); - console.debug( + verboseDebug( `Streaming session completed: ${totalProcessed}/${totalExpected} items processed`, ); } @@ -303,14 +304,14 @@ async function endStreamingSession() { return; } - console.debug("Ending streaming session..."); + verboseDebug("Ending streaming session..."); if (streamingSession.processingPromise) { await streamingSession.processingPromise; } if (streamingSession.pendingItems.length > 0) { - console.debug( + verboseDebug( `Processing ${streamingSession.pendingItems.length} remaining items before ending session`, ); streamingSession.processingPromise = processStreamingItems(); @@ -320,7 +321,7 @@ async function endStreamingSession() { try { if (vectorIndex) { await vectorIndex.saveIndex("indexedDB"); - console.debug("Final save before ending streaming session"); + verboseDebug("Final save before ending streaming session"); } } catch (e) { console.error("Error in final save before ending session:", e); @@ -341,7 +342,7 @@ async function endStreamingSession() { } async function processItems(items: IndexItem[], signal: AbortSignal) { - console.debug("Worker received process request."); + verboseDebug("Worker received process request."); if (initializationFailed || isFirefoxWorker()) { self.postMessage({ @@ -378,7 +379,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) { }); if (signal.aborted) { - console.debug("Processing cancelled before starting."); + verboseDebug("Processing cancelled before starting."); self.postMessage({ type: "progress", data: { @@ -390,7 +391,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) { } if (unprocessedItems.length === 0) { - console.debug( + verboseDebug( `No new items to process. ${loadedItemIds.size} items already in index.`, ); self.postMessage({ @@ -403,7 +404,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) { return; } - console.debug( + verboseDebug( `Starting processing of ${unprocessedItems.length} items (${items.length - unprocessedItems.length} already processed).`, ); self.postMessage({ @@ -419,7 +420,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) { let processedCount = 0; for (let i = 0; i < unprocessedItems.length; i += BATCH_SIZE) { if (signal.aborted) { - console.debug("Processing cancelled during batching."); + verboseDebug("Processing cancelled during batching."); self.postMessage({ type: "progress", data: { @@ -437,7 +438,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) { ) as (IndexItem & { embedding: number[] })[]; if (signal.aborted) { - console.debug("Processing cancelled after vectorization batch."); + verboseDebug("Processing cancelled after vectorization batch."); self.postMessage({ type: "progress", data: { @@ -464,7 +465,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) { } if (signal.aborted) { - console.debug("Processing cancelled before saving batch."); + verboseDebug("Processing cancelled before saving batch."); self.postMessage({ type: "progress", data: { @@ -481,7 +482,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) { ) { try { await vectorIndex!.saveIndex("indexedDB"); - console.debug( + verboseDebug( `Saved index after processing batch ${i / BATCH_SIZE + 1} (${loadedItemIds.size} total unique items)`, ); } catch (e) { @@ -505,7 +506,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) { }); } - console.debug( + verboseDebug( `Processing complete. Total unique items in index: ${loadedItemIds.size}`, ); self.postMessage({ @@ -520,7 +521,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) { } async function resetWorker() { - console.debug("Resetting vector worker state..."); + verboseDebug("Resetting vector worker state..."); loadedItemIds.clear(); @@ -532,7 +533,7 @@ async function resetWorker() { if (vectorIndex) { try { await vectorIndex.saveIndex("indexedDB"); - console.debug("Saved index before reset"); + verboseDebug("Saved index before reset"); } catch (e) { console.warn("Error saving index before reset:", e); } @@ -543,7 +544,7 @@ async function resetWorker() { await initWorker(); - console.debug( + verboseDebug( `Vector worker reset complete. Loaded ${loadedItemIds.size} items.`, ); diff --git a/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts b/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts index 33b92385..c1de4196 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts @@ -3,6 +3,7 @@ import type { IndexItem } from "../types"; import { isVectorSearchSupported } from "../../utils/browserDetection"; import vectorWorker from "./vectorWorker.ts?inlineWorker"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; export type ProgressCallback = (data: { status: "started" | "processing" | "complete" | "error" | "cancelled"; total?: number; @@ -38,7 +39,7 @@ export class VectorWorkerManager { static getInstance(): VectorWorkerManager { if (!VectorWorkerManager.instance) { - console.debug("Creating new VectorWorkerManager instance"); + verboseDebug("Creating new VectorWorkerManager instance"); VectorWorkerManager.instance = new VectorWorkerManager(); } return VectorWorkerManager.instance; @@ -47,7 +48,7 @@ export class VectorWorkerManager { private async initWorker(): Promise { // Skip initialization if vector search is not supported (e.g., Firefox) if (!isVectorSearchSupported()) { - console.debug("[VectorWorkerManager] Vector search not supported - skipping worker initialization"); + verboseDebug("[VectorWorkerManager] Vector search not supported - skipping worker initialization"); this.isInitialized = false; return Promise.resolve(); } @@ -55,19 +56,19 @@ export class VectorWorkerManager { if (this.isInitialized) return Promise.resolve(); if (this.readyPromise) return this.readyPromise; - console.debug("Lazy-loading vector worker..."); + verboseDebug("Lazy-loading vector worker..."); return new Promise((resolve, reject) => { if (this.worker) { - console.debug("Terminating existing worker before creating new one"); + verboseDebug("Terminating existing worker before creating new one"); this.worker.terminate(); this.worker = null; } - console.debug("Creating new vector worker instance"); + verboseDebug("Creating new vector worker instance"); this.worker = vectorWorker(); - console.log("Worker initialized", this.worker); + verboseLog("Worker initialized", this.worker); const timeout = setTimeout(() => { console.error("Vector worker initialization timed out"); @@ -82,14 +83,14 @@ export class VectorWorkerManager { this.worker!.addEventListener("message", (e) => { const { type, data } = e.data; - console.debug("Message from vector worker:", type, data); + verboseDebug("Message from vector worker:", type, data); switch (type) { case "ready": this.isInitialized = true; clearTimeout(timeout); this.updateActivity(); // Start idle timer after initialization - console.debug("Vector worker initialized and ready."); + verboseDebug("Vector worker initialized and ready."); resolve(); break; @@ -150,7 +151,7 @@ export class VectorWorkerManager { } private resetWorkerState() { - console.debug("Resetting vector worker state"); + verboseDebug("Resetting vector worker state"); if (this.worker) { this.worker.terminate(); this.worker = null; @@ -176,7 +177,7 @@ export class VectorWorkerManager { if (this.vectorizationLockCount > 0) return; if (this.streamingSession?.isActive) return; if (!this.isInitialized) return; - console.debug("[VectorWorker] Auto-shutting down due to 2 minutes of inactivity"); + verboseDebug("[VectorWorker] Auto-shutting down due to 2 minutes of inactivity"); this.resetWorkerState(); }, 120000); // 2 minutes } @@ -208,7 +209,7 @@ export class VectorWorkerManager { this.unloadTimer = setTimeout(() => { if (this.vectorizationLockCount > 0) return; if (!this.streamingSession?.isActive && this.isInitialized) { - console.debug("[VectorWorker] Auto-unloading after processing complete"); + verboseDebug("[VectorWorker] Auto-unloading after processing complete"); this.resetWorkerState(); } }, delay); @@ -295,7 +296,7 @@ export class VectorWorkerManager { }); if (uniqueItems.length !== items.length) { - console.debug( + verboseDebug( `Filtered out ${items.length - uniqueItems.length} duplicate items before processing`, ); } @@ -350,7 +351,7 @@ export class VectorWorkerManager { }; this.progressCallback = wrap; - console.debug( + verboseDebug( `Sending ${uniqueItems.length} unique items to worker for processing.`, ); @@ -378,7 +379,7 @@ export class VectorWorkerManager { ): Promise { // Skip if vector search is not supported if (!isVectorSearchSupported()) { - console.debug("[VectorWorker] Vector search not supported - skipping streaming session"); + verboseDebug("[VectorWorker] Vector search not supported - skipping streaming session"); if (onProgress) { onProgress({ status: "complete", @@ -390,7 +391,7 @@ export class VectorWorkerManager { // Only initialize if we expect items to process if (totalExpectedItems === 0) { - console.debug("[VectorWorker] No items expected, not starting streaming session"); + verboseDebug("[VectorWorker] No items expected, not starting streaming session"); return; } @@ -405,7 +406,7 @@ export class VectorWorkerManager { await new Promise((resolve) => setTimeout(resolve, 100)); } else { - console.debug(`Streaming session for job ${jobId} already active`); + verboseDebug(`Streaming session for job ${jobId} already active`); return; } } @@ -425,7 +426,7 @@ export class VectorWorkerManager { lastActivityTime: Date.now(), }; - console.debug( + verboseDebug( `Starting streaming session for job ${jobId} with ${totalExpectedItems} items (batch size ${batchSize})`, ); @@ -456,7 +457,7 @@ export class VectorWorkerManager { }); if (uniqueItems.length !== items.length) { - console.debug( + verboseDebug( `[Streaming] Filtered out ${items.length - uniqueItems.length} duplicate items before streaming`, ); } @@ -472,7 +473,7 @@ export class VectorWorkerManager { this.streamingSession.inactivityTimer = setTimeout(() => { if (this.streamingSession?.isActive) { - console.debug( + verboseDebug( "[VectorWorker] Auto-ending streaming session due to inactivity", ); this.endStreamingSession(); @@ -513,7 +514,7 @@ export class VectorWorkerManager { this.streamingSession.flushTimer = null; } - console.debug( + verboseDebug( `Streaming batch of ${batch.length} items to worker (${this.streamingSession.totalSent}/${this.streamingSession.totalExpected})`, ); @@ -549,7 +550,7 @@ export class VectorWorkerManager { type: "endStreaming", }); - console.debug("Streaming session ended"); + verboseDebug("Streaming session ended"); if (this.progressCallback) { this.progressCallback({ @@ -590,12 +591,12 @@ export class VectorWorkerManager { } terminate() { - console.debug("Terminating Vector Worker Manager..."); + verboseDebug("Terminating Vector Worker Manager..."); this.resetWorkerState(); } async resetWorker(): Promise { - console.debug("Resetting vector worker..."); + verboseDebug("Resetting vector worker..."); if (this.streamingSession?.isActive) { await this.endStreamingSession(); @@ -605,6 +606,6 @@ export class VectorWorkerManager { this.worker!.postMessage({ type: "reset" }); - console.debug("Reset command sent to worker"); + verboseDebug("Reset command sent to worker"); } } diff --git a/src/plugins/built-in/globalSearch/src/search/searchUtils.ts b/src/plugins/built-in/globalSearch/src/search/searchUtils.ts index b4d4b91d..67e26b3e 100644 --- a/src/plugins/built-in/globalSearch/src/search/searchUtils.ts +++ b/src/plugins/built-in/globalSearch/src/search/searchUtils.ts @@ -10,6 +10,7 @@ import { isStrongLexicalMatch, STRONG_LEXICAL_THRESHOLD, } from "./lexicalMatch"; +import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog"; /** Same normalization as lexical matching (trim + lowercase). */ function normSearchKey(s: string): string { @@ -91,7 +92,7 @@ function setCachedResults(query: string, results: CombinedResult[]) { */ export function clearSearchCache(): void { searchCache.clear(); - console.debug("[Search] Search result cache cleared"); + verboseDebug("[Search] Search result cache cleared"); } // Listen for cache clear events (e.g., on extension update) diff --git a/src/plugins/built-in/globalSearch/src/search/vector/vectorSearch.ts b/src/plugins/built-in/globalSearch/src/search/vector/vectorSearch.ts index a56a5902..606a3b9e 100644 --- a/src/plugins/built-in/globalSearch/src/search/vector/vectorSearch.ts +++ b/src/plugins/built-in/globalSearch/src/search/vector/vectorSearch.ts @@ -3,6 +3,7 @@ import type { IndexItem } from "../../indexing/types"; import type { SearchResult } from "embeddia"; import { isVectorSearchSupported } from "../../utils/browserDetection"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; let vectorIndex: EmbeddingIndex | null = null; let initializationAttempted = false; let initializationFailed = false; @@ -11,7 +12,7 @@ export async function initVectorSearch() { // Skip initialization if already attempted and failed, or if not supported if (initializationFailed || !isVectorSearchSupported()) { if (!isVectorSearchSupported()) { - console.debug("[Vector Search] Vector search not supported in Firefox - using text search only"); + verboseDebug("[Vector Search] Vector search not supported in Firefox - using text search only"); } return; } @@ -26,7 +27,7 @@ export async function initVectorSearch() { await initializeModel(); vectorIndex = new EmbeddingIndex([]); vectorIndex.preloadIndexedDB(); - console.debug("[Vector Search] Initialized successfully"); + verboseDebug("[Vector Search] Initialized successfully"); } catch (e) { console.warn("[Vector Search] Failed to initialize vector search (will use text search only):", e); initializationFailed = true; @@ -66,7 +67,7 @@ function setCachedEmbedding(query: string, embedding: number[]) { */ export function clearEmbeddingCache(): void { embeddingCache.clear(); - console.debug("[Vector Search] Embedding cache cleared"); + verboseDebug("[Vector Search] Embedding cache cleared"); } // Listen for cache clear events (e.g., on extension update) diff --git a/src/plugins/built-in/globalSearch/src/utils/versionCheck.ts b/src/plugins/built-in/globalSearch/src/utils/versionCheck.ts index 2c4e9b04..37618793 100644 --- a/src/plugins/built-in/globalSearch/src/utils/versionCheck.ts +++ b/src/plugins/built-in/globalSearch/src/utils/versionCheck.ts @@ -1,6 +1,7 @@ import browser from "webextension-polyfill"; import { resetSearchIndexes } from "../indexing/resetIndexes"; +import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; const VERSION_STORAGE_KEY = "betterseqta-global-search-version"; const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version"; @@ -60,7 +61,7 @@ export async function checkAndHandleUpdate(): Promise { // First run: just remember the version, don't reset (the user likely // just installed the extension; the index is already empty). if (!storedVersion) { - console.debug( + verboseDebug( `[Version Check] First run detected, storing version ${currentVersion}`, ); storeVersion(currentVersion); @@ -71,7 +72,7 @@ export async function checkAndHandleUpdate(): Promise { return false; } - console.log( + verboseLog( `[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, resetting search index...`, ); @@ -79,7 +80,7 @@ export async function checkAndHandleUpdate(): Promise { try { await resetSearchIndexes(); - console.log( + verboseLog( "[Version Check] Search index reset; next indexing pass will repopulate from scratch.", ); } catch (e) { @@ -112,7 +113,7 @@ export async function clearAllCaches(): Promise { } catch (e: any) { // Module might not be loaded yet, or CSS preload error - that's okay if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) { - console.debug("[Version Check] Could not clear search cache:", e); + verboseDebug("[Version Check] Could not clear search cache:", e); } } @@ -122,12 +123,12 @@ export async function clearAllCaches(): Promise { } catch (e: any) { // Module might not be loaded yet, or CSS preload error - that's okay if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) { - console.debug("[Version Check] Could not clear embedding cache:", e); + verboseDebug("[Version Check] Could not clear embedding cache:", e); } } }, 50); - console.debug("[Version Check] All caches cleared"); + verboseDebug("[Version Check] All caches cleared"); } catch (e) { console.error("[Version Check] Error clearing caches:", e); } diff --git a/src/plugins/built-in/notificationCollector/index.ts b/src/plugins/built-in/notificationCollector/index.ts index 779183cb..78d30bea 100644 --- a/src/plugins/built-in/notificationCollector/index.ts +++ b/src/plugins/built-in/notificationCollector/index.ts @@ -1,5 +1,6 @@ import type { Plugin } from "../../core/types"; import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; +import { verboseInfo } from "@/utils/verboseLog"; interface NotificationCollectorStorage { lastNotificationCount: number; @@ -75,7 +76,7 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = { if (alertDiv) { alertDiv.textContent = notificationCount.toString(); } else { - console.info("[BetterSEQTA+] No notifications currently"); + verboseInfo("[BetterSEQTA+] No notifications currently"); } } catch (error) { console.error("[BetterSEQTA+] Error fetching notifications:", error); diff --git a/src/plugins/built-in/themes/theme-manager.ts b/src/plugins/built-in/themes/theme-manager.ts index 492315df..33b5c7e9 100644 --- a/src/plugins/built-in/themes/theme-manager.ts +++ b/src/plugins/built-in/themes/theme-manager.ts @@ -17,6 +17,12 @@ import { clearCustomThemeAdaptiveCssVariables, setCustomThemeAdaptiveCssVariables, } from "@/seqta/ui/colors/customThemeAdaptiveBindings"; +import { + clearThemeInPage, + syncThemeToPage, + type ThemePageSyncInput, +} from "@/seqta/utils/patchThemeImagesPageContext"; +import { verboseDebug, verboseInfo } from "@/utils/verboseLog"; import { clearThemeRuntime, injectThemeDom, @@ -56,18 +62,15 @@ export type InstallThemeMeta = { export class ThemeManager { private static instance: ThemeManager; private currentTheme: CustomTheme | null = null; - private styleElement: HTMLStyleElement | null = null; - private previewStyleElement: HTMLStyleElement | null = null; private previousImageVariableNames: string[] = []; + private lastSyncedImageKey: string | null = null; private originalPreviewColor: string | null = null; private originalPreviewTheme: boolean | null = null; - private imageUrlCache: Map = new Map(); private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 }; private storeUpdateCheckRunning = false; - private headObserver: MutationObserver | null = null; private constructor() { - console.debug("[ThemeManager] Initializing..."); + verboseDebug("[ThemeManager] Initializing..."); } public static getInstance(): ThemeManager { @@ -88,7 +91,7 @@ export class ThemeManager { * Get a theme by ID from storage */ public async getTheme(themeId: string): Promise { - console.debug("[ThemeManager] Getting theme:", themeId); + verboseDebug("[ThemeManager] Getting theme:", themeId); try { const theme = (await localforage.getItem(themeId)) as CustomTheme; return theme; @@ -164,17 +167,17 @@ export class ThemeManager { * Disable the current theme without deleting it */ public async disableTheme(): Promise { - console.debug("[ThemeManager] Disabling current theme"); + verboseDebug("[ThemeManager] Disabling current theme"); try { if (!this.currentTheme) { - console.debug("[ThemeManager] No theme to disable"); + verboseDebug("[ThemeManager] No theme to disable"); return; } await this.removeTheme(this.currentTheme); this.currentTheme = null; settingsState.selectedTheme = ""; - console.debug("[ThemeManager] Theme disabled successfully"); + verboseDebug("[ThemeManager] Theme disabled successfully"); } catch (error) { console.error("[ThemeManager] Error disabling theme:", error); } @@ -211,7 +214,7 @@ export class ThemeManager { * Initialize the theme system and restore previous state */ public async initialize(): Promise { - console.debug("[ThemeManager] Starting initialization"); + verboseDebug("[ThemeManager] Starting initialization"); try { const neumorphicThemeId = "9a9786d1-b5fc-4a91-8c7a-f8bf7f7679ad"; const migrationCSS = "#title {\nbackground: transparent !important;\n}"; @@ -224,7 +227,7 @@ export class ThemeManager { const themeCreatorOpen = localStorage.getItem("themeCreatorOpen"); if (themeCreatorOpen === "true") { - console.debug( + verboseDebug( "[ThemeManager] Theme creator was open, clearing preview state", ); this.clearPreview(); @@ -232,7 +235,7 @@ export class ThemeManager { } if (settingsState.selectedTheme) { - console.debug( + verboseDebug( "[ThemeManager] Found selected theme, restoring:", settingsState.selectedTheme, ); @@ -249,7 +252,7 @@ export class ThemeManager { * Clean up theme system resources */ public async cleanup(): Promise { - console.debug("[ThemeManager] Cleaning up resources"); + verboseDebug("[ThemeManager] Cleaning up resources"); try { if (this.currentTheme) { await this.removeTheme(this.currentTheme, false); @@ -263,7 +266,7 @@ export class ThemeManager { * Set and apply a theme by ID */ public async setTheme(themeId: string, applyViewTransition: boolean = true): Promise { - console.debug("[ThemeManager] Setting theme:", themeId); + verboseDebug("[ThemeManager] Setting theme:", themeId); try { const theme = (await localforage.getItem(themeId)) as CustomTheme; if (!theme) { @@ -273,7 +276,7 @@ export class ThemeManager { // Store original settings before applying new theme if (!settingsState.selectedTheme) { - console.debug("[ThemeManager] Storing original settings"); + verboseDebug("[ThemeManager] Storing original settings"); settingsState.originalSelectedColor = settingsState.selectedColor; if (shouldForceThemeAppearance(theme)) { @@ -286,7 +289,7 @@ export class ThemeManager { await this.applyViewTransition(async () => { // Remove current theme if exists if (this.currentTheme) { - console.debug("[ThemeManager] Removing current theme"); + verboseDebug("[ThemeManager] Removing current theme"); await this.removeThemeWithoutTransition(this.currentTheme); } @@ -298,7 +301,7 @@ export class ThemeManager { } else { // Remove current theme if exists if (this.currentTheme) { - console.debug("[ThemeManager] Removing current theme"); + verboseDebug("[ThemeManager] Removing current theme"); await this.removeThemeWithoutTransition(this.currentTheme); } @@ -317,7 +320,7 @@ export class ThemeManager { * Apply theme components (CSS, images, settings) */ private async applyTheme(theme: CustomTheme): Promise { - console.debug("[ThemeManager] Applying theme:", theme.name); + verboseDebug("[ThemeManager] Applying theme:", theme.name); try { // Run the theme script BEFORE injecting CustomCSS so any state the // script publishes (e.g. `data-city-state` and `--city-sky-color` for @@ -326,40 +329,34 @@ export class ThemeManager { // its previous state before snapping to the right colour. runThemeScript(theme.themeScript); - // Apply custom CSS - if (theme.CustomCSS) { - console.debug("[ThemeManager] Applying custom CSS"); - this.applyCustomCSS(theme.CustomCSS); - } - - // Apply custom images - if (theme.CustomImages) { - console.debug("[ThemeManager] Applying custom images"); - theme.CustomImages.forEach((image) => { - const imageUrl = URL.createObjectURL(image.blob); - document.documentElement.style.setProperty( - "--" + image.variableName, - `url(${imageUrl})`, - ); - }); + // Custom CSS + images must be applied in page context (Firefox). + verboseDebug("[ThemeManager] Applying theme styles in page context"); + await syncThemeToPage({ + customCss: theme.CustomCSS || "", + images: theme.CustomImages ?? [], + }); + if (theme.CustomImages?.length) { + this.lastSyncedImageKey = this.imageSyncKey(theme.CustomImages); + } else { + this.lastSyncedImageKey = null; } // Apply theme settings if (shouldForceThemeAppearance(theme)) { const dark = getForcedDarkMode(theme); - console.debug("[ThemeManager] Setting dark mode:", dark); + verboseDebug("[ThemeManager] Setting dark mode:", dark); settingsState.DarkMode = dark; } // Use the stored selected color if available, otherwise use the default if (theme.selectedColor) { - console.debug( + verboseDebug( "[ThemeManager] Restoring saved color:", theme.selectedColor, ); settingsState.selectedColor = theme.selectedColor; } else if (theme.defaultColour) { - console.debug( + verboseDebug( "[ThemeManager] Using default color:", theme.defaultColour, ); @@ -381,7 +378,7 @@ export class ThemeManager { theme: CustomTheme, clearSelectedTheme: boolean = true, ): Promise { - console.debug("[ThemeManager] Removing theme with transition:", theme.name); + verboseDebug("[ThemeManager] Removing theme with transition:", theme.name); try { await this.applyViewTransition(async () => { await this.removeThemeWithoutTransition(theme, clearSelectedTheme); @@ -398,37 +395,13 @@ export class ThemeManager { theme: CustomTheme, clearSelectedTheme: boolean = true, ): Promise { - console.debug("[ThemeManager] Removing theme:", theme.name); + verboseDebug("[ThemeManager] Removing theme:", theme.name); try { clearThemeRuntime(); - // Disconnect the head observer BEFORE removing the style element, - // otherwise the removal fires the observer and it would no-op only - // because the style is already gone — wasted work, but harmless. - this.disconnectStyleObserver(); - - // Remove custom CSS - if (this.styleElement) { - console.debug("[ThemeManager] Removing custom CSS"); - this.styleElement.remove(); - this.styleElement = null; - } - - // Remove custom images - if (theme.CustomImages) { - console.debug("[ThemeManager] Removing custom images"); - theme.CustomImages.forEach((image) => { - const value = document.documentElement.style.getPropertyValue( - "--" + image.variableName, - ); - if (value) { - URL.revokeObjectURL(value.slice(4, -1)); // Remove url() wrapper - } - document.documentElement.style.removeProperty( - "--" + image.variableName, - ); - }); - } + verboseDebug("[ThemeManager] Removing theme page styles"); + clearThemeInPage(); + this.lastSyncedImageKey = null; if (this.currentTheme) { // Store the current color with the theme before removing it @@ -449,7 +422,7 @@ export class ThemeManager { // Restore original settings if (settingsState.originalSelectedColor) { - console.debug( + verboseDebug( "[ThemeManager] Restoring original color:", settingsState.originalSelectedColor, ); @@ -457,7 +430,7 @@ export class ThemeManager { } if (settingsState.originalDarkMode !== undefined) { - console.debug( + verboseDebug( "[ThemeManager] Restoring original dark mode:", settingsState.originalDarkMode, ); @@ -476,58 +449,21 @@ export class ThemeManager { } /** - * Apply custom CSS to the document. The ` diff --git a/src/interface/utils/syncPageTheme.ts b/src/interface/utils/syncPageTheme.ts index 7e45a197..77612741 100644 --- a/src/interface/utils/syncPageTheme.ts +++ b/src/interface/utils/syncPageTheme.ts @@ -7,6 +7,8 @@ const THEME_CSS_VARS = [ "--text-color", "--background-primary", "--background-secondary", + "--theme-primary", + "--theme-secondary", "--text-primary", "--theme-offset-bg", "--better-sub", diff --git a/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte b/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte index 354c1c20..a12c6043 100644 --- a/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte +++ b/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte @@ -1,5 +1,11 @@ + + diff --git a/src/interface/components/icons/LucideSun.svelte b/src/interface/components/icons/LucideSun.svelte new file mode 100644 index 00000000..1914ef63 --- /dev/null +++ b/src/interface/components/icons/LucideSun.svelte @@ -0,0 +1,25 @@ + + + diff --git a/src/interface/pages/themeCreator.svelte b/src/interface/pages/themeCreator.svelte index 48261bd2..d9a49b46 100644 --- a/src/interface/pages/themeCreator.svelte +++ b/src/interface/pages/themeCreator.svelte @@ -28,6 +28,8 @@ import { themeUpdates } from '../hooks/ThemeUpdates' import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator' import ThemeBlobImage from '@/interface/components/themes/ThemeBlobImage.svelte' + import LucideMoon from '@/interface/components/icons/LucideMoon.svelte' + import LucideSun from '@/interface/components/icons/LucideSun.svelte' const { themeID } = $props<{ themeID: string }>() const themeManager = ThemeManager.getInstance(); @@ -253,19 +255,23 @@ {:else if item.type === 'lightDarkToggle'} {/if} diff --git a/src/lib/icons/lucideMoon.ts b/src/lib/icons/lucideMoon.ts new file mode 100644 index 00000000..fd880246 --- /dev/null +++ b/src/lib/icons/lucideMoon.ts @@ -0,0 +1,6 @@ +/** + * Lucide "moon" icon (https://lucide.dev/icons/moon, ISC License). + */ +export const LUCIDE_MOON_ICON_SVG = ` + +`.trim(); diff --git a/src/lib/icons/lucideSun.ts b/src/lib/icons/lucideSun.ts new file mode 100644 index 00000000..be2f252d --- /dev/null +++ b/src/lib/icons/lucideSun.ts @@ -0,0 +1,15 @@ +/** + * Lucide "sun" icon (https://lucide.dev/icons/sun, ISC License). + * Thin strokes — reads clearly as a sun, not a gear. + */ +export const LUCIDE_SUN_ICON_SVG = ` + + + + + + + + + +`.trim(); diff --git a/src/seqta/ui/AddBetterSEQTAElements.ts b/src/seqta/ui/AddBetterSEQTAElements.ts index ca01d6da..e572e5bd 100644 --- a/src/seqta/ui/AddBetterSEQTAElements.ts +++ b/src/seqta/ui/AddBetterSEQTAElements.ts @@ -14,6 +14,8 @@ import stringToHTML from "@/seqta/utils/stringToHTML"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { updateAllColors } from "./colors/Manager"; import { delay } from "@/seqta/utils/delay"; +import { LUCIDE_MOON_ICON_SVG } from "@/lib/icons/lucideMoon"; +import { LUCIDE_SUN_ICON_SVG } from "@/lib/icons/lucideSun"; let cachedUserInfo: any = null; @@ -413,14 +415,14 @@ function GetLightDarkModeString() { } async function addDarkLightToggle(parent?: Element) { - const SUN_ICON_SVG = /* html */ ``; - const MOON_ICON_SVG = /* html */ ``; + const SUN_ICON_SVG = LUCIDE_SUN_ICON_SVG; + const MOON_ICON_SVG = LUCIDE_MOON_ICON_SVG; const toggleTarget = parent ?? document.getElementById("content")!; toggleTarget.append( stringToHTML(/* html */ ` `).firstChild!, From 670f9d73f304801f3befaf15eac78ce8825d33d0 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Thu, 25 Jun 2026 18:36:57 +0930 Subject: [PATCH 17/36] fix(cloud PFP): Not pulling from cloud on page load --- .../components/CloudPfpAvatar.svelte | 8 +- .../ProfilePictureSetting.svelte | 125 ++++++++++----- src/plugins/built-in/profilePicture/index.ts | 26 +-- src/seqta/utils/cloudPfpCache.ts | 8 + src/seqta/utils/cloudPfpSync.ts | 149 +++++++++++++++--- 5 files changed, 249 insertions(+), 67 deletions(-) diff --git a/src/interface/components/CloudPfpAvatar.svelte b/src/interface/components/CloudPfpAvatar.svelte index d44a181a..c8bf4891 100644 --- a/src/interface/components/CloudPfpAvatar.svelte +++ b/src/interface/components/CloudPfpAvatar.svelte @@ -1,5 +1,5 @@ -
value ? null : triggerSelect()} - ondragover={(e) => { e.stopPropagation(); dragging = true }} - ondragleave={() => dragging = false} - ondrop={onDrop} - onkeydown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - triggerSelect() - } - }} - role="button" - tabindex="0" -> - {#if value} - Profile - - {:else} -
- {'\ued47'} - Upload +
+ {#if useCloudPfp} +
+ +
+ {#if cloudRefreshError} +

{cloudRefreshError}

+ {/if} {/if} - - {#if dragging} -
- {/if} + +
value ? null : triggerSelect()} + ondragover={(e) => { e.stopPropagation(); dragging = true }} + ondragleave={() => dragging = false} + ondrop={onDrop} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + triggerSelect() + } + }} + role="button" + tabindex="0" + > + {#if value} + Profile + + {:else} +
+ {'\ued47'} + Upload +
+ {/if} + + {#if dragging} +
+ {/if} +
diff --git a/src/plugins/built-in/profilePicture/index.ts b/src/plugins/built-in/profilePicture/index.ts index 450b567e..4d5ad7b7 100644 --- a/src/plugins/built-in/profilePicture/index.ts +++ b/src/plugins/built-in/profilePicture/index.ts @@ -8,7 +8,7 @@ import ProfilePictureSetting from "./ProfilePictureSetting.svelte"; import { waitForElm } from "@/seqta/utils/waitForElm"; import browser from "webextension-polyfill"; import { cloudAuth } from "@/seqta/utils/CloudAuth"; -import { resolveCloudPfp } from "@/seqta/utils/cloudPfpCache"; +import { resolveCloudPfp, defaultAccountsPfpUrl } from "@/seqta/utils/cloudPfpCache"; import styles from "./styles.css?inline"; import localforage from "localforage"; @@ -64,10 +64,13 @@ const profilePicturePlugin: Plugin = { } const useCloud = api.settings.useCloudPfp; - const pfpUrl = cloudAuth.state.user?.pfpUrl; + const userId = cloudAuth.state.user?.id; + const pfpUrl = + cloudAuth.state.user?.pfpUrl ?? + (userId ? defaultAccountsPfpUrl(userId) : undefined); - if (useCloud && pfpUrl && cloudAuth.state.user?.id) { - const resolved = await resolveCloudPfp(cloudAuth.state.user.id, pfpUrl); + if (useCloud && pfpUrl && userId) { + const resolved = await resolveCloudPfp(userId, pfpUrl); if (resolved) { currentBlobUrl = resolved.src; img = document.createElement("img"); @@ -92,6 +95,13 @@ const profilePicturePlugin: Plugin = { } } + if (api.settings.useCloudPfp && cloudAuth.state.isLoggedIn) { + const { pullCloudProfilePictureFromServer } = await import( + "@/seqta/utils/cloudPfpSync" + ); + await pullCloudProfilePictureFromServer(); + } + await applyProfileImage(); const onLocalPictureUpdated = () => { @@ -114,11 +124,9 @@ const profilePicturePlugin: Plugin = { }); const useCloudUnreg = api.settings.onChange("useCloudPfp", (enabled: boolean) => { - if (enabled) { - void import("@/seqta/utils/cloudPfpSync").then(({ syncLocalProfilePictureToCloud }) => - syncLocalProfilePictureToCloud(), - ); - } + void import("@/seqta/utils/cloudPfpSync").then(({ onUseCloudPfpToggled }) => + onUseCloudPfpToggled(enabled), + ); void applyProfileImage(); }); diff --git a/src/seqta/utils/cloudPfpCache.ts b/src/seqta/utils/cloudPfpCache.ts index bbb3b560..64c86cfc 100644 --- a/src/seqta/utils/cloudPfpCache.ts +++ b/src/seqta/utils/cloudPfpCache.ts @@ -28,6 +28,10 @@ export function pfpUrlWithHash(url: string, hash: string | null | undefined): st return `${base}?v=${hash}`; } +export function defaultAccountsPfpUrl(userId: string): string { + return `${ACCOUNTS_BASE}/api/user/pfp/${userId}`; +} + async function fetchServerHash(userId: string): Promise { const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp/${userId}/meta`); if (!res.ok) return null; @@ -95,6 +99,10 @@ export async function resolveCloudPfp( if (localHash) { headers["If-None-Match"] = `"${localHash}"`; } + const token = await cloudAuth.getStoredToken(); + if (token && isAccountsHostedPfpUrl(pfpUrl)) { + headers.Authorization = `Bearer ${token}`; + } const res = await fetch(imageUrl, { headers }); if (res.status === 304 && localBlob instanceof Blob) { diff --git a/src/seqta/utils/cloudPfpSync.ts b/src/seqta/utils/cloudPfpSync.ts index 2271307c..9532597e 100644 --- a/src/seqta/utils/cloudPfpSync.ts +++ b/src/seqta/utils/cloudPfpSync.ts @@ -1,7 +1,10 @@ import browser from "webextension-polyfill"; import localforage from "localforage"; -import { cloudAuth } from "@/seqta/utils/CloudAuth"; -import { clearCloudPfpCache, pfpUrlWithHash } from "@/seqta/utils/cloudPfpCache"; +import { cloudAuth, type CloudUser } from "@/seqta/utils/CloudAuth"; +import { + clearCloudPfpCache, + pfpUrlWithHash, +} from "@/seqta/utils/cloudPfpCache"; const ACCOUNTS_BASE = "https://accounts.betterseqta.org"; const PLUGIN_SETTINGS_KEY = "plugin.profile-picture.settings"; @@ -54,6 +57,92 @@ async function parseJsonResponse(r: Response): Promise> } } +function mergeMeIntoUser(current: CloudUser, data: Record): CloudUser { + const raw = (data.user as Record | undefined) ?? data; + const pfpUrlRaw = raw.pfpUrl as string | null | undefined; + const pfpHash = (raw.pfpHash as string | null | undefined) ?? null; + const pfpUrl = + pfpUrlRaw == null || pfpUrlRaw === "" + ? undefined + : pfpUrlWithHash(pfpUrlRaw, pfpHash); + + return { + ...current, + email: (raw.email as string | undefined) ?? current.email, + username: (raw.username as string | undefined) ?? current.username, + displayName: (raw.displayName as string | undefined) ?? current.displayName, + admin_level: (raw.admin_level as number | undefined) ?? current.admin_level, + pfpUrl, + pfpHash, + }; +} + +/** Fetch `/api/auth/me` and update stored user (pfpUrl / pfpHash). */ +export async function refreshCloudUserFromServer(): Promise<{ + success: boolean; + error?: string; +}> { + if (!cloudAuth.state.isLoggedIn) { + return { success: false, error: "Not signed in to BetterSEQTA Cloud" }; + } + + const token = await cloudAuth.getStoredToken(); + if (!token) return { success: false, error: "Not signed in to BetterSEQTA Cloud" }; + + const current = cloudAuth.state.user; + if (!current?.id) return { success: false, error: "No cloud user on this device" }; + + try { + const res = await fetch(`${ACCOUNTS_BASE}/api/auth/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await parseJsonResponse(res); + if (!res.ok) { + return { + success: false, + error: (data.error as string) ?? `Could not refresh account (${res.status})`, + }; + } + + await cloudAuth.setUser(mergeMeIntoUser(current, data)); + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Could not refresh account", + }; + } +} + +/** Pull cloud avatar metadata from the server and refresh the in-page profile image. */ +export async function pullCloudProfilePictureFromServer(): Promise<{ + success: boolean; + error?: string; +}> { + const refreshed = await refreshCloudUserFromServer(); + if (!refreshed.success) return refreshed; + + const userId = cloudAuth.state.user?.id; + if (userId) await clearCloudPfpCache(userId); + await notifyProfilePictureChanged(); + return { success: true }; +} + +/** When cloud PFP is enabled: upload local image if present, otherwise pull from server. */ +export async function onUseCloudPfpToggled(enabled: boolean): Promise { + if (!enabled) { + await notifyProfilePictureChanged(); + return; + } + + const blob = await profileStore.getItem("profile-picture"); + if (blob instanceof Blob) { + await syncLocalProfilePictureToCloud(); + } else { + await pullCloudProfilePictureFromServer(); + } +} + export async function syncLocalProfilePictureToCloud(): Promise<{ success: boolean; error?: string; @@ -72,22 +161,7 @@ export async function syncLocalProfilePictureToCloud(): Promise<{ try { if (!blob || !(blob instanceof Blob)) { - const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp/clear`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - }); - const data = await parseJsonResponse(res); - if (!res.ok) { - return { success: false, error: (data.error as string) ?? `Clear failed (${res.status})` }; - } - if (user) { - await cloudAuth.setUser({ ...user, pfpUrl: undefined, pfpHash: null }); - } - if (userId) await clearCloudPfpCache(userId); + // No local upload — keep the server avatar; do not clear cloud. return { success: true }; } @@ -131,6 +205,45 @@ export async function syncLocalProfilePictureToCloud(): Promise<{ } } +/** Upload local image to cloud, or clear cloud only when explicitly removing local image. */ +export async function clearCloudProfilePicture(): Promise<{ + success: boolean; + error?: string; +}> { + if (!cloudAuth.state.isLoggedIn) return { success: true }; + + const token = await cloudAuth.getStoredToken(); + if (!token) return { success: false, error: "Not logged in" }; + + const user = cloudAuth.state.user; + const userId = user?.id; + + try { + const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp/clear`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + const data = await parseJsonResponse(res); + if (!res.ok) { + return { success: false, error: (data.error as string) ?? `Clear failed (${res.status})` }; + } + if (user) { + await cloudAuth.setUser({ ...user, pfpUrl: undefined, pfpHash: null }); + } + if (userId) await clearCloudPfpCache(userId); + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Cloud profile picture clear failed", + }; + } +} + /** Notify SEQTA content scripts to refresh the in-page profile image. */ export async function notifyProfilePictureChanged(): Promise { const revision = Date.now(); From 969c3bbdd5ea1f86b70c60d6af6eb408da047115 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Thu, 25 Jun 2026 19:01:00 +0930 Subject: [PATCH 18/36] fix(cloud PFP): button placement --- .../ProfilePictureSetting.svelte | 97 ++++++++++--------- 1 file changed, 53 insertions(+), 44 deletions(-) diff --git a/src/plugins/built-in/profilePicture/ProfilePictureSetting.svelte b/src/plugins/built-in/profilePicture/ProfilePictureSetting.svelte index 9aec678e..2a35d6b1 100644 --- a/src/plugins/built-in/profilePicture/ProfilePictureSetting.svelte +++ b/src/plugins/built-in/profilePicture/ProfilePictureSetting.svelte @@ -112,57 +112,66 @@ } -
- {#if useCloudPfp} -
- +
+
+ {#if useCloudPfp} + -
- {#if cloudRefreshError} -

{cloudRefreshError}

+ {/if} - {/if} -
value ? null : triggerSelect()} - ondragover={(e) => { e.stopPropagation(); dragging = true }} - ondragleave={() => dragging = false} - ondrop={onDrop} - onkeydown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - triggerSelect() - } - }} - role="button" - tabindex="0" - > - {#if value} - Profile - - {:else} -
- {'\ued47'} - Upload -
- {/if} - - {#if dragging} -
- {/if} +
(value ? null : triggerSelect())} + ondragover={(e) => { e.stopPropagation(); dragging = true }} + ondragleave={() => dragging = false} + ondrop={onDrop} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + triggerSelect() + } + }} + role="button" + tabindex="0" + > + {#if value} +
+ Local profile + +
+ {:else} +
+ {'\ued47'} + Upload +
+ {/if} + + {#if dragging} +
+ {/if} +
+ {#if cloudRefreshError} +

{cloudRefreshError}

+ {/if}
From e400890eee63acc2f4bee9c459990ea3fd26ff40 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Thu, 25 Jun 2026 19:32:53 +0930 Subject: [PATCH 19/36] fix(accounts): login with account give device name --- src/background.ts | 31 ++++++++----- src/seqta/utils/CloudAuth.ts | 3 ++ src/seqta/utils/bsplusDeviceName.ts | 70 +++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 src/seqta/utils/bsplusDeviceName.ts diff --git a/src/background.ts b/src/background.ts index 6a6b5970..fd7aeb16 100644 --- a/src/background.ts +++ b/src/background.ts @@ -11,6 +11,7 @@ import { requestCloudSettingsDebouncedUpload, runCloudSettingsPoll, } from "./background/cloudSettingsAutoSync"; +import { getBsplusDeviceName } from "@/seqta/utils/bsplusDeviceName"; /** * Session-only dev-mode override of the content API base. @@ -178,25 +179,33 @@ function handleCloudReserveClient(request: any, sendResponse: MessageSender): bo } function handleCloudLogin(request: any, sendResponse: MessageSender): boolean { - const { client_id, redirect_uri, login, password } = request; + const { client_id, redirect_uri, login, password, device_name } = request; if (!client_id || !redirect_uri || !login || !password) { sendResponse({ error: "Missing client_id, redirect_uri, login, or password" }); return false; } - fetch("https://accounts.betterseqta.org/api/bsplus/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ client_id, redirect_uri, login, password }), - }) - .then(async (r) => { + void (async () => { + const loginBody: Record = { + client_id, + redirect_uri, + login, + password, + device_name: device_name ?? await getBsplusDeviceName(), + }; + try { + const r = await fetch("https://accounts.betterseqta.org/api/bsplus/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(loginBody), + }); const data = await parseJsonResponse(r); if (!r.ok) sendResponse({ error: data?.error ?? "Login failed" }); else sendResponse(data); - }) - .catch((err) => { + } catch (err) { console.error("[Background] cloudLogin error:", err); - sendResponse({ error: err?.message ?? "Network error" }); - }); + sendResponse({ error: (err as Error)?.message ?? "Network error" }); + } + })(); return true; } diff --git a/src/seqta/utils/CloudAuth.ts b/src/seqta/utils/CloudAuth.ts index 0a178c94..baa7a52e 100644 --- a/src/seqta/utils/CloudAuth.ts +++ b/src/seqta/utils/CloudAuth.ts @@ -1,4 +1,5 @@ import browser from "webextension-polyfill"; +import { getBsplusDeviceName } from "@/seqta/utils/bsplusDeviceName"; import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache"; import { clearLastUploadedSnapshot } from "@/seqta/utils/cloudSettingsSync"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; @@ -167,12 +168,14 @@ class CloudAuthService { ): Promise<{ success: boolean; error?: string }> { try { const clientId = await this.getClientId(); + const device_name = await getBsplusDeviceName(); const result = (await browser.runtime.sendMessage({ type: "cloudLogin", client_id: clientId, redirect_uri: REDIRECT_URI, login: login.trim(), password, + device_name, })) as { access_token?: string; refresh_token?: string; diff --git a/src/seqta/utils/bsplusDeviceName.ts b/src/seqta/utils/bsplusDeviceName.ts new file mode 100644 index 00000000..8edaf005 --- /dev/null +++ b/src/seqta/utils/bsplusDeviceName.ts @@ -0,0 +1,70 @@ +import browser from "webextension-polyfill"; + +function detectOsNameFromNavigator(): string { + const ua = navigator.userAgent; + const platform = navigator.platform ?? ""; + + const userAgentData = (navigator as Navigator & { userAgentData?: { platform?: string } }) + .userAgentData; + if (userAgentData?.platform) { + const mapped: Record = { + Windows: "Windows", + macOS: "macOS", + Linux: "Linux", + Android: "Android", + iOS: "iOS", + "Chrome OS": "ChromeOS", + }; + return mapped[userAgentData.platform] ?? userAgentData.platform; + } + + if (/Win/i.test(platform) || ua.includes("Windows")) return "Windows"; + if (/Mac/i.test(platform) || ua.includes("Mac OS X") || ua.includes("Macintosh")) return "macOS"; + if (/Linux/i.test(platform) || ua.includes("Linux")) return "Linux"; + if (/Android/i.test(ua)) return "Android"; + if (/iPhone|iPad|iPod/i.test(ua)) return "iOS"; + if (/CrOS/i.test(ua)) return "ChromeOS"; + + return platform || "Unknown OS"; +} + +function detectBrowserNameFromUserAgent(ua: string): string { + if (ua.includes("Edg/")) return "Edge"; + if (ua.includes("OPR/") || ua.includes("Opera")) return "Opera"; + if (ua.includes("Firefox/")) return "Firefox"; + if (ua.includes("Chrome/") && !ua.includes("Edg/")) return "Chrome"; + if (ua.includes("Safari/") && !ua.includes("Chrome/")) return "Safari"; + return "Browser"; +} + +async function detectBrowserName(): Promise { + try { + const runtime = browser.runtime as typeof browser.runtime & { + getBrowserInfo?: () => Promise<{ name?: string }>; + }; + if (typeof runtime.getBrowserInfo === "function") { + const info = await runtime.getBrowserInfo(); + if (info.name === "Firefox") return "Firefox"; + if (info.name) return info.name; + } + } catch { + // Fall back to user-agent parsing below. + } + + if (typeof navigator !== "undefined") { + return detectBrowserNameFromUserAgent(navigator.userAgent); + } + + return "Browser"; +} + +/** + * Friendly device label for BetterSEQTA+ cloud login (`device_name` on POST /api/bsplus/login). + * Format: "Chrome on Windows", "Firefox on macOS", etc. + */ +export async function getBsplusDeviceName(): Promise { + const browserName = await detectBrowserName(); + const osName = + typeof navigator !== "undefined" ? detectOsNameFromNavigator() : "Unknown OS"; + return `${browserName} on ${osName}`; +} From c02388750ca1d1ed6b1442a61c3024d1697f9fa4 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Fri, 26 Jun 2026 16:18:20 +0930 Subject: [PATCH 20/36] fix(upcoming assessments): max assessments option --- src/interface/pages/settings/general.svelte | 63 +++++++++ src/seqta/utils/Loaders/LoadHomePage.ts | 132 +++++++++++++++--- src/seqta/utils/defaultSettings.ts | 3 + .../utils/ensureSyncableStorageDefaults.ts | 2 + src/seqta/utils/listeners/StorageChanges.ts | 2 + src/types/storage.ts | 6 + 6 files changed, 192 insertions(+), 16 deletions(-) diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index a25b46bf..4f6f3776 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -289,6 +289,69 @@ {@render Setting(option)} {/each} +
+
+
+
+

Home Page Assessments

+

Limit upcoming assessments shown on the home page by subject

+
+
+
+
+

Include Past Assessments

+

Show past-due assessments from the upcoming list, matching the Assessments page

+
+
+ (settingsState.homeUpcomingIncludePast = isOn)} + /> +
+
+
+
+

Maximum Subjects

+

Number of subjects to include, ordered by soonest due date

+
+
+ (settingsState.homeUpcomingAssessmentsPerSubjectMax = Number(value))} + options={[ + { value: "0", label: "All" }, + { value: "1", label: "1" }, + { value: "2", label: "2" }, + { value: "3", label: "3" }, + { value: "5", label: "5" }, + { value: "10", label: "10" }, + ]} + /> +
+
+
+
+
diff --git a/src/seqta/utils/Loaders/LoadHomePage.ts b/src/seqta/utils/Loaders/LoadHomePage.ts index 6e66b2ce..a28e9f89 100644 --- a/src/seqta/utils/Loaders/LoadHomePage.ts +++ b/src/seqta/utils/Loaders/LoadHomePage.ts @@ -154,6 +154,55 @@ export async function loadHomePage() { return cleanup; } +let upcomingRefreshTimeout: ReturnType | null = null; + +/** Re-render the home page upcoming assessments block when related settings change. */ +export function refreshHomeUpcomingAssessments() { + if (!document.getElementById("upcoming-items")) return; + + if (upcomingRefreshTimeout) clearTimeout(upcomingRefreshTimeout); + upcomingRefreshTimeout = setTimeout(() => { + upcomingRefreshTimeout = null; + void renderHomeUpcomingAssessments(); + }, 150); +} + +async function renderHomeUpcomingAssessments() { + const upcomingItems = document.getElementById("upcoming-items"); + if (!upcomingItems) return; + + upcomingItems.classList.add("loading"); + upcomingItems.innerHTML = ""; + const filterContainer = document.getElementById("upcoming-filters"); + if (filterContainer) filterContainer.innerHTML = ""; + + const [assessments, classes] = await Promise.all([ + GetUpcomingAssessments(), + GetActiveClasses(), + ]); + + const activeSubjects = activeSubjectsFromLearnPayload(classes); + const currentAssessments = filterAssessmentsForActiveSubjects( + assessments, + activeSubjects, + ).sort(comparedate); + + await CreateUpcomingSection(currentAssessments, activeSubjects); + upcomingItems.classList.remove("loading"); +} + +export function registerHomeUpcomingSettingsListeners() { + const keys = [ + "homeUpcomingSubjectsMax", + "homeUpcomingAssessmentsPerSubjectMax", + "homeUpcomingIncludePast", + ] as const; + + for (const key of keys) { + settingsState.register(key, () => refreshHomeUpcomingAssessments()); + } +} + async function GetUpcomingAssessments() { try { return fetch(`${location.origin}/seqta/student/assessment/list/upcoming?`, { @@ -285,7 +334,13 @@ function debounce any>( } function comparedate(obj1: any, obj2: any) { - return obj1.date < obj2.date ? -1 : obj1.date > obj2.date ? 1 : 0; + const d1 = new Date(obj1.due || obj1.date || 0).getTime(); + const d2 = new Date(obj2.due || obj2.date || 0).getTime(); + return d1 - d2; +} + +function startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } function processNotices(response: any, labelArray: string[]) { const NoticeContainer = document.getElementById("notice-container"); @@ -894,21 +949,74 @@ function CheckUnmarkedAttendance(lessonattendance: any) { return lessonattendance ? lessonattendance.label : " "; } +function applyHomeUpcomingLimits(assessments: any[], activeSubjects: any[]) { + const maxSubjects = settingsState.homeUpcomingSubjectsMax ?? 5; + const maxPerSubject = settingsState.homeUpcomingAssessmentsPerSubjectMax ?? 0; + + for (let i = 0; i < assessments.length; i++) { + const subject = activeSubjectForAssessment(assessments[i], activeSubjects); + assessments[i].filterCode = subject?.code ?? assessments[i].code; + } + + const bySubject = new Map(); + const subjectOrder: string[] = []; + + for (const assessment of assessments) { + const code = assessment.filterCode; + if (!bySubject.has(code)) { + bySubject.set(code, []); + subjectOrder.push(code); + } + bySubject.get(code)!.push(assessment); + } + + for (const items of bySubject.values()) { + items.sort( + (a, b) => new Date(a.due).getTime() - new Date(b.due).getTime(), + ); + } + + subjectOrder.sort( + (a, b) => + new Date(bySubject.get(a)![0].due).getTime() - + new Date(bySubject.get(b)![0].due).getTime(), + ); + + const subjectLimit = + maxSubjects > 0 ? maxSubjects : subjectOrder.length; + const allowedSubjects = subjectOrder.slice(0, subjectLimit); + + const limited: any[] = []; + for (const code of allowedSubjects) { + const items = bySubject.get(code)!; + const perSubjectLimit = maxPerSubject > 0 ? maxPerSubject : items.length; + limited.push(...items.slice(0, perSubjectLimit)); + } + + limited.sort( + (a, b) => new Date(a.due).getTime() - new Date(b.due).getTime(), + ); + + assessments.splice(0, assessments.length, ...limited); +} + async function CreateUpcomingSection(assessments: any, activeSubjects: any) { const upcomingitemcontainer = document.querySelector("#upcoming-items"); - const overdueDates = []; const upcomingDates = {}; const Today = new Date(); - for (let i = 0; i < assessments.length; i++) { - const assessmentdue = new Date(assessments[i].due); - if (assessmentdue < Today && !CheckSpecialDay(Today, assessmentdue)) { - overdueDates.push(assessments[i]); - assessments.splice(i, 1); - i--; + if (!(settingsState.homeUpcomingIncludePast ?? true)) { + const todayStart = startOfDay(Today); + for (let i = assessments.length - 1; i >= 0; i--) { + const assessmentdue = new Date(assessments[i].due); + if (assessmentdue < todayStart) { + assessments.splice(i, 1); + } } } + applyHomeUpcomingLimits(assessments, activeSubjects); + const colours = await GetLessonColours(); for (let i = 0; i < assessments.length; i++) { @@ -945,14 +1053,6 @@ async function CreateUpcomingSection(assessments: any, activeSubjects: any) { CreateFilters(filterSubjects); - for (let i = 0; i < assessments.length; i++) { - const subject = activeSubjectForAssessment( - assessments[i], - activeSubjects, - ); - assessments[i].filterCode = subject?.code ?? assessments[i].code; - } - for (let i = 0; i < assessments.length; i++) { const element: any = assessments[i]; if (!upcomingDates[element.due as keyof typeof upcomingDates]) { diff --git a/src/seqta/utils/defaultSettings.ts b/src/seqta/utils/defaultSettings.ts index 4d95d704..0285a696 100644 --- a/src/seqta/utils/defaultSettings.ts +++ b/src/seqta/utils/defaultSettings.ts @@ -50,6 +50,9 @@ export function getDefaultSettingsState(): SettingsState { animations: !isLowEndDevice, assessmentsAverage: false, defaultPage: "home", + homeUpcomingSubjectsMax: 5, + homeUpcomingAssessmentsPerSubjectMax: 0, + homeUpcomingIncludePast: true, shortcuts: [ { name: "Outlook", enabled: true }, { name: "Office", enabled: true }, diff --git a/src/seqta/utils/ensureSyncableStorageDefaults.ts b/src/seqta/utils/ensureSyncableStorageDefaults.ts index 1c200714..d350a08b 100644 --- a/src/seqta/utils/ensureSyncableStorageDefaults.ts +++ b/src/seqta/utils/ensureSyncableStorageDefaults.ts @@ -32,6 +32,8 @@ const OPTIONAL_UNSET_MEANS_DEFAULT_KEYS = [ "verboseLogging", "hideSensitiveContent", "mockNotices", + "homeUpcomingAssessmentsPerSubjectMax", + "homeUpcomingIncludePast", "devGhReleaseVersionOverride", "lastSeenNightlyPublishedAt", "originalDarkMode", diff --git a/src/seqta/utils/listeners/StorageChanges.ts b/src/seqta/utils/listeners/StorageChanges.ts index 7d7693ab..74247b0c 100644 --- a/src/seqta/utils/listeners/StorageChanges.ts +++ b/src/seqta/utils/listeners/StorageChanges.ts @@ -5,6 +5,7 @@ import { applySelectedFont } from "@/seqta/ui/fonts/Manager"; // Shortcuts rendering import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts"; import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments"; +import { registerHomeUpcomingSettingsListeners } from "@/seqta/utils/Loaders/LoadHomePage"; import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility"; import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/OpenMenuOptions"; @@ -39,6 +40,7 @@ export class StorageChangeHandler { "subjectfilters", FilterUpcomingAssessments.bind(this), ); + registerHomeUpcomingSettingsListeners(); settingsState.register( "iconOnlySidebar", this.handleIconOnlySidebarChange.bind(this), diff --git a/src/types/storage.ts b/src/types/storage.ts index 8a7d7cb7..51889c53 100644 --- a/src/types/storage.ts +++ b/src/types/storage.ts @@ -48,6 +48,12 @@ export interface SettingsState { timeFormat?: string; animations: boolean; defaultPage: string; + /** Max subjects with upcoming assessments on the home page; 0 = no limit. */ + homeUpcomingSubjectsMax?: number; + /** Max assessments shown per subject on the home page; 0 = no limit. */ + homeUpcomingAssessmentsPerSubjectMax?: number; + /** When true, show past-due assessments from the upcoming list on the home page. */ + homeUpcomingIncludePast?: boolean; devMode?: boolean; /** Dev-only: emit verboseDebug / verboseInfo / verboseLog output. */ verboseLogging?: boolean; From 75c65b1d5ec1f03edca52f84fca68a8fe221c303 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Fri, 26 Jun 2026 16:31:19 +0930 Subject: [PATCH 21/36] fix(theme):sun/moon icons --- src/interface/components/icons/LucideMoon.svelte | 8 ++------ src/interface/components/icons/LucideSun.svelte | 16 ++-------------- src/lib/icons/lucideMoon.ts | 4 ++-- src/lib/icons/lucideSun.ts | 13 ++----------- 4 files changed, 8 insertions(+), 33 deletions(-) diff --git a/src/interface/components/icons/LucideMoon.svelte b/src/interface/components/icons/LucideMoon.svelte index 7b679166..d4101ce5 100644 --- a/src/interface/components/icons/LucideMoon.svelte +++ b/src/interface/components/icons/LucideMoon.svelte @@ -5,13 +5,9 @@ diff --git a/src/interface/components/icons/LucideSun.svelte b/src/interface/components/icons/LucideSun.svelte index 1914ef63..84d30287 100644 --- a/src/interface/components/icons/LucideSun.svelte +++ b/src/interface/components/icons/LucideSun.svelte @@ -5,21 +5,9 @@ diff --git a/src/lib/icons/lucideMoon.ts b/src/lib/icons/lucideMoon.ts index fd880246..b07ff999 100644 --- a/src/lib/icons/lucideMoon.ts +++ b/src/lib/icons/lucideMoon.ts @@ -1,6 +1,6 @@ /** - * Lucide "moon" icon (https://lucide.dev/icons/moon, ISC License). + * Material "dark_mode" moon icon — filled style to match SEQTA menu bar icons. */ export const LUCIDE_MOON_ICON_SVG = ` - + `.trim(); diff --git a/src/lib/icons/lucideSun.ts b/src/lib/icons/lucideSun.ts index be2f252d..826c2c7f 100644 --- a/src/lib/icons/lucideSun.ts +++ b/src/lib/icons/lucideSun.ts @@ -1,15 +1,6 @@ /** - * Lucide "sun" icon (https://lucide.dev/icons/sun, ISC License). - * Thin strokes — reads clearly as a sun, not a gear. + * Material "light_mode" sun icon — filled style to match SEQTA menu bar icons. */ export const LUCIDE_SUN_ICON_SVG = ` - - - - - - - - - + `.trim(); From abcbca37450618b4b82d64b796ecc128a8720b27 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 26 Jun 2026 20:02:29 +0930 Subject: [PATCH 22/36] fix: restore theme edit label and add Select keyboard a11y Restore the theme settings toggle copy to Edit/Done and add listbox keyboard navigation with aria-activedescendant for the custom Select. Co-authored-by: Cursor --- src/interface/components/Select.svelte | 161 +++++++++++++++++++++- src/interface/pages/settings/theme.svelte | 2 +- 2 files changed, 155 insertions(+), 8 deletions(-) diff --git a/src/interface/components/Select.svelte b/src/interface/components/Select.svelte index 007e16c3..c0d6d3f8 100644 --- a/src/interface/components/Select.svelte +++ b/src/interface/components/Select.svelte @@ -5,29 +5,149 @@ options: Array<{ value: string, label: string }> }>(); + const listboxId = `select-listbox-${Math.random().toString(36).slice(2, 9)}`; + let isOpen = $state(false); + let activeIndex = $state(0); let root: HTMLDivElement | undefined = $state(); + let trigger: HTMLButtonElement | undefined = $state(); + let listbox: HTMLUListElement | undefined = $state(); const selectedLabel = $derived( options.find((option) => option.value === value)?.label ?? value, ); + const selectedIndex = $derived( + options.findIndex((option) => option.value === value), + ); + + const activeDescendantId = $derived( + isOpen && options[activeIndex] + ? optionId(options[activeIndex].value) + : undefined, + ); + + function optionId(optionValue: string): string { + return `${listboxId}-option-${optionValue}`; + } + + function openMenu(preferredIndex?: number) { + isOpen = true; + activeIndex = + preferredIndex ?? + (selectedIndex >= 0 ? selectedIndex : 0); + } + + function closeMenu(returnFocus = true) { + isOpen = false; + if (returnFocus) { + trigger?.focus(); + } + } + function toggleOpen() { - isOpen = !isOpen; + if (isOpen) { + closeMenu(); + } else { + openMenu(); + } } function selectValue(nextValue: string) { onChange(nextValue); - isOpen = false; + closeMenu(); + } + + function selectActive() { + const option = options[activeIndex]; + if (option) { + selectValue(option.value); + } + } + + function moveActive(delta: number) { + if (!options.length) return; + activeIndex = (activeIndex + delta + options.length) % options.length; + } + + function onTriggerKeydown(event: KeyboardEvent) { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + if (isOpen) { + moveActive(1); + } else { + openMenu(); + } + break; + case "ArrowUp": + event.preventDefault(); + if (isOpen) { + moveActive(-1); + } else { + openMenu(); + } + break; + case "Enter": + case " ": + event.preventDefault(); + if (isOpen) { + selectActive(); + } else { + openMenu(); + } + break; + case "Escape": + if (isOpen) { + event.preventDefault(); + closeMenu(); + } + break; + } + } + + function onListboxKeydown(event: KeyboardEvent) { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + moveActive(1); + break; + case "ArrowUp": + event.preventDefault(); + moveActive(-1); + break; + case "Home": + event.preventDefault(); + activeIndex = 0; + break; + case "End": + event.preventDefault(); + activeIndex = Math.max(0, options.length - 1); + break; + case "Enter": + case " ": + event.preventDefault(); + selectActive(); + break; + case "Escape": + event.preventDefault(); + closeMenu(); + break; + case "Tab": + closeMenu(false); + break; + } } $effect(() => { if (!isOpen) return; + queueMicrotask(() => listbox?.focus()); + const onPointerDown = (event: PointerEvent) => { const path = event.composedPath(); if (root && path.includes(root)) return; - isOpen = false; + closeMenu(false); }; document.addEventListener("pointerdown", onPointerDown, true); @@ -37,11 +157,14 @@
{#if isOpen} -
    - {#each options as option (option.value)} -
  • +
      + {#each options as option, index (option.value)} +
    • @@ -146,6 +284,14 @@ gap: 0.125rem; } + .select-menu:focus-visible { + outline: none; + box-shadow: + 0 10px 25px -5px rgb(0 0 0 / 0.25), + 0 8px 10px -6px rgb(0 0 0 / 0.2), + 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent); + } + .select-option { display: block; width: 100%; @@ -163,7 +309,8 @@ } .select-option:hover, - .select-option:focus-visible { + .select-option:focus-visible, + .select-option.is-active { outline: none; background: var(--theme-secondary, #e5e7eb); } diff --git a/src/interface/pages/settings/theme.svelte b/src/interface/pages/settings/theme.svelte index 422afe69..fece578a 100644 --- a/src/interface/pages/settings/theme.svelte +++ b/src/interface/pages/settings/theme.svelte @@ -22,7 +22,7 @@ From e10c5fe2c4d302799a5e57b0d677927bf5671ae0 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Fri, 26 Jun 2026 21:24:09 +0930 Subject: [PATCH 23/36] fix(actions): workflow files get passed correct GH token --- .github/workflows/nightly.yml | 1 + .github/workflows/release.yml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 31e7769c..cc7dcb78 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -20,6 +20,7 @@ permissions: env: NIGHTLY_TAG: nightly + GH_TOKEN: ${{ github.token }} jobs: nightly: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10596a81..bbf2646f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,9 @@ on: permissions: contents: write +env: + GH_TOKEN: ${{ github.token }} + jobs: release: runs-on: ubuntu-latest From e0ee41c270ee9a50f8196b5fb2c06540c8e47ba3 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 26 Jun 2026 21:33:10 +0930 Subject: [PATCH 24/36] fix: fix igdb issues at times --- .../built-in/globalSearch/src/core/index.ts | 11 +- .../built-in/globalSearch/src/indexing/db.ts | 329 +++++++++++------- .../globalSearch/src/indexing/indexer.ts | 20 +- .../globalSearch/src/indexing/resetIndexes.ts | 64 ++-- .../indexing/worker/vectorWorkerManager.ts | 12 + 5 files changed, 255 insertions(+), 181 deletions(-) diff --git a/src/plugins/built-in/globalSearch/src/core/index.ts b/src/plugins/built-in/globalSearch/src/core/index.ts index 45dcb524..d8b14fff 100644 --- a/src/plugins/built-in/globalSearch/src/core/index.ts +++ b/src/plugins/built-in/globalSearch/src/core/index.ts @@ -10,7 +10,7 @@ import { import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog"; import styles from "./styles.css?inline"; import { waitForElm } from "@/seqta/utils/waitForElm"; -import { runIndexing } from "../indexing/indexer"; +import { runIndexing, ensureSchemaCurrent } from "../indexing/indexer"; import { initVectorSearch } from "../search/vector/vectorSearch"; import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar"; import { IndexedDbManager } from "embeddia"; @@ -198,6 +198,15 @@ const globalSearchPlugin: Plugin = { } } + // Run schema migration before any IndexedDB connections are opened. + // If this runs later (during indexing), embeddiaDB and betterseqta-index + // may already be open and delete requests come back blocked. + try { + await ensureSchemaCurrent(); + } catch (error) { + console.warn("[Global Search] Schema check failed:", error); + } + try { await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", { primaryKey: "id", diff --git a/src/plugins/built-in/globalSearch/src/indexing/db.ts b/src/plugins/built-in/globalSearch/src/indexing/db.ts index 6ca106ac..6f622d84 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/db.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/db.ts @@ -14,71 +14,177 @@ function updateVersion(version: number) { localStorage.setItem(VERSION_KEY, version.toString()); } +function invalidateConnection(): void { + if (cachedDb) { + cachedDb.close(); + cachedDb = null; + } + dbPromise = null; +} + +function attachConnection(db: IDBDatabase): void { + if (cachedDb && cachedDb !== db) { + cachedDb.close(); + } + cachedDb = db; + cachedDb.onclose = () => { + cachedDb = null; + dbPromise = null; + }; + updateVersion(db.version); +} + +function setupUpgradeHandler( + request: IDBOpenDBRequest, + extraStore?: string, +): void { + request.onupgradeneeded = (event) => { + const db = request.result; + + if (!Array.from(db.objectStoreNames).includes(META_STORE)) { + db.createObjectStore(META_STORE); + } + + if (extraStore && !db.objectStoreNames.contains(extraStore)) { + db.createObjectStore(extraStore); + } + + if (event.newVersion != null) { + updateVersion(event.newVersion); + } + }; +} + +function openAtVersion(version: number, extraStore?: string): Promise { + return new Promise((resolve, reject) => { + let request: IDBOpenDBRequest; + + try { + request = indexedDB.open(DB_NAME, version); + } catch (error) { + reject(error); + return; + } + + setupUpgradeHandler(request, extraStore); + + request.onsuccess = () => { + attachConnection(request.result); + resolve(request.result); + }; + + request.onerror = () => reject(request.error); + }); +} + +function openAtCurrentVersion(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME); + + setupUpgradeHandler(request); + + request.onsuccess = () => { + attachConnection(request.result); + resolve(request.result); + }; + + request.onerror = () => reject(request.error); + }); +} + +function wipeDatabase(): Promise { + invalidateConnection(); + localStorage.removeItem(VERSION_KEY); + return deleteDatabaseWithRetries(DB_NAME); +} + +function deleteDatabaseWithRetries( + name: string, + maxAttempts = 6, +): Promise { + return new Promise((resolve) => { + const attemptDelete = (attempt: number) => { + let req: IDBOpenDBRequest; + try { + req = indexedDB.deleteDatabase(name); + } catch (error) { + console.warn(`[DB] Could not start delete of ${name}:`, error); + resolve(); + return; + } + + req.onsuccess = () => resolve(); + + req.onerror = () => { + console.warn(`[DB] Error deleting ${name}:`, req.error); + if (attempt + 1 < maxAttempts) { + setTimeout(() => attemptDelete(attempt + 1), 150 * (attempt + 1)); + return; + } + resolve(); + }; + + req.onblocked = () => { + console.warn( + `[DB] Delete of ${name} blocked (attempt ${attempt + 1}/${maxAttempts}); waiting for connections to close`, + ); + if (attempt + 1 < maxAttempts) { + setTimeout(() => attemptDelete(attempt + 1), 200 * (attempt + 1)); + return; + } + resolve(); + }; + }; + + attemptDelete(0); + }); +} + +export function closeSearchDatabase(): void { + invalidateConnection(); +} + +if (typeof window !== "undefined") { + window.addEventListener("betterseqta-reset-search-index", () => { + closeSearchDatabase(); + }); +} + +async function openDBInternal(): Promise { + const storedVersion = getCurrentVersion(); + + try { + return await openAtVersion(storedVersion); + } catch (error) { + const domError = error as DOMException | undefined; + + if (domError?.name === "VersionError") { + console.warn( + "[DB] localStorage version out of sync with IndexedDB; opening current version", + ); + invalidateConnection(); + try { + return await openAtCurrentVersion(); + } catch (fallbackError) { + console.warn("[DB] Fallback open failed, recreating database:", fallbackError); + } + } else { + console.error("Error opening database:", error); + } + + await wipeDatabase(); + return openAtVersion(1); + } +} + function openDB(): Promise { - if (cachedDb && cachedDb.version >= getCurrentVersion()) { + if (cachedDb) { return Promise.resolve(cachedDb); } if (dbPromise) return dbPromise; - const currentVersion = getCurrentVersion(); - - dbPromise = new Promise((resolve, reject) => { - let request: IDBOpenDBRequest; - - try { - request = indexedDB.open(DB_NAME, currentVersion); - } catch (e) { - console.warn("Database version conflict, recreating database..."); - if (cachedDb) { - cachedDb.close(); - cachedDb = null; - } - indexedDB.deleteDatabase(DB_NAME); - localStorage.removeItem(VERSION_KEY); - request = indexedDB.open(DB_NAME, 1); - updateVersion(1); - } - - request.onupgradeneeded = (event) => { - const db = request.result; - const existingStores = Array.from(db.objectStoreNames); - - if (!existingStores.includes(META_STORE)) { - db.createObjectStore(META_STORE); - } - - updateVersion(event.newVersion || 1); - }; - - request.onsuccess = () => { - if (cachedDb && cachedDb !== request.result) { - cachedDb.close(); - } - cachedDb = request.result; - - cachedDb.onclose = () => { - cachedDb = null; - dbPromise = null; - }; - - resolve(request.result); - }; - - request.onerror = () => { - console.error("Error opening database:", request.error); - - if (cachedDb) { - cachedDb.close(); - cachedDb = null; - } - indexedDB.deleteDatabase(DB_NAME); - localStorage.removeItem(VERSION_KEY); - dbPromise = null; - reject(request.error); - }; - }); - + dbPromise = openDBInternal(); return dbPromise; } @@ -97,45 +203,29 @@ async function getStore(store: string, mode: IDBTransactionMode = "readonly") { return tx.objectStore(store); } -function upgradeDB(newStore: string): Promise { - return new Promise((resolve, reject) => { - const currentVersion = getCurrentVersion(); - const newVersion = currentVersion + 1; +async function upgradeDB(newStore: string): Promise { + invalidateConnection(); - if (cachedDb) { - cachedDb.close(); - cachedDb = null; - } + let baseVersion = 0; + + try { + const db = await openAtCurrentVersion(); + baseVersion = db.version; + db.close(); + cachedDb = null; dbPromise = null; + } catch (error) { + console.warn("[DB] Could not probe database version before upgrade:", error); + } - const request = indexedDB.open(DB_NAME, newVersion); + const newVersion = baseVersion + 1; - request.onupgradeneeded = (event) => { - const db = request.result; - if (!db.objectStoreNames.contains(newStore)) { - db.createObjectStore(newStore); - } - - updateVersion(event.newVersion || newVersion); - }; - - request.onsuccess = () => { - cachedDb = request.result; - - cachedDb.onclose = () => { - cachedDb = null; - dbPromise = null; - }; - - dbPromise = Promise.resolve(request.result); - resolve(); - }; - - request.onerror = () => { - console.error("Error upgrading database:", request.error); - reject(request.error); - }; - }); + try { + await openAtVersion(newVersion, newStore); + } catch (error) { + console.error("Error upgrading database:", error); + throw error; + } } export async function getAll(store: string): Promise { @@ -263,54 +353,23 @@ export async function clear(store: string): Promise { } export async function resetDatabase(): Promise { - // Close cached database connection - if (cachedDb) { - try { - cachedDb.close(); - } catch (e) { - console.warn("[DB] Error closing cached database:", e); - } - cachedDb = null; - } - - // Close pending database promise if (dbPromise) { try { const db = await dbPromise; db.close(); - } catch (e) { + } catch { // Database might not be open yet, that's okay } - dbPromise = null; } - // Wait a bit for connections to fully close - await new Promise(resolve => setTimeout(resolve, 100)); + invalidateConnection(); - return new Promise((resolve, reject) => { - const req = indexedDB.deleteDatabase(DB_NAME); - req.onsuccess = () => { - localStorage.removeItem(VERSION_KEY); - resolve(); - }; - req.onerror = () => { - console.error("[DB] Error deleting database:", req.error); - reject(req.error); - }; - req.onblocked = () => { - console.warn("[DB] Database deletion blocked - waiting for connections to close"); - // Wait a bit longer and try again - setTimeout(() => { - const retryReq = indexedDB.deleteDatabase(DB_NAME); - retryReq.onsuccess = () => { - localStorage.removeItem(VERSION_KEY); - resolve(); - }; - retryReq.onerror = () => reject(retryReq.error); - retryReq.onblocked = () => { - reject(new Error(`Database is still open. Please close other tabs/windows and try again.`)); - }; - }, 500); - }; - }); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("betterseqta-reset-search-index")); + } + + await new Promise((resolve) => setTimeout(resolve, 200)); + + localStorage.removeItem(VERSION_KEY); + await deleteDatabaseWithRetries(DB_NAME); } diff --git a/src/plugins/built-in/globalSearch/src/indexing/indexer.ts b/src/plugins/built-in/globalSearch/src/indexing/indexer.ts index 8a489b5b..5a80310f 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/indexer.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/indexer.ts @@ -1,4 +1,4 @@ -import { applyStoreDiff, get, getAll, put, remove, resetDatabase } from "./db"; +import { applyStoreDiff, get, getAll, put, remove } from "./db"; import { jobs } from "./jobs"; import { decorateIndexItems } from "./renderComponents"; import type { IndexItem, Job, JobContext } from "./types"; @@ -6,6 +6,7 @@ import { VectorWorkerManager } from "./worker/vectorWorkerManager"; import { loadDynamicItems } from "../utils/dynamicItems"; import { getVectorizedItemIds, pruneOrphanVectorEmbeddings } from "./utils"; import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion"; +import { resetSearchIndexes } from "./resetIndexes"; import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; const META_STORE = "meta"; @@ -33,20 +34,9 @@ async function ensureSchemaCurrent(): Promise { ); try { - await resetDatabase(); + await resetSearchIndexes(); } catch (e) { - console.warn("[Indexer] Failed to reset structured database:", e); - } - - try { - await new Promise((resolve) => { - const req = indexedDB.deleteDatabase("embeddiaDB"); - req.onsuccess = () => resolve(); - req.onerror = () => resolve(); - req.onblocked = () => resolve(); - }); - } catch (e) { - console.warn("[Indexer] Failed to reset embeddiaDB:", e); + console.warn("[Indexer] Failed to reset search indexes:", e); } try { @@ -58,6 +48,8 @@ async function ensureSchemaCurrent(): Promise { return schemaCheckPromise; } +export { ensureSchemaCurrent }; + /* ─────────── Progress‑meta helpers ─────────── */ async function loadProgress(jobId: string): Promise { const rec = await get(META_STORE, `progress:${jobId}`); diff --git a/src/plugins/built-in/globalSearch/src/indexing/resetIndexes.ts b/src/plugins/built-in/globalSearch/src/indexing/resetIndexes.ts index c4d30b75..80fe39f1 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/resetIndexes.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/resetIndexes.ts @@ -34,49 +34,51 @@ const STRUCTURED_DB = "betterseqta-index"; const VECTOR_DB = "embeddiaDB"; const STRUCTURED_VERSION_KEY = "betterseqta-index-version"; -function deleteIndexedDb(name: string): Promise { - return new Promise((resolve) => { - let resolved = false; - const finish = () => { - if (resolved) return; - resolved = true; - resolve(); - }; +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +function tryDeleteDatabase( + name: string, +): Promise<"success" | "blocked" | "error"> { + return new Promise((resolve) => { let req: IDBOpenDBRequest; try { req = indexedDB.deleteDatabase(name); - } catch (e) { - console.warn(`[Reset] Could not start delete of ${name}:`, e); - finish(); + } catch (error) { + console.warn(`[Reset] Could not start delete of ${name}:`, error); + resolve("error"); return; } - req.onsuccess = () => finish(); + req.onsuccess = () => resolve("success"); req.onerror = () => { console.warn(`[Reset] Error deleting ${name}:`, req.error); - finish(); - }; - req.onblocked = () => { - // Connections are still open in another tab. Wait briefly, retry, - // then resolve regardless so we never hang the caller forever. - console.warn( - `[Reset] Delete of ${name} blocked; will retry then resolve.`, - ); - setTimeout(() => { - try { - const retry = indexedDB.deleteDatabase(name); - retry.onsuccess = () => finish(); - retry.onerror = () => finish(); - retry.onblocked = () => finish(); - } catch { - finish(); - } - }, 600); + resolve("error"); }; + req.onblocked = () => resolve("blocked"); }); } +async function deleteIndexedDb(name: string): Promise { + const maxAttempts = 6; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const result = await tryDeleteDatabase(name); + if (result === "success") return; + + if (result === "blocked") { + console.warn( + `[Reset] Delete of ${name} blocked (attempt ${attempt + 1}/${maxAttempts}); waiting for connections to close`, + ); + } + + await delay(200 * (attempt + 1)); + } + + console.warn(`[Reset] Gave up deleting ${name} after ${maxAttempts} attempts`); +} + export async function resetSearchIndexes(): Promise { try { if (typeof window !== "undefined") { @@ -96,7 +98,7 @@ export async function resetSearchIndexes(): Promise { // Give listeners a tick to close any open IDB connections; otherwise // the delete request below comes back with `onblocked`. - await new Promise((resolve) => setTimeout(resolve, 150)); + await delay(300); await Promise.allSettled([ deleteIndexedDb(STRUCTURED_DB), diff --git a/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts b/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts index c1de4196..01bff330 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts @@ -13,6 +13,7 @@ export type ProgressCallback = (data: { export class VectorWorkerManager { private static instance: VectorWorkerManager; + private static resetListenerInstalled = false; private worker: Worker | null = null; private isInitialized = false; private readyPromise: Promise | null = null; @@ -42,6 +43,17 @@ export class VectorWorkerManager { verboseDebug("Creating new VectorWorkerManager instance"); VectorWorkerManager.instance = new VectorWorkerManager(); } + + if ( + !VectorWorkerManager.resetListenerInstalled && + typeof window !== "undefined" + ) { + VectorWorkerManager.resetListenerInstalled = true; + window.addEventListener("betterseqta-reset-search-index", () => { + VectorWorkerManager.getInstance().terminate(); + }); + } + return VectorWorkerManager.instance; } From b4aca7277bf8099c3953dca0f4f8ee8e36391810 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 26 Jun 2026 21:38:04 +0930 Subject: [PATCH 25/36] fix: fix RGBA issue --- src/plugins/built-in/gradeAnalytics/ui.ts | 21 +----- .../gradeAnalytics/utils/accentColor.ts | 5 +- src/seqta/ui/colors/parseCssColor.test.ts | 47 ++++++++++++++ src/seqta/ui/colors/parseCssColor.ts | 65 +++++++++++++++++++ 4 files changed, 116 insertions(+), 22 deletions(-) create mode 100644 src/seqta/ui/colors/parseCssColor.test.ts create mode 100644 src/seqta/ui/colors/parseCssColor.ts diff --git a/src/plugins/built-in/gradeAnalytics/ui.ts b/src/plugins/built-in/gradeAnalytics/ui.ts index 5beecc69..cf320d70 100644 --- a/src/plugins/built-in/gradeAnalytics/ui.ts +++ b/src/plugins/built-in/gradeAnalytics/ui.ts @@ -4,6 +4,7 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { mount, unmount } from "svelte"; import GradeAnalyticsPage from "./GradeAnalyticsPage.svelte"; import { buildContrastAccentPalette } from "./utils/accentColor"; +import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor"; type ThemeSettingKey = | "selectedColor" @@ -62,26 +63,6 @@ const ACCENT_CSS_VARS = [ "--colour-betterseqta-blue", ] as const; -/** Resolve a solid colour for charts (gradients → first stop). */ -function extractSolidColor(value: string): string | null { - const trimmed = value.trim(); - if (!trimmed || trimmed === "initial") return null; - if ( - trimmed.startsWith("#") || - trimmed.startsWith("rgb") || - trimmed.startsWith("hsl") - ) { - return trimmed; - } - if (trimmed.includes("gradient")) { - const match = trimmed.match( - /#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/i, - ); - return match?.[0] ?? null; - } - return null; -} - const THEME_ACCENT_OVERRIDES: Record = { "bb0aaf40-55ef-40f7-bc64-93b67ef96c01": "#4ade80", }; diff --git a/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts b/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts index 606b2042..2858771a 100644 --- a/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts +++ b/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts @@ -1,4 +1,5 @@ import Color from "color"; +import { parseCssColor } from "@/seqta/ui/colors/parseCssColor"; export type ContrastAccentPalette = { accent: string; @@ -52,8 +53,8 @@ export function buildContrastAccentPalette( accentRaw: string, backgroundRaw: string, ): ContrastAccentPalette { - const accent = Color(accentRaw); - const background = Color(backgroundRaw); + const accent = parseCssColor(accentRaw); + const background = parseCssColor(backgroundRaw, "#ffffff"); const isDark = background.isDark(); const { h, s } = accent.hsl().object(); diff --git a/src/seqta/ui/colors/parseCssColor.test.ts b/src/seqta/ui/colors/parseCssColor.test.ts new file mode 100644 index 00000000..b7a71181 --- /dev/null +++ b/src/seqta/ui/colors/parseCssColor.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + extractSolidColor, + normalizeCssColorString, + parseCssColor, +} from "./parseCssColor"; + +describe("normalizeCssColorString", () => { + it("lowercases uppercase RGBA/RGB function names", () => { + expect(normalizeCssColorString("RGBA(3, 29, 11, 0.58)")).toBe( + "rgba(3, 29, 11, 0.58)", + ); + expect(normalizeCssColorString("RGB(10, 20, 30)")).toBe("rgb(10, 20, 30)"); + }); +}); + +describe("extractSolidColor", () => { + it("extracts solid uppercase RGBA values", () => { + expect(extractSolidColor("RGBA(3, 29, 11, 0.58)")).toBe( + "rgba(3, 29, 11, 0.58)", + ); + }); + + it("extracts the first rgba stop from gradients with mixed casing", () => { + expect( + extractSolidColor( + "linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)", + ), + ).toBe("rgba(201,61,0,1)"); + }); +}); + +describe("parseCssColor", () => { + it("parses uppercase RGBA without throwing", () => { + const parsed = parseCssColor("RGBA(3, 29, 11, 0.58)"); + expect(parsed.alpha()).toBeCloseTo(0.58, 2); + expect(parsed.red()).toBe(3); + expect(parsed.green()).toBe(29); + expect(parsed.blue()).toBe(11); + }); + + it("falls back when the value is not a colour", () => { + expect(parseCssColor("not-a-color", "#007bff").hex().toLowerCase()).toBe( + "#007bff", + ); + }); +}); diff --git a/src/seqta/ui/colors/parseCssColor.ts b/src/seqta/ui/colors/parseCssColor.ts new file mode 100644 index 00000000..d2835f94 --- /dev/null +++ b/src/seqta/ui/colors/parseCssColor.ts @@ -0,0 +1,65 @@ +import Color from "color"; + +type ColorInstance = ReturnType; + +/** + * SEQTA themes and user gradients often use uppercase `RGBA()` / `RGB()`. + * The `color` package only accepts lowercase function names. + */ +export function normalizeCssColorString(value: string): string { + return value + .trim() + .replace(/\bRGBA?\(/gi, (match) => match.toLowerCase()) + .replace(/\bHSLA?\(/gi, (match) => match.toLowerCase()); +} + +/** Pick a single solid colour from a CSS value (hex, rgb(a), hsl(a), or gradient). */ +export function extractSolidColor(value: string): string | null { + const trimmed = normalizeCssColorString(value); + if (!trimmed || trimmed === "initial") return null; + if ( + trimmed.startsWith("#") || + /^rgba?\(/i.test(trimmed) || + /^hsla?\(/i.test(trimmed) + ) { + return trimmed; + } + if (trimmed.includes("gradient")) { + const match = trimmed.match( + /#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/gi, + ); + return match?.[0] ? normalizeCssColorString(match[0]) : null; + } + return null; +} + +/** Parse a CSS colour for the `color` library; never throws. */ +export function parseCssColor(value: string, fallback = "#007bff"): ColorInstance { + const candidates = [ + extractSolidColor(value), + normalizeCssColorString(value), + ].filter((candidate): candidate is string => Boolean(candidate)); + + for (const candidate of candidates) { + try { + return Color(candidate); + } catch { + // try next strategy + } + + const rgbaMatch = candidate.match( + /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i, + ); + if (rgbaMatch) { + try { + const [, r, g, b, a] = rgbaMatch; + const rgb = Color.rgb(Number(r), Number(g), Number(b)); + return a !== undefined ? rgb.alpha(Number(a)) : rgb; + } catch { + // fall through + } + } + } + + return Color(fallback); +} From 9a9eb91a2d8b65ceacdef4525ef928f3b11865c6 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Fri, 26 Jun 2026 22:20:20 +0930 Subject: [PATCH 26/36] fix(BGImages): BG images wouldn't load on either browser --- .../themes/BackgroundSelector.svelte | 63 +++---------------- 1 file changed, 9 insertions(+), 54 deletions(-) diff --git a/src/interface/components/themes/BackgroundSelector.svelte b/src/interface/components/themes/BackgroundSelector.svelte index b6be121b..7c7e7ef7 100644 --- a/src/interface/components/themes/BackgroundSelector.svelte +++ b/src/interface/components/themes/BackgroundSelector.svelte @@ -1,10 +1,9 @@ -
      +
      {#if !(imageBackgrounds.length === 0 && isEditMode)}

      Background Images

      @@ -198,7 +153,7 @@ handleFileChange(e.detail)} /> {/if} {#each imageBackgrounds as bg (bg.id)} - {#if isVisible && bg.blob} + {#if bg.url} handleFileChange(e.detail)} /> {/if} {#each videoBackgrounds as bg (bg.id)} - {#if isVisible && bg.blob} + {#if bg.url} {/if} -
      \ No newline at end of file +
      From 1045d38f47da6f38dc114f9c6a25f3993ddc5e89 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Fri, 26 Jun 2026 22:27:46 +0930 Subject: [PATCH 27/36] fix(test): Wrong runner import --- jest.config.js | 4 ++++ src/seqta/ui/colors/parseCssColor.test.ts | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index 428e2515..3903c51f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,7 +8,11 @@ export default { ], transform: { '^.+\\.ts$': 'ts-jest', + '^.+\\.js$': ['ts-jest', { tsconfig: { allowJs: true } }], }, + transformIgnorePatterns: [ + '/node_modules/(?!(color|color-string|color-convert|color-name)/)', + ], moduleNameMapper: { '^@/(.*)$': '/src/$1', '^webextension-polyfill$': '/src/test/mocks/webextension-polyfill.ts', diff --git a/src/seqta/ui/colors/parseCssColor.test.ts b/src/seqta/ui/colors/parseCssColor.test.ts index b7a71181..ae0dbc0b 100644 --- a/src/seqta/ui/colors/parseCssColor.test.ts +++ b/src/seqta/ui/colors/parseCssColor.test.ts @@ -1,4 +1,3 @@ -import { describe, expect, it } from "vitest"; import { extractSolidColor, normalizeCssColorString, From 3213d0ca28fa05be87c1b05bbf90dd7341229c65 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Sat, 27 Jun 2026 15:10:24 +0930 Subject: [PATCH 28/36] fix(global search): CSS preload killed plugin load --- .gitignore | 3 ++ lib/extensionChunkUrls.ts | 9 +++- lib/inlineWorker.ts | 11 ++--- package.json | 2 +- scripts/copy-ort-wasm-assets.mjs | 24 ++++++++++ src/interface/contentShadow.css | 21 +++++++++ src/interface/index.ts | 3 +- src/interface/main.d.ts | 2 - src/interface/renderInShadow.ts | 26 +++++++++++ src/lib/transformersExtension.ts | 43 ++++++++++++++++++ src/manifests/manifest.json | 4 +- src/plugins/built-in/globalSearch/lazy.ts | 9 ++-- .../built-in/globalSearch/src/core/index.ts | 7 ++- .../globalSearch/src/core/mountSearchBar.ts | 2 +- .../globalSearch/src/indexing/indexer.ts | 25 +++++++++++ .../src/indexing/indexingPause.ts | 10 +++++ .../src/indexing/jobs/messages.ts | 11 ++--- .../src/indexing/jobs/notifications.ts | 11 ++--- .../src/indexing/passiveObserver.ts | 18 ++++++-- .../globalSearch/src/indexing/resetIndexes.ts | 44 +++++++++++++++++++ .../src/indexing/worker/vectorWorker.ts | 26 ++++++----- .../indexing/worker/vectorWorkerManager.ts | 27 ++++++++---- .../src/indexing/worker/workerVerboseLog.ts | 13 ++++++ .../src/search/vector/vectorSearch.ts | 2 + src/plugins/core/dynamicLoader.ts | 12 +++-- src/plugins/monofile.ts | 3 +- src/seqta/utils/Loaders/LoadEngageHomePage.ts | 9 ++-- src/seqta/utils/Loaders/LoadHomePage.ts | 7 +-- src/seqta/utils/SendNewsPage.ts | 5 ++- 29 files changed, 327 insertions(+), 62 deletions(-) create mode 100644 scripts/copy-ort-wasm-assets.mjs create mode 100644 src/interface/contentShadow.css create mode 100644 src/interface/renderInShadow.ts create mode 100644 src/lib/transformersExtension.ts create mode 100644 src/plugins/built-in/globalSearch/src/indexing/indexingPause.ts create mode 100644 src/plugins/built-in/globalSearch/src/indexing/worker/workerVerboseLog.ts diff --git a/.gitignore b/.gitignore index 302d14da..311312a3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ bun.lock # PDF.js extension assets (copied by postinstall from pdfjs-dist) src/public/resources/pdfjs/pdf.worker.min.mjs src/public/resources/pdfjs/pdf.legacy.min.mjs +# ONNX Runtime WASM assets (copied by postinstall from @huggingface/transformers) +src/public/resources/ort/ort-wasm-simd-threaded.jsep.mjs +src/public/resources/ort/ort-wasm-simd-threaded.jsep.wasm # Build extension.zip diff --git a/lib/extensionChunkUrls.ts b/lib/extensionChunkUrls.ts index 6dff51bb..1701505c 100644 --- a/lib/extensionChunkUrls.ts +++ b/lib/extensionChunkUrls.ts @@ -15,8 +15,15 @@ export function extensionChunkUrls(): Plugin { base: "./", experimental: { renderBuiltUrl(filename, { hostType, type }) { + const path = filename.replace(/^\//, ""); if (type === "chunk" && hostType === "js") { - const path = filename.replace(/^\//, ""); + return { + runtime: `chrome.runtime.getURL(${JSON.stringify(path)})`, + }; + } + // Rewrite CSS preloads from JS dynamic imports (content scripts). + // Do not rewrite hostType "css" — extension HTML pages need static hrefs. + if (type === "asset" && hostType === "js" && path.endsWith(".css")) { return { runtime: `chrome.runtime.getURL(${JSON.stringify(path)})`, }; diff --git a/lib/inlineWorker.ts b/lib/inlineWorker.ts index a877a05b..b351339a 100644 --- a/lib/inlineWorker.ts +++ b/lib/inlineWorker.ts @@ -43,12 +43,13 @@ export default function InlineWorkerDevPlugin(): Plugin { // Note: Original code had `await fs.readFile(cleanPath, "utf-8");` but `code` wasn't used. // `esbuild` directly takes `cleanPath` as an entry point. const result = await build({ - entryPoints: [cleanPath], // esbuild uses the file path directly + entryPoints: [cleanPath], bundle: true, - write: false, // We want the output in memory, not written to disk - platform: "browser", // Target environment for the worker code - format: "iife", // Immediately Invoked Function Expression, suitable for workers - target: "esnext", // Transpile to modern JavaScript + write: false, + platform: "browser", + format: "iife", + target: "esnext", + external: ["webextension-polyfill"], }); const workerCode = result.outputFiles[0].text; diff --git a/package.json b/package.json index 62156400..cddffb3b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "browserslist": "> 0.5%, last 2 versions, not dead", "scripts": { "compile:layerchart": "node scripts/compile-layerchart-vendor.mjs", - "postinstall": "node scripts/copy-pdfjs-assets.mjs && npm run compile:layerchart", + "postinstall": "node scripts/copy-pdfjs-assets.mjs && node scripts/copy-ort-wasm-assets.mjs && npm run compile:layerchart", "autoaudit": "npm audit && npm audit fix && npm run build", "dev": "cross-env MODE=chrome vite dev", "dev:firefox": "cross-env MODE=firefox vite build --watch", diff --git a/scripts/copy-ort-wasm-assets.mjs b/scripts/copy-ort-wasm-assets.mjs new file mode 100644 index 00000000..a5345bb7 --- /dev/null +++ b/scripts/copy-ort-wasm-assets.mjs @@ -0,0 +1,24 @@ +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const transformersDist = join( + root, + "node_modules", + "@huggingface", + "transformers", + "dist", +); +const outDir = join(root, "src", "public", "resources", "ort"); + +mkdirSync(outDir, { recursive: true }); + +const ortFiles = [ + "ort-wasm-simd-threaded.jsep.mjs", + "ort-wasm-simd-threaded.jsep.wasm", +]; + +for (const file of ortFiles) { + copyFileSync(join(transformersDist, file), join(outDir, file)); +} diff --git a/src/interface/contentShadow.css b/src/interface/contentShadow.css new file mode 100644 index 00000000..d12e9377 --- /dev/null +++ b/src/interface/contentShadow.css @@ -0,0 +1,21 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +button { + @apply cursor-pointer; +} + +::-webkit-scrollbar { + display: none; +} + +input { + &:focus { + box-shadow: unset !important; + } +} + +.no-scrollbar { + scrollbar-width: none !important; +} diff --git a/src/interface/index.ts b/src/interface/index.ts index a760b630..5003af7b 100644 --- a/src/interface/index.ts +++ b/src/interface/index.ts @@ -2,6 +2,7 @@ import "./index.css"; import Settings from "./pages/settings.svelte"; import IconFamily from "@/resources/fonts/IconFamily.woff"; import browser from "webextension-polyfill"; +import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import renderSvelte from "./main"; import { initializeSettingsState } from "@/seqta/utils/listeners/SettingsState"; import { initVerboseLogging, verboseInfo } from "@/utils/verboseLog"; @@ -14,7 +15,7 @@ function InjectCustomIcons() { style.innerHTML = ` @font-face { font-family: 'IconFamily'; - src: url('${browser.runtime.getURL(IconFamily)}') format('woff'); + src: url('${resolveExtensionAssetUrl(IconFamily)}') format('woff'); font-weight: normal; font-style: normal; }`; diff --git a/src/interface/main.d.ts b/src/interface/main.d.ts index 38431407..0357e4f1 100644 --- a/src/interface/main.d.ts +++ b/src/interface/main.d.ts @@ -1,5 +1,3 @@ -import "./index.css"; - declare module "*.png"; declare module "*.svg"; declare module "*.jpeg"; diff --git a/src/interface/renderInShadow.ts b/src/interface/renderInShadow.ts new file mode 100644 index 00000000..c54f1361 --- /dev/null +++ b/src/interface/renderInShadow.ts @@ -0,0 +1,26 @@ +import { mount } from "svelte"; +import type { SvelteComponent } from "svelte"; +import style from "./contentShadow.css?inline"; + +/** Mount Svelte UI inside a shadow root from content scripts (decoupled from settings popup CSS). */ +export default function renderInShadow( + Component: SvelteComponent | any, + mountPoint: ShadowRoot | HTMLElement, + props: Record = {}, +) { + const app = mount(Component, { + target: mountPoint, + props: { + standalone: false, + ...props, + }, + }); + + if (mountPoint instanceof ShadowRoot) { + const styleElement = document.createElement("style"); + styleElement.textContent = style; + mountPoint.appendChild(styleElement); + } + + return app; +} diff --git a/src/lib/transformersExtension.ts b/src/lib/transformersExtension.ts new file mode 100644 index 00000000..3e872478 --- /dev/null +++ b/src/lib/transformersExtension.ts @@ -0,0 +1,43 @@ +import browser from "webextension-polyfill"; + +const ORT_RESOURCE_DIR = "resources/ort/"; + +let configured = false; + +function extensionAssetUrl(relativePath: string): string { + return browser.runtime.getURL(relativePath.replace(/^\/+/, "")); +} + +/** + * Point HuggingFace transformers / onnxruntime at extension-local WASM files + * instead of CDN (required on SEQTA pages where page CSP blocks jsdelivr). + * Safe to call multiple times; must run before embeddia `initializeModel()`. + */ +export async function ensureTransformersEnv( + ortWasmBase?: string, +): Promise { + if (configured) return; + + const { env } = await import("@huggingface/transformers"); + const base = ortWasmBase ?? extensionAssetUrl(ORT_RESOURCE_DIR); + + env.backends.onnx.wasm = env.backends.onnx.wasm ?? {}; + env.backends.onnx.wasm.wasmPaths = base.endsWith("/") ? base : `${base}/`; + + configured = true; +} + +export function getOrtWasmBaseUrl(): string { + const base = extensionAssetUrl(ORT_RESOURCE_DIR); + return base.endsWith("/") ? base : `${base}/`; +} + +/** For page-origin blob workers that cannot call `browser.runtime.getURL`. */ +export async function configureTransformersEnvForBase( + ortWasmBase: string, +): Promise { + configured = false; + await ensureTransformersEnv(ortWasmBase); +} + +export { ORT_RESOURCE_DIR }; diff --git a/src/manifests/manifest.json b/src/manifests/manifest.json index b4bfe4e4..9ecc5e86 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -36,7 +36,9 @@ "resources/icons/*", "resources/update-image.webp", "resources/pdfjs/pdf.worker.min.mjs", - "resources/pdfjs/pdf.legacy.min.mjs" + "resources/pdfjs/pdf.legacy.min.mjs", + "resources/ort/*", + "assets/*.css" ], "matches": ["*://*/*"] } diff --git a/src/plugins/built-in/globalSearch/lazy.ts b/src/plugins/built-in/globalSearch/lazy.ts index 31a3e379..6ff82be2 100644 --- a/src/plugins/built-in/globalSearch/lazy.ts +++ b/src/plugins/built-in/globalSearch/lazy.ts @@ -7,7 +7,10 @@ import { } from "../../core/settingsHelpers"; import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; import styles from "./src/core/styles.css?inline"; -import { resetSearchIndexes } from "./src/indexing/resetIndexes"; +import { + resetSearchIndexes, + notifyOpenTabsResetSearchIndex, +} from "./src/indexing/resetIndexes"; // Platform-aware default hotkey const getDefaultHotkey = () => { @@ -52,9 +55,7 @@ const settings = defineSettings({ if (!confirmed) return; try { - // `resetSearchIndexes` is a tiny statically-imported helper: no - // dynamic chunks to chase, so the button keeps working even when - // the settings page has been open across an extension update. + await notifyOpenTabsResetSearchIndex(); await resetSearchIndexes(); alert( "Search index and storage were reset.\n\nReload this tab to regenerate the index.", diff --git a/src/plugins/built-in/globalSearch/src/core/index.ts b/src/plugins/built-in/globalSearch/src/core/index.ts index d8b14fff..a8d02f54 100644 --- a/src/plugins/built-in/globalSearch/src/core/index.ts +++ b/src/plugins/built-in/globalSearch/src/core/index.ts @@ -11,6 +11,8 @@ import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog"; import styles from "./styles.css?inline"; import { waitForElm } from "@/seqta/utils/waitForElm"; import { runIndexing, ensureSchemaCurrent } from "../indexing/indexer"; +import { installResetIndexMessageListener } from "../indexing/resetIndexes"; +import { isIndexingPaused } from "../indexing/indexingPause"; import { initVectorSearch } from "../search/vector/vectorSearch"; import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar"; import { IndexedDbManager } from "embeddia"; @@ -168,6 +170,8 @@ const globalSearchPlugin: Plugin = { run: async (api) => { const appRef = { current: null }; + installResetIndexMessageListener(); + // Run the version check BEFORE we open any IndexedDB connections. // On a normal load (no version change) this is just a string compare // and a manifest read, so the cost is negligible. On a real update, @@ -287,8 +291,9 @@ const globalSearchPlugin: Plugin = { } } - if (api.settings.runIndexingOnLoad) { + if (api.settings.runIndexingOnLoad && !isIndexingPaused()) { setTimeout(async () => { + if (isIndexingPaused()) return; await runIndexing(); }, 2000); } diff --git a/src/plugins/built-in/globalSearch/src/core/mountSearchBar.ts b/src/plugins/built-in/globalSearch/src/core/mountSearchBar.ts index 2549220d..aa270be0 100644 --- a/src/plugins/built-in/globalSearch/src/core/mountSearchBar.ts +++ b/src/plugins/built-in/globalSearch/src/core/mountSearchBar.ts @@ -280,7 +280,7 @@ export async function mountSearchBar( }); try { - const { default: renderSvelte } = await import("@/interface/main"); + const { default: renderSvelte } = await import("@/interface/renderInShadow"); appRef.current = renderSvelte(SearchBar, searchRootShadow, { transparencyEffects: api.settings.transparencyEffects ? true : false, showRecentFirst: api.settings.showRecentFirst, diff --git a/src/plugins/built-in/globalSearch/src/indexing/indexer.ts b/src/plugins/built-in/globalSearch/src/indexing/indexer.ts index 5a80310f..72604989 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/indexer.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/indexer.ts @@ -7,6 +7,7 @@ import { loadDynamicItems } from "../utils/dynamicItems"; import { getVectorizedItemIds, pruneOrphanVectorEmbeddings } from "./utils"; import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion"; import { resetSearchIndexes } from "./resetIndexes"; +import { isIndexingPaused } from "./indexingPause"; import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; const META_STORE = "meta"; @@ -252,8 +253,19 @@ export async function loadAllStoredItems(): Promise { } export async function runIndexing(): Promise { + if (isIndexingPaused()) { + verboseDebug( + "[Indexer] Skipping indexing — index was reset; reload the page to rebuild.", + ); + return; + } + await ensureSchemaCurrent(); + if (isIndexingPaused()) { + return; + } + if (!(await acquireLock())) { verboseDebug( "%c[Indexer] Could not acquire lock - another tab is indexing or this tab is already indexing", @@ -272,6 +284,19 @@ export async function runIndexing(): Promise { dispatchProgress(completedJobs, totalSteps, true, "Starting jobs"); for (const jobId of jobIds) { + if (isIndexingPaused()) { + verboseDebug( + "[Indexer] Indexing stopped — index was reset; reload the page to rebuild.", + ); + dispatchProgress( + completedJobs, + totalSteps, + false, + "Indexing paused — reload to rebuild", + ); + return; + } + dispatchProgress( completedJobs, totalSteps, diff --git a/src/plugins/built-in/globalSearch/src/indexing/indexingPause.ts b/src/plugins/built-in/globalSearch/src/indexing/indexingPause.ts new file mode 100644 index 00000000..56a248c2 --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/indexing/indexingPause.ts @@ -0,0 +1,10 @@ +/** In-memory gate: after a manual reset, skip indexing until the tab reloads. */ +let pausedUntilReload = false; + +export function pauseIndexingUntilReload(): void { + pausedUntilReload = true; +} + +export function isIndexingPaused(): boolean { + return pausedUntilReload; +} diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/messages.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/messages.ts index 6e13d485..aa1ff832 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs/messages.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/messages.ts @@ -395,7 +395,7 @@ export const messagesJob: Job = { progress.totalEstimated = await estimateMessageCount(); try { - await vectorWorker.startStreamingSession( + progress.streamingStarted = await vectorWorker.startStreamingSession( progress.totalEstimated, (progressData) => { verboseLog( @@ -405,10 +405,11 @@ export const messagesJob: Job = { RATE_LIMIT_CONFIG.vectorBatchSize, "messages", ); - progress.streamingStarted = true; - verboseLog( - `[Messages job] Started streaming vectorization session for ~${progress.totalEstimated} items`, - ); + if (progress.streamingStarted) { + verboseLog( + `[Messages job] Started streaming vectorization session for ~${progress.totalEstimated} items`, + ); + } } catch (error) { console.warn( "[Messages job] Failed to start streaming session:", diff --git a/src/plugins/built-in/globalSearch/src/indexing/jobs/notifications.ts b/src/plugins/built-in/globalSearch/src/indexing/jobs/notifications.ts index 523b00ed..de83fb1c 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/jobs/notifications.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/jobs/notifications.ts @@ -199,7 +199,7 @@ export const notificationsJob: Job = { const estimatedTotal = Math.min(notifications.length * 1.2, 100); try { - await vectorWorker.startStreamingSession( + progress.streamingStarted = await vectorWorker.startStreamingSession( estimatedTotal, (progressData) => { verboseLog( @@ -209,10 +209,11 @@ export const notificationsJob: Job = { NOTIFICATIONS_RATE_LIMIT.vectorBatchSize, "notifications", ); - progress.streamingStarted = true; - verboseLog( - `[Notifications job] Started streaming vectorization session for ~${estimatedTotal} items`, - ); + if (progress.streamingStarted) { + verboseLog( + `[Notifications job] Started streaming vectorization session for ~${estimatedTotal} items`, + ); + } } catch (error) { console.warn( "[Notifications job] Failed to start streaming session:", diff --git a/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts b/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts index 9b551ba4..e0310a11 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts @@ -10,6 +10,7 @@ import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog"; import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api"; import { mergeDynamicItems } from "../utils/dynamicItems"; import { decorateIndexItems } from "./renderComponents"; +import { isIndexingPaused } from "./indexingPause"; /** * Passive network observer. @@ -380,7 +381,7 @@ function synthesizeItems( /* ------------------------------------------------------------------ */ async function persistItems(items: IndexItem[]): Promise { - if (items.length === 0) return; + if (items.length === 0 || isIndexingPaused()) return; // Dedupe against existing entries. We replace on collision so the latest // observation wins (e.g. if a message changes title). @@ -401,16 +402,27 @@ async function persistItems(items: IndexItem[]): Promise { } function scheduleFlush() { - if (pendingFlush) return; + if (pendingFlush || isIndexingPaused()) return; pendingFlush = setTimeout(() => { pendingFlush = null; - if (!pendingDirty) return; + if (!pendingDirty || isIndexingPaused()) return; pendingDirty = false; void flushDynamicItems(); }, FLUSH_DEBOUNCE_MS); } +/** Drop queued passive captures after a manual index reset. */ +export function pausePassiveObserver(): void { + pendingChangedItems.clear(); + pendingDirty = false; + if (pendingFlush) { + clearTimeout(pendingFlush); + pendingFlush = null; + } +} + async function flushDynamicItems(): Promise { + if (isIndexingPaused()) return; if (pendingChangedItems.size === 0) return; const rawChanged = Array.from(pendingChangedItems.values()); diff --git a/src/plugins/built-in/globalSearch/src/indexing/resetIndexes.ts b/src/plugins/built-in/globalSearch/src/indexing/resetIndexes.ts index 80fe39f1..eb6400f5 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/resetIndexes.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/resetIndexes.ts @@ -1,4 +1,48 @@ import { SCHEMA_VERSION_KEY } from "./schemaVersion"; +import { pauseIndexingUntilReload } from "./indexingPause"; +import { pausePassiveObserver } from "./passiveObserver"; +import browser from "webextension-polyfill"; + +export const RESET_INDEX_MESSAGE = "global-search-reset-index"; + +let resetMessageListenerInstalled = false; + +/** Notify open SEQTA tabs to pause indexing and wipe page-origin stores. */ +export async function notifyOpenTabsResetSearchIndex(): Promise { + const tabs = await browser.tabs.query({}); + await Promise.allSettled( + tabs.map((tab) => + tab.id != null + ? browser.tabs.sendMessage(tab.id, { type: RESET_INDEX_MESSAGE }) + : Promise.resolve(), + ), + ); +} + +/** Content scripts: handle reset broadcast from the settings popup. */ +export function installResetIndexMessageListener(): void { + if (resetMessageListenerInstalled) return; + resetMessageListenerInstalled = true; + + browser.runtime.onMessage.addListener((message) => { + if (message?.type !== RESET_INDEX_MESSAGE) return; + pauseIndexingUntilReload(); + pausePassiveObserver(); + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("indexing-progress", { + detail: { + completed: 0, + total: 0, + indexing: false, + status: "Indexing paused — reload to rebuild", + }, + }), + ); + } + void resetSearchIndexes(); + }); +} /** * Hard-reset of all global-search persistence. diff --git a/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorker.ts b/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorker.ts index cca5bc45..728e2475 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorker.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorker.ts @@ -1,7 +1,15 @@ import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia"; import type { IndexItem } from "../types"; -import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; +import { verboseDebug, verboseInfo, verboseLog } from "./workerVerboseLog"; + +let ortWasmBase: string | null = null; + +async function configureOrtWasm(base: string): Promise { + const { env } = await import("@huggingface/transformers"); + env.backends.onnx.wasm = env.backends.onnx.wasm ?? {}; + env.backends.onnx.wasm.wasmPaths = base.endsWith("/") ? base : `${base}/`; +} let vectorIndex: EmbeddingIndex | null = null; let isInitialized = false; let initializationFailed = false; @@ -49,6 +57,9 @@ async function initWorker() { verboseDebug("Initializing vector worker..."); try { + if (ortWasmBase) { + await configureOrtWasm(ortWasmBase); + } await initializeModel(); vectorIndex = new EmbeddingIndex([]); @@ -562,6 +573,9 @@ self.addEventListener("message", async (e) => { switch (type) { case "init": + if (data?.ortWasmBase) { + ortWasmBase = data.ortWasmBase; + } await initWorker(); self.postMessage({ type: "ready" }); break; @@ -594,13 +608,3 @@ self.addEventListener("message", async (e) => { console.warn("Unknown message type:", type); } }); - -initWorker() - .then(() => { - self.postMessage({ type: "ready" }); - }) - .catch((err) => { - console.error("Initial worker initialization failed:", err); - - self.postMessage({ type: "ready" }); - }); diff --git a/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts b/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts index 01bff330..c8a34a49 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/worker/vectorWorkerManager.ts @@ -1,6 +1,7 @@ import { refreshVectorCache } from "../../search/vector/vectorSearch"; import type { IndexItem } from "../types"; import { isVectorSearchSupported } from "../../utils/browserDetection"; +import { getOrtWasmBaseUrl } from "@/lib/transformersExtension"; import vectorWorker from "./vectorWorker.ts?inlineWorker"; import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; @@ -91,7 +92,7 @@ export class VectorWorkerManager { this.isInitialized = false; reject(new Error("Worker initialization timed out")); - }, 10000); + }, 60000); this.worker!.addEventListener("message", (e) => { const { type, data } = e.data; @@ -158,7 +159,10 @@ export class VectorWorkerManager { } }); - this.worker!.postMessage({ type: "init" }); + this.worker!.postMessage({ + type: "init", + data: { ortWasmBase: getOrtWasmBaseUrl() }, + }); }); } @@ -388,7 +392,7 @@ export class VectorWorkerManager { onProgress?: ProgressCallback, batchSize: number = 10, jobId?: string, - ): Promise { + ): Promise { // Skip if vector search is not supported if (!isVectorSearchSupported()) { verboseDebug("[VectorWorker] Vector search not supported - skipping streaming session"); @@ -398,13 +402,13 @@ export class VectorWorkerManager { message: "Vector search not available - using text search only", }); } - return; + return false; } // Only initialize if we expect items to process if (totalExpectedItems === 0) { verboseDebug("[VectorWorker] No items expected, not starting streaming session"); - return; + return false; } await this.ensureReady(); @@ -419,7 +423,7 @@ export class VectorWorkerManager { await new Promise((resolve) => setTimeout(resolve, 100)); } else { verboseDebug(`Streaming session for job ${jobId} already active`); - return; + return true; } } @@ -455,13 +459,20 @@ export class VectorWorkerManager { message: `Starting streaming vectorization for ${jobId}`, }); } + + return true; } async streamItems(items: IndexItem[]): Promise { + if (!isVectorSearchSupported()) { + return; + } + if (!this.streamingSession?.isActive) { - throw new Error( - "No active streaming session. Call startStreamingSession first.", + verboseDebug( + "[VectorWorker] streamItems skipped — no active streaming session", ); + return; } const uniqueItems = items.filter((item, index, arr) => { diff --git a/src/plugins/built-in/globalSearch/src/indexing/worker/workerVerboseLog.ts b/src/plugins/built-in/globalSearch/src/indexing/worker/workerVerboseLog.ts new file mode 100644 index 00000000..cf6c3bdc --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/indexing/worker/workerVerboseLog.ts @@ -0,0 +1,13 @@ +/** Worker-safe logging — no webextension-polyfill or SettingsState. */ + +export function verboseDebug(...args: unknown[]): void { + if (typeof console !== "undefined") console.debug(...args); +} + +export function verboseInfo(...args: unknown[]): void { + if (typeof console !== "undefined") console.info(...args); +} + +export function verboseLog(...args: unknown[]): void { + if (typeof console !== "undefined") console.log(...args); +} diff --git a/src/plugins/built-in/globalSearch/src/search/vector/vectorSearch.ts b/src/plugins/built-in/globalSearch/src/search/vector/vectorSearch.ts index 606a3b9e..cbf6b47d 100644 --- a/src/plugins/built-in/globalSearch/src/search/vector/vectorSearch.ts +++ b/src/plugins/built-in/globalSearch/src/search/vector/vectorSearch.ts @@ -2,6 +2,7 @@ import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia"; import type { IndexItem } from "../../indexing/types"; import type { SearchResult } from "embeddia"; import { isVectorSearchSupported } from "../../utils/browserDetection"; +import { ensureTransformersEnv } from "@/lib/transformersExtension"; import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; let vectorIndex: EmbeddingIndex | null = null; @@ -24,6 +25,7 @@ export async function initVectorSearch() { initializationAttempted = true; try { + await ensureTransformersEnv(); await initializeModel(); vectorIndex = new EmbeddingIndex([]); vectorIndex.preloadIndexedDB(); diff --git a/src/plugins/core/dynamicLoader.ts b/src/plugins/core/dynamicLoader.ts index 339bba3c..665d1ccb 100644 --- a/src/plugins/core/dynamicLoader.ts +++ b/src/plugins/core/dynamicLoader.ts @@ -49,10 +49,16 @@ export function createLazyPlugin - +

      No lessons for this day.

      `; } else { @@ -310,7 +311,7 @@ function appendEngageNoticeEmptyState(container: HTMLElement, message: string) { const emptyState = document.createElement("div"); emptyState.classList.add("day-empty"); const img = document.createElement("img"); - img.src = browser.runtime.getURL(LogoLight); + img.src = resolveExtensionAssetUrl(LogoLight); const text = document.createElement("p"); text.innerText = message; emptyState.append(img, text); @@ -717,7 +718,7 @@ function showEngageTimetableError(message: string): void { dayContainer.classList.remove("loading"); dayContainer.innerHTML = `
      - +

      ${message}

      `; } @@ -728,7 +729,7 @@ function showEngageNoticesSectionError(message: string): void { noticeContainer.classList.remove("loading"); noticeContainer.innerHTML = `
      - +

      ${message}

      `; } diff --git a/src/seqta/utils/Loaders/LoadHomePage.ts b/src/seqta/utils/Loaders/LoadHomePage.ts index a28e9f89..52f96c2e 100644 --- a/src/seqta/utils/Loaders/LoadHomePage.ts +++ b/src/seqta/utils/Loaders/LoadHomePage.ts @@ -1,6 +1,7 @@ import { animate, stagger } from "motion"; import browser from "webextension-polyfill"; import LogoLight from "@/resources/icons/betterseqta-light-icon.png"; +import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import assessmentsicon from "@/seqta/icons/assessmentsIcon"; import coursesicon from "@/seqta/icons/coursesIcon"; import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour"; @@ -385,7 +386,7 @@ function appendNoticeEmptyState(container: HTMLElement, message: string) { const emptyState = document.createElement("div"); emptyState.classList.add("day-empty"); const img = document.createElement("img"); - img.src = browser.runtime.getURL(LogoLight); + img.src = resolveExtensionAssetUrl(LogoLight); const text = document.createElement("p"); text.innerText = message; emptyState.append(img, text); @@ -786,7 +787,7 @@ function callHomeTimetable(date: string, change?: any) { const dummyDay = document.createElement("div"); dummyDay.classList.add("day-empty"); const img = document.createElement("img"); - img.src = browser.runtime.getURL(LogoLight); + img.src = resolveExtensionAssetUrl(LogoLight); const text = document.createElement("p"); text.innerText = "No lessons available."; dummyDay.append(img, text); @@ -1096,7 +1097,7 @@ async function CreateUpcomingSection(assessments: any, activeSubjects: any) { if (assessments.length === 0) { upcomingitemcontainer!.innerHTML = `
      - +

      No assessments available.

      `; } diff --git a/src/seqta/utils/SendNewsPage.ts b/src/seqta/utils/SendNewsPage.ts index a41d4dec..97189abb 100644 --- a/src/seqta/utils/SendNewsPage.ts +++ b/src/seqta/utils/SendNewsPage.ts @@ -4,6 +4,7 @@ import { delay } from "./delay"; import { settingsState } from "./listeners/SettingsState"; import browser from "webextension-polyfill"; import LogoLightOutline from "@/resources/icons/betterseqta-light-outline.png"; +import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import { animate, stagger } from "motion"; import { verboseInfo } from "@/utils/verboseLog"; @@ -65,7 +66,7 @@ export async function SendNewsPage() { const emptyState = document.createElement("div"); emptyState.classList.add("day-empty"); const img = document.createElement("img"); - img.src = browser.runtime.getURL(LogoLightOutline); + img.src = resolveExtensionAssetUrl(LogoLightOutline); const text = document.createElement("p"); text.innerText = "No news articles available right now."; emptyState.append(img, text); @@ -86,7 +87,7 @@ export async function SendNewsPage() { if (article.urlToImage == "null" || article.urlToImage == null) { articleimage.style.cssText = ` - background-image: url(${browser.runtime.getURL(LogoLightOutline)}); + background-image: url(${resolveExtensionAssetUrl(LogoLightOutline)}); width: 20%; margin: 0 7.5%; `; From bdb1911e74fdb9d4ca720faeac7916ae9686ebf2 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Sat, 27 Jun 2026 15:20:52 +0930 Subject: [PATCH 29/36] feat(CI): modular testing and linting system + cursor rule --- .github/actions/build-extension/action.yml | 10 +--- .github/actions/run-lint/action.yml | 11 +++++ .github/actions/run-smoke-tests/action.yml | 9 ++++ .github/actions/run-unit-tests/action.yml | 9 ++++ .github/actions/setup-node-deps/action.yml | 14 ++++++ .github/workflows/mvp.yml | 36 -------------- .github/workflows/pr-ci.yml | 47 ++++++++++++------- jest.config.js | 1 + package.json | 5 +- .../src/indexing/selfTests.test.ts | 12 +++++ .../globalSearch/src/indexing/selfTests.ts | 10 ++-- .../globalSearch/src/indexing/utils.ts | 2 +- src/test/jest.setup.ts | 5 ++ src/test/mocks/webextension-polyfill.ts | 12 ++++- 14 files changed, 114 insertions(+), 69 deletions(-) create mode 100644 .github/actions/run-lint/action.yml create mode 100644 .github/actions/run-smoke-tests/action.yml create mode 100644 .github/actions/run-unit-tests/action.yml create mode 100644 .github/actions/setup-node-deps/action.yml delete mode 100644 .github/workflows/mvp.yml create mode 100644 src/plugins/built-in/globalSearch/src/indexing/selfTests.test.ts create mode 100644 src/test/jest.setup.ts diff --git a/.github/actions/build-extension/action.yml b/.github/actions/build-extension/action.yml index 6d71a1b8..57918b56 100644 --- a/.github/actions/build-extension/action.yml +++ b/.github/actions/build-extension/action.yml @@ -33,14 +33,8 @@ outputs: runs: using: composite steps: - - name: Use Node.js 22.x - uses: actions/setup-node@v4 - with: - node-version: 22.x - - - name: Install dependencies - shell: bash - run: npm install --legacy-peer-deps + - name: Setup Node and dependencies + uses: ./.github/actions/setup-node-deps - name: Read version id: version diff --git a/.github/actions/run-lint/action.yml b/.github/actions/run-lint/action.yml new file mode 100644 index 00000000..6f9e9ae4 --- /dev/null +++ b/.github/actions/run-lint/action.yml @@ -0,0 +1,11 @@ +name: Run lint +description: Run ESLint on src. + +runs: + using: composite + steps: + - name: Lint + shell: bash + run: npm run lint + env: + ESLINT_USE_FLAT_CONFIG: "false" diff --git a/.github/actions/run-smoke-tests/action.yml b/.github/actions/run-smoke-tests/action.yml new file mode 100644 index 00000000..4723f5ce --- /dev/null +++ b/.github/actions/run-smoke-tests/action.yml @@ -0,0 +1,9 @@ +name: Run smoke tests +description: Verify built extension dist output. + +runs: + using: composite + steps: + - name: Smoke tests + shell: bash + run: npm run test:smoke diff --git a/.github/actions/run-unit-tests/action.yml b/.github/actions/run-unit-tests/action.yml new file mode 100644 index 00000000..6c92a48a --- /dev/null +++ b/.github/actions/run-unit-tests/action.yml @@ -0,0 +1,9 @@ +name: Run unit tests +description: Run Jest unit tests. + +runs: + using: composite + steps: + - name: Unit tests + shell: bash + run: npm run test:unit diff --git a/.github/actions/setup-node-deps/action.yml b/.github/actions/setup-node-deps/action.yml new file mode 100644 index 00000000..c1e247c6 --- /dev/null +++ b/.github/actions/setup-node-deps/action.yml @@ -0,0 +1,14 @@ +name: Setup Node and dependencies +description: Install Node.js 22.x and npm dependencies. + +runs: + using: composite + steps: + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: 22.x + + - name: Install dependencies + shell: bash + run: npm install --legacy-peer-deps diff --git a/.github/workflows/mvp.yml b/.github/workflows/mvp.yml deleted file mode 100644 index c461ef9f..00000000 --- a/.github/workflows/mvp.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: NodeJS Build - -on: - push: - branches: ["main"] - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [20.x] - - steps: - - uses: actions/checkout@v4 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - - name: Build - run: | - npm install --legacy-peer-deps - npm run build - - - name: Zip dist folder - run: | - zip -r dist.zip dist - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: dist-zip - path: dist.zip diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 1744162a..fbf2ce76 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -1,11 +1,13 @@ -name: PR CI +name: CI on: pull_request: branches: ["main"] + push: + branches: ["main"] jobs: - ci: + lint: # windows-latest: Vite/Svelte build fails on Linux CI for layerchart vendor .svelte (see nightly.yml). runs-on: windows-latest defaults: @@ -14,21 +16,34 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 22.x - uses: actions/setup-node@v4 - with: - node-version: 22.x + - name: Setup Node and dependencies + uses: ./.github/actions/setup-node-deps - - name: Install dependencies - run: npm install --legacy-peer-deps + - name: Run lint + uses: ./.github/actions/run-lint - - name: Lint - run: npm run lint - env: - ESLINT_USE_FLAT_CONFIG: "false" + unit-tests: + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 - - name: Unit tests - run: npm test + - name: Setup Node and dependencies + uses: ./.github/actions/setup-node-deps + + - name: Run unit tests + uses: ./.github/actions/run-unit-tests + + build-and-smoke: + needs: [lint, unit-tests] + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 - name: Build extension id: build @@ -44,5 +59,5 @@ jobs: ${{ steps.build.outputs.chrome_zip }} ${{ steps.build.outputs.firefox_zip }} - - name: Smoke tests - run: npm run test:smoke + - name: Run smoke tests + uses: ./.github/actions/run-smoke-tests diff --git a/jest.config.js b/jest.config.js index 3903c51f..b581d02a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,6 +17,7 @@ export default { '^@/(.*)$': '/src/$1', '^webextension-polyfill$': '/src/test/mocks/webextension-polyfill.ts', }, + setupFilesAfterEnv: ['/src/test/jest.setup.ts'], moduleFileExtensions: ['ts', 'js', 'json'], collectCoverageFrom: [ 'src/**/*.ts', diff --git a/package.json b/package.json index cddffb3b..3318f047 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,10 @@ "convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari", "dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg", "lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint \"src/**/*.{js,ts}\"", - "test": "jest", + "test": "npm run test:unit", + "test:unit": "jest", "test:smoke": "node scripts/smoke-test.mjs", + "test:ci": "npm run test:unit && npm run build && npm run test:smoke", "release": "gh release create $npm_package_version --repo BetterSEQTA/BetterSEQTA-Plus ./dist/*.zip --generate-notes", "publish": "bun lib/publish.js --b", "zip": "bedframe zip" @@ -58,6 +60,7 @@ "eslint-plugin-import": "^2.31.0", "glob": "^11.0.1", "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", "mime-types": "^3.0.1", "prettier": "^3.5.3", "process": "^0.11.10", diff --git a/src/plugins/built-in/globalSearch/src/indexing/selfTests.test.ts b/src/plugins/built-in/globalSearch/src/indexing/selfTests.test.ts new file mode 100644 index 00000000..b6c6b7f5 --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/indexing/selfTests.test.ts @@ -0,0 +1,12 @@ +/** + * @jest-environment jsdom + */ +import { runGlobalSearchSelfTests } from "./selfTests"; + +describe("globalSearch selfTests", () => { + it("all in-process cases pass", async () => { + const report = await runGlobalSearchSelfTests(); + expect(report.failed).toBe(0); + expect(report.failures).toEqual([]); + }); +}); diff --git a/src/plugins/built-in/globalSearch/src/indexing/selfTests.ts b/src/plugins/built-in/globalSearch/src/indexing/selfTests.ts index f85d6abe..27f46175 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/selfTests.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/selfTests.ts @@ -22,12 +22,10 @@ import { /** * Lightweight in-process self-tests for the global-search overhaul. * - * The repository does not (yet) ship with a test runner, so we instead - * expose a deterministic suite of assertions over the pure helpers that - * back active jobs and the passive observer. This is intentionally - * dependency-free so it can run inside the extension page (`window. - * globalSearchDebug.runSelfTests()`) and from any future Vitest harness - * without modification. + * Exposes a deterministic suite of assertions over the pure helpers that + * back active jobs and the passive observer. Runs in Jest via + * `selfTests.test.ts`, and inside the extension page via + * `window.globalSearchDebug.runSelfTests()`. */ interface TestCase { diff --git a/src/plugins/built-in/globalSearch/src/indexing/utils.ts b/src/plugins/built-in/globalSearch/src/indexing/utils.ts index 9de11592..0ee9a245 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/utils.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/utils.ts @@ -141,7 +141,7 @@ export function htmlToPlainText(rawHtml: string): string { } }); - let text = body.innerText || ""; + let text = body.textContent || body.innerText || ""; text = text .replace(/\u00A0/g, " ") diff --git a/src/test/jest.setup.ts b/src/test/jest.setup.ts new file mode 100644 index 00000000..ab105650 --- /dev/null +++ b/src/test/jest.setup.ts @@ -0,0 +1,5 @@ +import { __resetBrowserStorageMock } from "./mocks/webextension-polyfill"; + +afterEach(() => { + __resetBrowserStorageMock(); +}); diff --git a/src/test/mocks/webextension-polyfill.ts b/src/test/mocks/webextension-polyfill.ts index 6171d913..0acee899 100644 --- a/src/test/mocks/webextension-polyfill.ts +++ b/src/test/mocks/webextension-polyfill.ts @@ -23,8 +23,16 @@ const local = { }), }; +const onChanged = { + addListener: jest.fn(), + removeListener: jest.fn(), +}; + export default { - storage: { local }, + storage: { local, onChanged }, + runtime: { + sendMessage: jest.fn(async () => undefined), + }, }; export function __resetBrowserStorageMock() { @@ -32,4 +40,6 @@ export function __resetBrowserStorageMock() { local.get.mockClear(); local.set.mockClear(); local.remove.mockClear(); + onChanged.addListener.mockClear(); + onChanged.removeListener.mockClear(); } From 6518999ac57219be078724b3c7ddc344c3565d15 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Sat, 27 Jun 2026 15:38:53 +0930 Subject: [PATCH 30/36] fix(global Search): duped indexes --- .../globalSearch/src/indexing/actions.ts | 36 +++-- .../src/indexing/passiveObserver.ts | 3 + .../src/indexing/routeFilters.test.ts | 18 +++ .../globalSearch/src/indexing/routeFilters.ts | 8 ++ .../src/search/dedupeIndexItems.test.ts | 123 ++++++++++++++++++ .../src/search/dedupeIndexItems.ts | 90 +++++++++++-- 6 files changed, 253 insertions(+), 25 deletions(-) create mode 100644 src/plugins/built-in/globalSearch/src/indexing/routeFilters.test.ts create mode 100644 src/plugins/built-in/globalSearch/src/indexing/routeFilters.ts create mode 100644 src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.test.ts diff --git a/src/plugins/built-in/globalSearch/src/indexing/actions.ts b/src/plugins/built-in/globalSearch/src/indexing/actions.ts index b485b3a5..0b282ee2 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/actions.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/actions.ts @@ -353,9 +353,28 @@ export const actionMap: Record> = { const forumId = num("forumId") ?? num("forum"); const year = num("year"); const assessmentId = - num("assessmentId") ?? num("assessmentID") ?? num("id"); + num("assessmentId") ?? + num("assessmentID") ?? + num("entityId") ?? + num("id"); const messageId = num("messageId"); + const navigateToAssessment = (): void => { + if (programme !== undefined && metaclass !== undefined) { + const itemSuffix = + assessmentId !== undefined ? `&item=${assessmentId}` : ""; + navigateToHashRoute( + `/assessments/${programme}:${metaclass}${itemSuffix}`, + ); + return; + } + if (assessmentId !== undefined) { + navigateToHashRoute(`/assessments/upcoming&item=${assessmentId}`); + return; + } + navigateToHashRoute("/assessments/upcoming"); + }; + if (sourcePage === "/messages") { navigateInCurrentSeqtaApp("/messages"); return; @@ -369,19 +388,8 @@ export const actionMap: Record> = { } break; case "assessments": - if (programme !== undefined && metaclass !== undefined) { - const itemSuffix = - assessmentId !== undefined ? `&item=${assessmentId}` : ""; - navigateToHashRoute( - `/assessments/${programme}:${metaclass}${itemSuffix}`, - ); - return; - } - if (assessmentId !== undefined) { - navigateToHashRoute(`/assessments/upcoming&item=${assessmentId}`); - return; - } - navigateToHashRoute("/assessments/upcoming"); + case "assessment": + navigateToAssessment(); return; case "forums": case "forum": diff --git a/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts b/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts index e0310a11..4d77082d 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts @@ -11,6 +11,7 @@ import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api"; import { mergeDynamicItems } from "../utils/dynamicItems"; import { decorateIndexItems } from "./renderComponents"; import { isIndexingPaused } from "./indexingPause"; +import { isAssessmentListRoute } from "./routeFilters"; /** * Passive network observer. @@ -298,6 +299,8 @@ function synthesizeItems( ctx: CapturedContext, payload: unknown, ): IndexItem[] { + if (isAssessmentListRoute(ctx.route)) return []; + const entities = entitiesFromPayload(payload); if (entities.length === 0) return []; diff --git a/src/plugins/built-in/globalSearch/src/indexing/routeFilters.test.ts b/src/plugins/built-in/globalSearch/src/indexing/routeFilters.test.ts new file mode 100644 index 00000000..c1c7e069 --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/indexing/routeFilters.test.ts @@ -0,0 +1,18 @@ +import { isAssessmentListRoute } from "./routeFilters"; + +describe("isAssessmentListRoute", () => { + it("matches past and upcoming assessment list routes", () => { + expect(isAssessmentListRoute("/seqta/student/assessment/list/past?")).toBe( + true, + ); + expect( + isAssessmentListRoute("/seqta/student/assessment/list/upcoming?"), + ).toBe(true); + }); + + it("does not match unrelated routes", () => { + expect(isAssessmentListRoute("/seqta/student/load/courses")).toBe(false); + expect(isAssessmentListRoute("/seqta/student/load/messages")).toBe(false); + expect(isAssessmentListRoute("/seqta/student/assessment/save")).toBe(false); + }); +}); diff --git a/src/plugins/built-in/globalSearch/src/indexing/routeFilters.ts b/src/plugins/built-in/globalSearch/src/indexing/routeFilters.ts new file mode 100644 index 00000000..b5bd4ab8 --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/indexing/routeFilters.ts @@ -0,0 +1,8 @@ +/** Routes already indexed by the assignments job — passive capture would duplicate them. */ +export function isAssessmentListRoute(route: string): boolean { + const normalized = route.toLowerCase(); + return ( + normalized.includes("/assessment/list/past") || + normalized.includes("/assessment/list/upcoming") + ); +} diff --git a/src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.test.ts b/src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.test.ts new file mode 100644 index 00000000..889b2999 --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.test.ts @@ -0,0 +1,123 @@ +import { + assessmentDestinationKey, + dedupeCombinedResultsByCourseNav, + dedupeIndexItemsForSearch, +} from "./dedupeIndexItems"; +import type { IndexItem } from "../indexing/types"; + +function makeItem(overrides: Partial & Pick): IndexItem { + return { + text: "SAT 1: Differential Calculus", + category: "assignments", + content: "Subject: Mathematical Methods", + dateAdded: 1, + metadata: {}, + actionId: "assessment", + renderComponentId: "assessment", + ...overrides, + }; +} + +describe("assessmentDestinationKey", () => { + it("keys curated assignment items by assessment id", () => { + const item = makeItem({ + id: "assignment-19748", + metadata: { assessmentId: 19748 }, + }); + expect(assessmentDestinationKey(item)).toBe("assessment:19748"); + }); + + it("keys passive past items by entity id", () => { + const item = makeItem({ + id: "passive-past-19748", + category: "past", + actionId: "passive", + renderComponentId: "passive", + metadata: { + entityId: 19748, + route: "/seqta/student/assessment/list/past", + source: "passive", + }, + }); + expect(assessmentDestinationKey(item)).toBe("assessment:19748"); + }); +}); + +describe("dedupeIndexItemsForSearch assessments", () => { + it("keeps curated assignment over passive past duplicate", () => { + const passive = makeItem({ + id: "passive-past-19748", + category: "past", + actionId: "passive", + renderComponentId: "passive", + dateAdded: 2, + metadata: { + entityId: 19748, + route: "/seqta/student/assessment/list/past", + source: "passive", + }, + }); + const curated = makeItem({ + id: "assignment-19748", + category: "assignments", + actionId: "assessment", + renderComponentId: "assessment", + dateAdded: 1, + metadata: { + assessmentId: 19748, + programmeId: 3705, + metaclassId: 10337, + }, + }); + + const result = dedupeIndexItemsForSearch([passive, curated]); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("assignment-19748"); + }); + + it("preserves unrelated items", () => { + const course = makeItem({ + id: "course-1", + category: "courses", + actionId: "course", + renderComponentId: "course", + metadata: { programmeId: 1, metaclassId: 2 }, + }); + const assignment = makeItem({ + id: "assignment-99", + metadata: { assessmentId: 99 }, + }); + + const result = dedupeIndexItemsForSearch([course, assignment]); + expect(result).toHaveLength(2); + }); +}); + +describe("dedupeCombinedResultsByCourseNav assessments", () => { + it("collapses hybrid results for the same assessment id", () => { + const passive = makeItem({ + id: "passive-past-19748", + category: "past", + actionId: "passive", + renderComponentId: "passive", + metadata: { + entityId: 19748, + route: "/seqta/student/assessment/list/past", + source: "passive", + }, + }); + const curated = makeItem({ + id: "assignment-19748", + metadata: { assessmentId: 19748, programmeId: 3705, metaclassId: 10337 }, + }); + + const results = dedupeCombinedResultsByCourseNav([ + { type: "dynamic", id: passive.id, score: 0.9, item: passive }, + { type: "dynamic", id: curated.id, score: 0.8, item: curated }, + ]); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe("assignment-19748"); + expect(results[0].score).toBe(0.9); + }); +}); diff --git a/src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.ts b/src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.ts index 970216c6..20004724 100644 --- a/src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.ts +++ b/src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.ts @@ -42,12 +42,49 @@ export function courseDestinationKey(item: IndexItem): string | undefined { return `course:${programme}:${metaclass}`; } +function shouldDedupeAsSameAssessmentSPA(item: IndexItem): boolean { + if (item.actionId === "assessment") return true; + if (item.actionId !== "passive") return false; + + const md = item.metadata ?? {}; + const route = typeof md.route === "string" ? md.route.toLowerCase() : ""; + if (route.includes("/assessment/list/")) return true; + + const cat = item.category?.toLowerCase(); + return cat === "past" || cat === "upcoming"; +} + +export function assessmentDestinationKey(item: IndexItem): string | undefined { + if (!shouldDedupeAsSameAssessmentSPA(item)) return undefined; + const md = item.metadata ?? {}; + const assessmentId = toFiniteNumber( + md.assessmentId ?? md.assessmentID ?? md.entityId, + ); + if (assessmentId === undefined) return undefined; + return `assessment:${assessmentId}`; +} + +function searchDedupeKey(item: IndexItem): string | undefined { + return courseDestinationKey(item) ?? assessmentDestinationKey(item); +} + function isPassiveLike(item: IndexItem): boolean { return ( item.actionId === "passive" || item.metadata?.source === "passive" ); } +function hasProgrammeMetaclass(item: IndexItem): boolean { + const md = item.metadata ?? {}; + const programme = toFiniteNumber( + md.programme ?? md.programmeId ?? md.programmeID, + ); + const metaclass = toFiniteNumber( + md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId, + ); + return programme !== undefined && metaclass !== undefined; +} + function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem { const aP = isPassiveLike(a); const bP = isPassiveLike(b); @@ -65,20 +102,51 @@ function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem { return ad >= bd ? a : b; } +function pickBetterAssessmentDuplicate(a: IndexItem, b: IndexItem): IndexItem { + const aP = isPassiveLike(a); + const bP = isPassiveLike(b); + if (aP && !bP) return b; + if (!aP && bP) return a; + + if (a.category === "assignments" && b.category !== "assignments") return a; + if (b.category === "assignments" && a.category !== "assignments") return b; + + const aPm = hasProgrammeMetaclass(a); + const bPm = hasProgrammeMetaclass(b); + if (aPm && !bPm) return a; + if (!aPm && bPm) return b; + + const ad = typeof a.dateAdded === "number" ? a.dateAdded : 0; + const bd = typeof b.dateAdded === "number" ? b.dateAdded : 0; + return ad >= bd ? a : b; +} + +function pickBetterSearchDuplicate( + a: IndexItem, + b: IndexItem, + key: string, +): IndexItem { + if (key.startsWith("assessment:")) { + return pickBetterAssessmentDuplicate(a, b); + } + return pickBetterCourseNavDuplicate(a, b); +} + /** - * Collapses multiple index rows that open the same course hash route - * (e.g. `course` job + passive `/load/courses` capture) so search shows one hit. + * Collapses multiple index rows that open the same course or assessment hash + * route (e.g. `course` job + passive `/load/courses`, or assignments job + + * passive `/assessment/list/past`) so search shows one hit. */ export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] { const winners = new Map(); for (const item of items) { - const key = courseDestinationKey(item); + const key = searchDedupeKey(item); if (!key) continue; const prev = winners.get(key); winners.set( key, - prev ? pickBetterCourseNavDuplicate(prev, item) : item, + prev ? pickBetterSearchDuplicate(prev, item, key) : item, ); } @@ -86,7 +154,7 @@ export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] { const out: IndexItem[] = []; for (const item of items) { - const key = courseDestinationKey(item); + const key = searchDedupeKey(item); if (!key) { out.push(item); continue; @@ -99,14 +167,14 @@ export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] { return out; } -function dynamicCourseKey(row: CombinedResult): string | undefined { +function dynamicSearchKey(row: CombinedResult): string | undefined { if (row.type !== "dynamic") return undefined; - return courseDestinationKey(row.item as IndexItem); + return searchDedupeKey(row.item as IndexItem); } /** * Final pass after hybrid expansion: vector-only recall can still surface a - * second row for the same `/courses/P:M` SPA route using a stale passive id. + * second row for the same SPA route using a stale passive id. */ export function dedupeCombinedResultsByCourseNav( results: CombinedResult[], @@ -114,7 +182,7 @@ export function dedupeCombinedResultsByCourseNav( const best = new Map(); for (const r of results) { - const key = dynamicCourseKey(r); + const key = dynamicSearchKey(r); if (!key) continue; const prev = best.get(key); if (!prev) { @@ -123,7 +191,7 @@ export function dedupeCombinedResultsByCourseNav( } const aItem = prev.item as IndexItem; const bItem = r.item as IndexItem; - const winnerItem = pickBetterCourseNavDuplicate(aItem, bItem); + const winnerItem = pickBetterSearchDuplicate(aItem, bItem, key); const envelope = winnerItem.id === aItem.id ? prev : r; best.set(key, { ...envelope, @@ -137,7 +205,7 @@ export function dedupeCombinedResultsByCourseNav( const out: CombinedResult[] = []; for (const r of results) { - const key = dynamicCourseKey(r); + const key = dynamicSearchKey(r); if (!key) { out.push(r); continue; From dcb4dd2f5e33a9e82e6d3a436e0c74bcebdb52eb Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Sat, 27 Jun 2026 15:53:41 +0930 Subject: [PATCH 31/36] fix(build): commas in wrong place --- src/manifests/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manifests/manifest.json b/src/manifests/manifest.json index 166443cb..82970383 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -48,7 +48,7 @@ "resources/pdfjs/pdf.worker.min.mjs", "resources/pdfjs/pdf.legacy.min.mjs", "resources/ort/*", - "assets/*.css" + "assets/*.css", "assets/*" ], "matches": ["*://*/*"] From 4fda63dedd8061175d5b4047b5456f91b3d21c24 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sun, 28 Jun 2026 10:30:25 +0930 Subject: [PATCH 32/36] refactor: trim PR debloat and fix transformers build Extract shared helpers for home notices, timetable subtitles, and theme images; dedupe global search, Select, and build scripts while preserving behaviour. Add @huggingface/transformers as a direct dependency and resolve ORT WASM paths via require.resolve so pnpm postinstall and Vite can bundle vector search. Co-authored-by: Cursor --- lib/closePlugin.ts | 51 +- lib/extensionChunkUrls.ts | 19 +- lib/inlineWorker.ts | 79 +- package.json | 1 + scripts/compile-layerchart-vendor.mjs | 77 +- scripts/copy-ort-wasm-assets.mjs | 22 +- scripts/package-extension-zips.mjs | 55 +- src/css/injected.scss | 9 +- src/interface/components/Select.svelte | 149 +- .../components/icons/LucideMoon.svelte | 4 +- .../components/icons/LucideSun.svelte | 4 +- .../components/store/ThemeModal.svelte | 1400 ++++++----------- .../themes/BackgroundSelector.svelte | 22 +- .../components/themes/ThemeBlobImage.svelte | 10 +- src/interface/pages/settings/general.svelte | 52 +- src/interface/pages/themeCreator.svelte | 25 +- src/lib/icons/lucideMoon.ts | 12 +- src/lib/icons/lucideSun.ts | 12 +- .../animatedBackground/backgroundLayers.ts | 20 +- .../built-in/animatedBackground/index.ts | 15 +- src/plugins/built-in/backgroundMusic/index.ts | 303 ++-- .../built-in/backgroundMusic/styles.css | 14 +- src/plugins/built-in/globalSearch/lazy.ts | 9 +- .../src/components/SearchBar.svelte | 21 - .../globalSearch/src/core/commands.ts | 2 - .../built-in/globalSearch/src/core/index.ts | 235 +-- .../globalSearch/src/core/mountSearchBar.ts | 32 +- .../globalSearch/src/indexing/actions.ts | 2 +- .../built-in/globalSearch/src/indexing/db.ts | 114 +- .../globalSearch/src/indexing/indexer.ts | 156 +- .../src/indexing/jobs/assignments.ts | 2 +- .../globalSearch/src/indexing/jobs/courses.ts | 2 +- .../src/indexing/jobs/documents.ts | 2 +- .../globalSearch/src/indexing/jobs/folio.ts | 2 +- .../globalSearch/src/indexing/jobs/goals.ts | 2 +- .../src/indexing/jobs/messages.ts | 46 +- .../globalSearch/src/indexing/jobs/notices.ts | 2 +- .../src/indexing/jobs/notifications.ts | 48 +- .../globalSearch/src/indexing/jobs/portals.ts | 2 +- .../globalSearch/src/indexing/jobs/reports.ts | 2 +- .../src/indexing/jobs/subjects.ts | 2 +- .../src/indexing/passiveObserver.ts | 76 +- .../src/indexing/renderComponents.ts | 19 + .../globalSearch/src/indexing/resetIndexes.ts | 36 +- .../globalSearch/src/indexing/selfTests.ts | 5 - .../globalSearch/src/indexing/utils.ts | 170 +- .../src/indexing/worker/vectorWorker.ts | 64 +- .../indexing/worker/vectorWorkerManager.ts | 2 +- .../src/indexing/worker/workerVerboseLog.ts | 8 - .../src/search/dedupeIndexItems.ts | 44 +- .../globalSearch/src/search/searchUtils.ts | 22 +- .../src/search/vector/vectorSearch.ts | 69 +- .../src/utils/HighlightedText.svelte | 61 +- .../globalSearch/src/utils/hotkeyUtils.ts | 4 + .../globalSearch/src/utils/versionCheck.ts | 76 +- .../built-in/gradeAnalytics/core/index.ts | 34 +- src/plugins/built-in/gradeAnalytics/ui.ts | 15 +- src/plugins/built-in/themes/theme-manager.ts | 62 +- src/plugins/built-in/themes/themeImageUrl.ts | 22 +- src/plugins/built-in/timetable/index.ts | 2 +- src/plugins/built-in/timetableEdit/index.ts | 21 +- src/plugins/core/dynamicLoader.ts | 62 +- src/plugins/monofile.ts | 2 +- src/seqta/ui/AddBetterSEQTAElements.ts | 36 +- src/seqta/utils/Loaders/LoadEngageHomePage.ts | 469 +----- src/seqta/utils/Loaders/LoadHomePage.ts | 462 +----- src/seqta/utils/Loaders/timetableSubtitle.ts | 27 + src/seqta/utils/menuItemVisibility.ts | 19 +- src/seqta/utils/notices/noticeHomeUi.ts | 430 +++++ .../utils/patchSeqtaMenuUpdateColours.ts | 107 +- .../utils/patchThemeImagesPageContext.ts | 40 +- src/seqta/utils/seqtaMenuColourPatch.js | 379 ++--- src/seqta/utils/sidebarMenuIcons.ts | 22 +- src/seqta/utils/themeImagePagePatch.js | 148 +- src/seqta/utils/timetableColoris.ts | 96 -- vite.config.ts | 3 - 76 files changed, 2003 insertions(+), 4149 deletions(-) create mode 100644 src/seqta/utils/Loaders/timetableSubtitle.ts create mode 100644 src/seqta/utils/notices/noticeHomeUi.ts delete mode 100644 src/seqta/utils/timetableColoris.ts diff --git a/lib/closePlugin.ts b/lib/closePlugin.ts index 6241c02a..9494f298 100644 --- a/lib/closePlugin.ts +++ b/lib/closePlugin.ts @@ -1,59 +1,20 @@ // ref: https://stackoverflow.com/a/76920975 import type { Plugin } from "vite"; -/** - * Creates a Vite plugin designed to gracefully handle the conclusion of the build process. - * This plugin utilizes the `buildEnd` and `closeBundle` hooks provided by Vite. - * It checks for errors at the end of the build: - * - If an error occurred during the build (`buildEnd` hook receives an error), it logs the error - * and explicitly exits the Node.js process with a status code of 1 (indicating failure). - * - If the build completes without errors and the bundle is successfully generated - * (`closeBundle` hook is called), it logs a success message and exits the process - * with a status code of 0 (indicating success). - * This explicit process exiting can be useful in CI/CD environments or scripts that - * rely on the process status code to determine the build outcome. - * The core logic for using these hooks to exit the process is inspired by - * a solution found on StackOverflow (https://stackoverflow.com/a/76920975). - * - * @returns {Plugin} A Vite plugin object configured with `name`, `buildEnd`, and `closeBundle` hooks. - */ +/** Exit with code 1 on build failure; do not exit on success (multi-target builds). */ export default function ClosePlugin(): Plugin { return { - /** - * The unique name of this Vite plugin. This name is used by Vite for identification - * purposes and will appear in warnings, errors, and logs related to this plugin. - * @type {string} - */ - name: "ClosePlugin", // required, will show up in warnings and errors - - /** - * A Vite hook that is called when the build process has finished, regardless of - * whether it was successful or encountered an error. - * - * @param {Error} [error] An optional error object. If the build failed, this parameter - * will contain the error that occurred. If the build was successful, - * this parameter will be undefined or null. - */ + name: "ClosePlugin", buildEnd(error) { if (error) { - console.error("Error bundling"); - console.error(error); - process.exit(1); // Exit with status 1 indicating an error + console.error("Error bundling", error); + process.exit(1); } else { - console.log("Build ended"); // Log successful completion of the build phase + console.log("Build ended"); } }, - - /** - * A Vite hook that is called after the `buildEnd` hook, but only if the build - * was successful (i.e., no errors were passed to `buildEnd`) and all output - * files have been generated and written to disk. This signifies the successful - * completion of the entire bundling process. - */ closeBundle() { - console.log("Bundle closed"); // Log successful closure of the bundle - // Do not process.exit here — it can mask Vite render errors and break - // multi-target builds (`npm run build` runs chrome then firefox). + console.log("Bundle closed"); }, }; } diff --git a/lib/extensionChunkUrls.ts b/lib/extensionChunkUrls.ts index 1701505c..0cc492ea 100644 --- a/lib/extensionChunkUrls.ts +++ b/lib/extensionChunkUrls.ts @@ -1,12 +1,6 @@ import type { Plugin } from "vite"; -/** - * Vite's default base (`/`) emits absolute chunk paths like `/assets/chunk.js`. - * In content scripts those resolve against the SEQTA page origin on Firefox, - * not the extension — causing MIME type / NS_ERROR_CORRUPTED_CONTENT failures. - * - * Use relative base plus `chrome.runtime.getURL` for dynamic import targets. - */ +/** Relative chunk/CSS URLs via chrome.runtime.getURL for content-script dynamic imports. */ export function extensionChunkUrls(): Plugin { return { name: "extension-chunk-urls", @@ -17,16 +11,11 @@ export function extensionChunkUrls(): Plugin { renderBuiltUrl(filename, { hostType, type }) { const path = filename.replace(/^\//, ""); if (type === "chunk" && hostType === "js") { - return { - runtime: `chrome.runtime.getURL(${JSON.stringify(path)})`, - }; + return { runtime: `chrome.runtime.getURL(${JSON.stringify(path)})` }; } - // Rewrite CSS preloads from JS dynamic imports (content scripts). - // Do not rewrite hostType "css" — extension HTML pages need static hrefs. + // JS-triggered CSS preloads only — extension HTML pages need static hrefs. if (type === "asset" && hostType === "js" && path.endsWith(".css")) { - return { - runtime: `chrome.runtime.getURL(${JSON.stringify(path)})`, - }; + return { runtime: `chrome.runtime.getURL(${JSON.stringify(path)})` }; } }, }, diff --git a/lib/inlineWorker.ts b/lib/inlineWorker.ts index b351339a..cfd846b5 100644 --- a/lib/inlineWorker.ts +++ b/lib/inlineWorker.ts @@ -1,71 +1,32 @@ -// vite-plugin-inline-worker-dev.ts -// vite-plugin-inline-worker-dev.ts import { Plugin } from "vite"; -import fs from "fs/promises"; import { build } from "esbuild"; -/** - * Creates a Vite plugin designed for bundling and inlining web worker scripts during development. - * This plugin specifically targets module imports that include a `?inlineWorker` query parameter. - * When such an import is encountered, the plugin bundles the worker script using `esbuild` - * and then generates JavaScript code that inlines this bundled worker as a Blob, - * creating the worker instance via `URL.createObjectURL()`. - * The name "vite:inline-worker-dev" suggests it's primarily intended for development builds. - * - * @returns {Plugin} A Vite plugin object with `name` and `load` properties. - */ +/** Bundle worker entry points imported with `?inlineWorker` as Blob-backed Workers in dev. */ export default function InlineWorkerDevPlugin(): Plugin { return { - /** - * The unique name of this Vite plugin. - * @type {string} - */ name: "vite:inline-worker-dev", - /** - * The Vite hook responsible for loading and transforming modules. - * This function intercepts modules imported with `?inlineWorker`. - * For such modules, it bundles the worker script and returns JavaScript code - * that, when executed, will create an instance of this worker from an inlined Blob. - * - * @async - * @param {string} id The path or ID of the module Vite is attempting to load, - * potentially including query parameters (e.g., "/path/to/worker.ts?inlineWorker"). - * @returns {Promise} A promise that resolves to: - * - `null` if the module ID does not include `?inlineWorker`. - * - A string of JavaScript code if the module is an inline worker. - * This code will define a default export function (e.g., `InlineWorker`) - * that, when called, creates and returns a new `Worker` instance - * from the bundled and inlined worker script. - */ async load(id) { - if (id.includes("?inlineWorker")) { - const [cleanPath] = id.split("?"); - // Note: Original code had `await fs.readFile(cleanPath, "utf-8");` but `code` wasn't used. - // `esbuild` directly takes `cleanPath` as an entry point. - const result = await build({ - entryPoints: [cleanPath], - bundle: true, - write: false, - platform: "browser", - format: "iife", - target: "esnext", - external: ["webextension-polyfill"], - }); + if (!id.includes("?inlineWorker")) return null; - const workerCode = result.outputFiles[0].text; + const [cleanPath] = id.split("?"); + const result = await build({ + entryPoints: [cleanPath], + bundle: true, + write: false, + platform: "browser", + format: "iife", + target: "esnext", + external: ["webextension-polyfill"], + }); - // Construct JavaScript code that will create the worker from a Blob. - // This code is what gets returned to Vite and replaces the original import. - const workerBlobCode = ` - const code = ${JSON.stringify(workerCode)}; - export default function InlineWorker() { - const blob = new Blob([code], { type: 'application/javascript' }); - return new Worker(URL.createObjectURL(blob), { type: 'module' }); - } - `; - return workerBlobCode; - } - return null; // Let Vite handle other modules normally + const workerCode = result.outputFiles[0].text; + return ` + const code = ${JSON.stringify(workerCode)}; + export default function InlineWorker() { + const blob = new Blob([code], { type: 'application/javascript' }); + return new Worker(URL.createObjectURL(blob), { type: 'module' }); + } + `; }, }; } diff --git a/package.json b/package.json index 3318f047..8c5ab429 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "dompurify": "^3.2.4", + "@huggingface/transformers": "^3.8.1", "embeddia": "^1.3.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-svelte": "^8.5.2", diff --git a/scripts/compile-layerchart-vendor.mjs b/scripts/compile-layerchart-vendor.mjs index 6aa7c81c..c3f84ea2 100644 --- a/scripts/compile-layerchart-vendor.mjs +++ b/scripts/compile-layerchart-vendor.mjs @@ -1,15 +1,8 @@ /** - * layerchart ships raw `.svelte` sources in `dist/`. Vite/Svelte compilation is - * unreliable for this package on CI (Rollup parses vendor sources as JS). Compile - * to plain `.js` at install/build time and rewrite internal imports. + * Pre-compile layerchart `.svelte` sources to `.js` so Rollup/Vite CI builds succeed. */ import { compile } from "svelte/compiler"; -import { - readFileSync, - readdirSync, - statSync, - writeFileSync, -} from "node:fs"; +import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,97 +10,71 @@ const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const layerchartRoot = join(root, "node_modules", "layerchart"); const layerchartDist = join(layerchartRoot, "dist"); const stampPath = join(layerchartDist, ".bsplus-compiled"); +const COMPILE_ALGO_VERSION = "2"; +const importSuffixPattern = /\.svelte(?=['"])/g; -function exists(path) { +const exists = (path) => { try { statSync(path); return true; } catch { return false; } -} +}; if (!exists(layerchartDist)) { console.log("compile-layerchart-vendor: layerchart not installed, skipping"); process.exit(0); } -const COMPILE_ALGO_VERSION = "2"; - const layerchartVersion = JSON.parse( readFileSync(join(layerchartRoot, "package.json"), "utf8"), ).version; - const stampContent = `${layerchartVersion}\n${COMPILE_ALGO_VERSION}`; -if ( - exists(stampPath) && - readFileSync(stampPath, "utf8").trim() === stampContent -) { - console.log( - `compile-layerchart-vendor: layerchart@${layerchartVersion} already compiled, skipping`, - ); +if (exists(stampPath) && readFileSync(stampPath, "utf8").trim() === stampContent) { + console.log(`compile-layerchart-vendor: layerchart@${layerchartVersion} already compiled, skipping`); process.exit(0); } -function walkFiles(dir, files = []) { +const walkFiles = (dir, files = []) => { for (const name of readdirSync(dir)) { if (name === "node_modules") continue; const path = join(dir, name); - if (statSync(path).isDirectory()) { - walkFiles(path, files); - } else { - files.push(path); - } + if (statSync(path).isDirectory()) walkFiles(path, files); + else files.push(path); } return files; -} +}; -const importSuffixPattern = /\.svelte(?=['"])/g; - -function patchSvelteImports(content) { - return content.replace(importSuffixPattern, ".js"); -} - -/** Rollup CJS resolver chokes on TS optional params (`name?`) in vendor `.js`. */ -function stripRollupBreakingSyntax(code) { - return code +const patchSvelteImports = (content) => content.replace(importSuffixPattern, ".js"); +const stripRollupBreakingSyntax = (code) => + code .replace(/(\w+)\?(?=\s*[,)\]])/g, "$1") .replace(/(\w+)\?(?=\s*:)/g, "$1"); -} const svelteFiles = walkFiles(layerchartDist).filter((f) => f.endsWith(".svelte")); for (const sveltePath of svelteFiles) { const source = readFileSync(sveltePath, "utf8"); if (!source.includes(" - /\.(js|svelte|ts|mjs)$/.test(f), -); - -for (const filePath of patchable) { +for (const filePath of walkFiles(layerchartDist).filter((f) => /\.(js|svelte|ts|mjs)$/.test(f))) { const content = readFileSync(filePath, "utf8"); if (!content.includes(".svelte")) continue; const patched = patchSvelteImports(content); - if (patched !== content) { - writeFileSync(filePath, patched); - } + if (patched !== content) writeFileSync(filePath, patched); } writeFileSync(stampPath, stampContent); - -console.log( - `compile-layerchart-vendor: compiled ${svelteFiles.length} Svelte files`, -); +console.log(`compile-layerchart-vendor: compiled ${svelteFiles.length} Svelte files`); diff --git a/scripts/copy-ort-wasm-assets.mjs b/scripts/copy-ort-wasm-assets.mjs index a5345bb7..9fc172b9 100644 --- a/scripts/copy-ort-wasm-assets.mjs +++ b/scripts/copy-ort-wasm-assets.mjs @@ -1,15 +1,12 @@ -import { copyFileSync, mkdirSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const transformersDist = join( - root, - "node_modules", - "@huggingface", - "transformers", - "dist", -); +const require = createRequire(import.meta.url); + +const transformersDist = dirname(require.resolve("@huggingface/transformers")); const outDir = join(root, "src", "public", "resources", "ort"); mkdirSync(outDir, { recursive: true }); @@ -20,5 +17,12 @@ const ortFiles = [ ]; for (const file of ortFiles) { - copyFileSync(join(transformersDist, file), join(outDir, file)); + const src = join(transformersDist, file); + if (!existsSync(src)) { + throw new Error( + `Missing ONNX Runtime WASM asset: ${src}\n` + + "Ensure @huggingface/transformers is installed (direct dependency).", + ); + } + copyFileSync(src, join(outDir, file)); } diff --git a/scripts/package-extension-zips.mjs b/scripts/package-extension-zips.mjs index 0342a26b..015d96fc 100644 --- a/scripts/package-extension-zips.mjs +++ b/scripts/package-extension-zips.mjs @@ -1,15 +1,8 @@ /** - * Package Chrome/Firefox build folders into zip files Windows Explorer can open. - * Git Bash `tar -a` on CI often produces zips that fail to unzip on Windows. + * Package Chrome/Firefox build folders into Windows-friendly zip files. */ import { execFileSync } from "node:child_process"; -import { - appendFileSync, - existsSync, - mkdirSync, - readFileSync, - unlinkSync, -} from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -18,29 +11,27 @@ const root = join(dirname(fileURLToPath(import.meta.url)), ".."); function zipDirectory(sourceRel, outRel) { const sourceDir = join(root, sourceRel); const outZip = join(root, outRel); - - if (!existsSync(sourceDir)) { - throw new Error(`Missing build output: ${sourceRel}`); - } + if (!existsSync(sourceDir)) throw new Error(`Missing build output: ${sourceRel}`); mkdirSync(dirname(outZip), { recursive: true }); - if (existsSync(outZip)) { - unlinkSync(outZip); - } + if (existsSync(outZip)) unlinkSync(outZip); if (process.platform === "win32") { - const ps = [ - "Add-Type -AssemblyName System.IO.Compression.FileSystem", - `[IO.Compression.ZipFile]::CreateFromDirectory('${sourceDir.replace(/'/g, "''")}', '${outZip.replace(/'/g, "''")}')`, - ].join("; "); - execFileSync("powershell", ["-NoProfile", "-Command", ps], { - stdio: "inherit", - }); + const esc = (s) => s.replace(/'/g, "''"); + execFileSync( + "powershell", + [ + "-NoProfile", + "-Command", + [ + "Add-Type -AssemblyName System.IO.Compression.FileSystem", + `[IO.Compression.ZipFile]::CreateFromDirectory('${esc(sourceDir)}', '${esc(outZip)}')`, + ].join("; "), + ], + { stdio: "inherit" }, + ); } else { - execFileSync("zip", ["-r", "-q", outZip, "."], { - cwd: sourceDir, - stdio: "inherit", - }); + execFileSync("zip", ["-r", "-q", outZip, "."], { cwd: sourceDir, stdio: "inherit" }); } } @@ -48,7 +39,6 @@ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); const version = process.argv[2] || pkg.version; const updateChannel = process.env.UPDATE_CHANNEL || "stable"; const buildLabel = process.env.BUILD_LABEL || ""; - const base = updateChannel === "nightly" && buildLabel ? `betterseqtaplus-nightly-${buildLabel}` @@ -59,13 +49,8 @@ const firefoxZip = `dist/${base}-firefox.zip`; zipDirectory("dist/chrome", chromeZip); zipDirectory("dist/firefox", firefoxZip); - -console.log(`Packaged ${chromeZip}`); -console.log(`Packaged ${firefoxZip}`); +console.log(`Packaged ${chromeZip}\nPackaged ${firefoxZip}`); if (process.env.GITHUB_OUTPUT) { - appendFileSync( - process.env.GITHUB_OUTPUT, - `chrome_zip=${chromeZip}\nfirefox_zip=${firefoxZip}\n`, - ); + appendFileSync(process.env.GITHUB_OUTPUT, `chrome_zip=${chromeZip}\nfirefox_zip=${firefoxZip}\n`); } diff --git a/src/css/injected.scss b/src/css/injected.scss index ffd35f06..d0efd9a8 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -94,6 +94,7 @@ select[size="1"] { appearance: none; -webkit-appearance: none; -moz-appearance: none; + color-scheme: light; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23999'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important; background-position: right 0.9rem center !important; background-repeat: no-repeat !important; @@ -101,16 +102,10 @@ select[size="1"] { padding-right: 2.6rem !important; } -html:not(.dark) select:not([multiple]):not([size]), -html:not(.dark) select[size="1"] { - color-scheme: light; -} - select::-ms-expand { display: none; } -/* OS option panels on Windows/Edge are often light even in dark mode */ select option { background-color: #ffffff !important; color: #18181b !important; @@ -123,8 +118,8 @@ select option { .dark select:not([multiple]):not([size]), .dark select[size="1"] { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23c9c9c9'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important; color-scheme: dark; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23c9c9c9'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important; } #container { background: var(--auto-background) !important; diff --git a/src/interface/components/Select.svelte b/src/interface/components/Select.svelte index c0d6d3f8..e88ad70d 100644 --- a/src/interface/components/Select.svelte +++ b/src/interface/components/Select.svelte @@ -11,20 +11,14 @@ let activeIndex = $state(0); let root: HTMLDivElement | undefined = $state(); let trigger: HTMLButtonElement | undefined = $state(); - let listbox: HTMLUListElement | undefined = $state(); + let listbox: HTMLDivElement | undefined = $state(); const selectedLabel = $derived( options.find((option) => option.value === value)?.label ?? value, ); - const selectedIndex = $derived( - options.findIndex((option) => option.value === value), - ); - const activeDescendantId = $derived( - isOpen && options[activeIndex] - ? optionId(options[activeIndex].value) - : undefined, + isOpen && options[activeIndex] ? optionId(options[activeIndex].value) : undefined, ); function optionId(optionValue: string): string { @@ -33,24 +27,13 @@ function openMenu(preferredIndex?: number) { isOpen = true; - activeIndex = - preferredIndex ?? - (selectedIndex >= 0 ? selectedIndex : 0); + const selectedIndex = options.findIndex((option) => option.value === value); + activeIndex = preferredIndex ?? (selectedIndex >= 0 ? selectedIndex : 0); } function closeMenu(returnFocus = true) { isOpen = false; - if (returnFocus) { - trigger?.focus(); - } - } - - function toggleOpen() { - if (isOpen) { - closeMenu(); - } else { - openMenu(); - } + if (returnFocus) trigger?.focus(); } function selectValue(nextValue: string) { @@ -58,41 +41,27 @@ closeMenu(); } - function selectActive() { - const option = options[activeIndex]; - if (option) { - selectValue(option.value); - } - } - function moveActive(delta: number) { if (!options.length) return; activeIndex = (activeIndex + delta + options.length) % options.length; } - function onTriggerKeydown(event: KeyboardEvent) { + function handleKeydown(event: KeyboardEvent, inListbox = false) { switch (event.key) { case "ArrowDown": + case "ArrowUp": { event.preventDefault(); - if (isOpen) { - moveActive(1); - } else { - openMenu(); - } - break; - case "ArrowUp": - event.preventDefault(); - if (isOpen) { - moveActive(-1); - } else { - openMenu(); - } + const delta = event.key === "ArrowDown" ? 1 : -1; + if (isOpen || inListbox) moveActive(delta); + else openMenu(); break; + } case "Enter": case " ": event.preventDefault(); if (isOpen) { - selectActive(); + const option = options[activeIndex]; + if (option) selectValue(option.value); } else { openMenu(); } @@ -103,38 +72,20 @@ closeMenu(); } break; - } - } - - function onListboxKeydown(event: KeyboardEvent) { - switch (event.key) { - case "ArrowDown": - event.preventDefault(); - moveActive(1); - break; - case "ArrowUp": - event.preventDefault(); - moveActive(-1); - break; case "Home": - event.preventDefault(); - activeIndex = 0; + if (inListbox) { + event.preventDefault(); + activeIndex = 0; + } break; case "End": - event.preventDefault(); - activeIndex = Math.max(0, options.length - 1); - break; - case "Enter": - case " ": - event.preventDefault(); - selectActive(); - break; - case "Escape": - event.preventDefault(); - closeMenu(); + if (inListbox) { + event.preventDefault(); + activeIndex = Math.max(0, options.length - 1); + } break; case "Tab": - closeMenu(false); + if (inListbox) closeMenu(false); break; } } @@ -145,8 +96,7 @@ queueMicrotask(() => listbox?.focus()); const onPointerDown = (event: PointerEvent) => { - const path = event.composedPath(); - if (root && path.includes(root)) return; + if (root && event.composedPath().includes(root)) return; closeMenu(false); }; @@ -163,8 +113,8 @@ aria-haspopup="listbox" aria-expanded={isOpen} aria-controls={listboxId} - onclick={toggleOpen} - onkeydown={onTriggerKeydown} + onclick={() => (isOpen ? closeMenu() : openMenu())} + onkeydown={(event) => handleKeydown(event)} > {selectedLabel}
{/if}
@@ -239,14 +186,14 @@ box-shadow 180ms ease; } - .select-trigger:hover { + .select-trigger:hover, + .select-trigger:focus-visible { + outline: none; background: var(--theme-secondary, #e5e7eb); border-color: var(--theme-offset-bg, var(--theme-secondary, #d4d4d8)); } .select-trigger:focus-visible { - outline: none; - background: var(--theme-secondary, #e5e7eb); border-color: color-mix(in srgb, var(--text-primary) 22%, var(--theme-secondary, #e5e7eb) 78%); box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent); } @@ -268,9 +215,11 @@ left: 0; right: 0; z-index: 50; + display: flex; + flex-direction: column; + gap: 0.125rem; margin: 0; padding: 0.5rem; - list-style: none; border: 1px solid var(--theme-offset-bg, var(--theme-secondary, #e5e7eb)); border-radius: 14px; background: var(--theme-primary, #ffffff); @@ -279,9 +228,6 @@ 0 8px 10px -6px rgb(0 0 0 / 0.2); max-height: 18rem; overflow-y: auto; - display: flex; - flex-direction: column; - gap: 0.125rem; } .select-menu:focus-visible { @@ -310,12 +256,9 @@ .select-option:hover, .select-option:focus-visible, - .select-option.is-active { + .select-option.is-active, + .select-option.is-selected { outline: none; background: var(--theme-secondary, #e5e7eb); } - - .select-option.is-selected { - background: var(--theme-secondary, #e5e7eb); - } diff --git a/src/interface/components/icons/LucideMoon.svelte b/src/interface/components/icons/LucideMoon.svelte index d4101ce5..beb0973d 100644 --- a/src/interface/components/icons/LucideMoon.svelte +++ b/src/interface/components/icons/LucideMoon.svelte @@ -1,4 +1,6 @@ @@ -9,5 +11,5 @@ class={className} aria-hidden="true" > - + diff --git a/src/interface/components/icons/LucideSun.svelte b/src/interface/components/icons/LucideSun.svelte index 84d30287..97a9cab0 100644 --- a/src/interface/components/icons/LucideSun.svelte +++ b/src/interface/components/icons/LucideSun.svelte @@ -1,4 +1,6 @@ @@ -9,5 +11,5 @@ class={className} aria-hidden="true" > - + diff --git a/src/interface/components/store/ThemeModal.svelte b/src/interface/components/store/ThemeModal.svelte index f1663644..130686af 100644 --- a/src/interface/components/store/ThemeModal.svelte +++ b/src/interface/components/store/ThemeModal.svelte @@ -1,978 +1,472 @@ - - -
{ - - if (e.target === e.currentTarget) hideModal() - - }} - - onkeydown={(e) => { - - if (e.target === e.currentTarget && e.key === 'Escape') hideModal() - - }} - - role="presentation" - - transition:fade - +class="flex fixed inset-0 z-50 justify-center items-end bg-black/70 backdrop-blur-sm" +onclick={(e) => { +if (e.target === e.currentTarget) hideModal() +}} +onkeydown={(e) => { +if (e.target === e.currentTarget && e.key === 'Escape') hideModal() +}} +role="presentation" +transition:fade > - - - -
e.stopPropagation()} - - onkeydown={(e) => e.stopPropagation()} - - role="dialog" - - aria-modal="true" - - tabindex="-1" - - > - - {#if theme} - -
- -
- - - -
- -
- -

- - {theme.name} - -

- - {#if theme.featured === true} - - - - - - - - - - Featured - - - - {/if} - -
- - {#if theme.author} - -

- - By {theme.author} - -

- - {/if} - -
- - - - - - - - - - {modalDisplayDownloadCount.toLocaleString()} downloads - - - - - - - - - - - - {(theme.favorite_count ?? 0).toLocaleString()} favorites - - - -
- - - - {#if heroSlides.length > 0} - - {#key theme?.id} - -
- -
- -
- - {#each heroSlides as slide, slideIdx (slideIdx)} - -
- - {slide.caption} - -
- - {/each} - -
- -
- - {#if heroSlides.length > 1} - -
- - - - - -
- - {/if} - -
- - {/key} - - {/if} - - - - {#if hasFlavours} - - {@const masterThumb = masterCarouselImageUrl(theme)} - -

Variants

- -
- - {#if currentThemes.includes(theme.id)} - - +
+
+

+{theme.name} +

+{#if theme.featured === true} + + + + +Featured + +{/if} +
+{#if theme.author} +

+By {theme.author} +

+{/if} +
+ + + + +{modalDisplayDownloadCount.toLocaleString()} downloads + + + + + +{(theme.favorite_count ?? 0).toLocaleString()} favorites + +
+{#if heroSlides.length > 0} +{#key theme?.id} +
+
+
+{#each heroSlides as slide, slideIdx (slideIdx)} +
+{slide.caption} +
+{/each} +
+
+{#if heroSlides.length > 1} +
+ + +
+{/if} +
+{/key} +{/if} +{#if hasFlavours} +{@const masterThumb = masterCarouselImageUrl(theme)} +

Variants

+
+{#if currentThemes.includes(theme.id)} + - - {:else} - - +{:else} + - - {/if} - - {#each theme.flavours ?? [] as f, flavourIdx (f.id)} - - {@const thumb = flavourCarouselImageUrl(f)} - - {#if currentThemes.includes(f.id)} - - +{/if} +{#each theme.flavours ?? [] as f, flavourIdx (f.id)} +{@const thumb = flavourCarouselImageUrl(f)} +{#if currentThemes.includes(f.id)} + - - {:else} - - +{:else} + - - {/if} - - {/each} - -
- - {/if} - - - -

- - {theme.description} - -

- - - -
- - {#if toggleFavorite && theme} - - - - {/if} - - - - {#if !hasFlavours} - - {#if currentThemes.includes(theme.id)} - - - - {:else} - - - - {/if} - - {/if} - -
- - - - {#if relatedThemes.length > 0} - -
- - - -

- - Related themes - -

- -
- - {#each relatedThemes as relatedTheme (relatedTheme.id)} - - - - {/each} - -
- - {/if} - -
- - {:else} - -
- - - -
- - {/if} - -
- +
+{#if installingId === f.id} + + + +{/if} +{f.name} +
+ +{/if} +{/each} +
+{/if} +

+{theme.description} +

+
+{#if toggleFavorite && theme} + +{/if} +{#if !hasFlavours} +{#if currentThemes.includes(theme.id)} + +{:else} + +{/if} +{/if} +
+{#if relatedThemes.length > 0} +
+

+Related themes +

+
+{#each relatedThemes as relatedTheme (relatedTheme.id)} + +{/each} +
+{/if} +
+{:else} +
+ +
+{/if} +
- - - diff --git a/src/interface/components/themes/BackgroundSelector.svelte b/src/interface/components/themes/BackgroundSelector.svelte index 7c7e7ef7..2be4c7f7 100644 --- a/src/interface/components/themes/BackgroundSelector.svelte +++ b/src/interface/components/themes/BackgroundSelector.svelte @@ -13,6 +13,10 @@ let imageBackgrounds = $derived(backgrounds.filter(bg => bg.type === 'image')); let videoBackgrounds = $derived(backgrounds.filter(bg => bg.type === 'video')); + function setError(e: unknown) { + error = e instanceof Error ? e.message : 'An unknown error occurred'; + } + async function getTheme() { return localStorage.getItem('selectedBackground'); } @@ -41,11 +45,7 @@ await writeData(fileId, fileType, blob); backgrounds = [...backgrounds, { id: fileId, type: fileType, blob, url: URL.createObjectURL(blob) }]; } catch (e) { - if (e instanceof Error) { - error = e.message; - } else { - error = 'An unknown error occurred'; - } + setError(e); } } @@ -78,11 +78,7 @@ selectNoBackground(); } } catch (e) { - if (e instanceof Error) { - error = e.message; - } else { - error = 'An unknown error occurred'; - } + setError(e); } } @@ -105,11 +101,7 @@ selectNoBackground(); } } catch (e) { - if (e instanceof Error) { - error = `Failed to delete background: ${e.message}`; - } else { - error = 'An unknown error occurred'; - } + error = e instanceof Error ? `Failed to delete background: ${e.message}` : 'An unknown error occurred'; } } diff --git a/src/interface/components/themes/ThemeBlobImage.svelte b/src/interface/components/themes/ThemeBlobImage.svelte index 4332a5b5..a7af43af 100644 --- a/src/interface/components/themes/ThemeBlobImage.svelte +++ b/src/interface/components/themes/ThemeBlobImage.svelte @@ -1,11 +1,7 @@ {#if src} - {alt} + {/if} diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index 4bf6273c..fe69e291 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -315,40 +315,36 @@

Maximum Subjects

Number of subjects to include, ordered by soonest due date

-
- (settingsState.homeUpcomingSubjectsMax = Number(value))} + options={[ + { value: "0", label: "All" }, + { value: "3", label: "3" }, + { value: "5", label: "5" }, + { value: "7", label: "7" }, + { value: "10", label: "10" }, + { value: "15", label: "15" }, + ]} + />

Maximum Assessments per Subject

Assessments shown for each included subject

-
- (settingsState.homeUpcomingAssessmentsPerSubjectMax = Number(value))} + options={[ + { value: "0", label: "All" }, + { value: "1", label: "1" }, + { value: "2", label: "2" }, + { value: "3", label: "3" }, + { value: "5", label: "5" }, + { value: "10", label: "10" }, + ]} + />
diff --git a/src/interface/pages/themeCreator.svelte b/src/interface/pages/themeCreator.svelte index d9a49b46..10c5b2bd 100644 --- a/src/interface/pages/themeCreator.svelte +++ b/src/interface/pages/themeCreator.svelte @@ -76,26 +76,17 @@ await themeManager.disableTheme(); if (themeID) { - const tempTheme = await themeManager.getTheme(themeID) - - if (!tempTheme) return - - // convert temptheme to LoadedCustomTheme - const loadedTheme = { - ...tempTheme, - CustomImages: tempTheme.CustomImages.map(image => ({ - ...image - })) - } + const tempTheme = await themeManager.getTheme(themeID); + if (!tempTheme) return; theme = { - ...loadedTheme, - adaptiveCssVariables: loadedTheme.adaptiveCssVariables ?? [], + ...tempTheme, + adaptiveCssVariables: tempTheme.adaptiveCssVariables ?? [], forceTheme: - loadedTheme.forceTheme ?? - (loadedTheme.forceDark !== undefined ? true : undefined), - } - themeLoaded = true + tempTheme.forceTheme ?? + (tempTheme.forceDark !== undefined ? true : undefined), + }; + themeLoaded = true; } else { themeLoaded = true } diff --git a/src/lib/icons/lucideMoon.ts b/src/lib/icons/lucideMoon.ts index b07ff999..c1040780 100644 --- a/src/lib/icons/lucideMoon.ts +++ b/src/lib/icons/lucideMoon.ts @@ -1,6 +1,6 @@ -/** - * Material "dark_mode" moon icon — filled style to match SEQTA menu bar icons. - */ -export const LUCIDE_MOON_ICON_SVG = ` - -`.trim(); +/** Material "dark_mode" moon icon — filled style to match SEQTA menu bar icons. */ +export const LUCIDE_MOON_PATH = + "M12,3C7.03,3 3,7.03 3,12C3,16.97 7.03,21 12,21C16.97,21 21,16.97 21,12C21,11.54 20.96,11.08 20.9,10.64C19.92,12.01 18.32,12.9 16.5,12.9C13.52,12.9 11.1,10.48 11.1,7.5C11.1,5.68 11.99,4.08 13.36,3.1C12.92,3.04 12.46,3 12,3Z"; + +export const LUCIDE_MOON_ICON_SVG = + ``; diff --git a/src/lib/icons/lucideSun.ts b/src/lib/icons/lucideSun.ts index 826c2c7f..543731e5 100644 --- a/src/lib/icons/lucideSun.ts +++ b/src/lib/icons/lucideSun.ts @@ -1,6 +1,6 @@ -/** - * Material "light_mode" sun icon — filled style to match SEQTA menu bar icons. - */ -export const LUCIDE_SUN_ICON_SVG = ` - -`.trim(); +/** Material "light_mode" sun icon — filled style to match SEQTA menu bar icons. */ +export const LUCIDE_SUN_PATH = + "M12,7C9.24,7 7,9.24 7,12C7,14.76 9.24,17 12,17C14.76,17 17,14.76 17,12C17,9.24 14.76,7 12,7M2,13H4C4.55,13 5,12.55 5,12C5,11.45 4.55,11 4,11H2C1.45,11 1,11.45 1,12C1,12.55 1.45,13 2,13M20,13H22C22.55,13 23,12.55 23,12C23,11.45 22.55,11 22,11H20C19.45,11 19,11.45 19,12C19,12.55 19.45,13 20,13M11,2V4C11,4.55 11.45,5 12,5C12.55,5 13,4.55 13,4V2C13,1.45 12.55,1 12,1C11.45,1 11,1.45 11,2M11,20V22C11,22.55 11.45,23 12,23C12.55,23 13,22.55 13,22V20C13,19.45 12.55,19 12,19C11.45,19 11,19.45 11,20M5.99,4.58C5.6,4.19 4.96,4.19 4.58,4.58C4.19,4.96 4.19,5.6 4.58,5.99L5.64,7.05C6.03,7.44 6.67,7.44 7.05,7.05C7.44,6.67 7.44,6.03 7.05,5.64L5.99,4.58M18.36,16.95C17.97,16.56 17.33,16.56 16.95,16.95C16.56,17.33 16.56,17.97 16.95,18.36L18.01,19.42C18.4,19.81 19.04,19.81 19.42,19.42C19.81,19.04 19.81,18.4 19.42,18.01L18.36,16.95M19.42,5.99C19.81,5.6 19.81,4.96 19.42,4.58C19.04,4.19 18.4,4.19 18.01,4.58L16.95,5.64C16.56,6.03 16.56,6.67 16.95,7.05C17.33,7.44 17.97,7.44 18.36,7.05L19.42,5.99M7.05,18.36C7.44,17.97 7.44,17.33 7.05,16.95C6.67,16.56 6.03,16.56 5.64,16.95L4.58,18.01C4.19,18.4 4.19,19.04 4.58,19.42C4.96,19.81 5.6,19.81 5.99,19.42L7.05,18.36Z"; + +export const LUCIDE_SUN_ICON_SVG = + ``; diff --git a/src/plugins/built-in/animatedBackground/backgroundLayers.ts b/src/plugins/built-in/animatedBackground/backgroundLayers.ts index 35d30a7d..59dd1c15 100644 --- a/src/plugins/built-in/animatedBackground/backgroundLayers.ts +++ b/src/plugins/built-in/animatedBackground/backgroundLayers.ts @@ -9,32 +9,26 @@ const LAYER_CLASSES = [ ["bg", "bg3", ANIMATED_BG_MARKER], ] as const; +const layerSelector = `:scope > div.bg.${ANIMATED_BG_MARKER}`; + export function updateAnimationSpeed(speed: number) { - const bgElements = document.querySelectorAll(`.bg.${ANIMATED_BG_MARKER}`); - Array.from(bgElements).forEach((element, index) => { + document.querySelectorAll(`.bg.${ANIMATED_BG_MARKER}`).forEach((element, index) => { const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5; (element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`; }); } -function countAnimatedLayers(container: HTMLElement): number { - return container.querySelectorAll(`:scope > div.bg.${ANIMATED_BG_MARKER}`).length; -} - export function ensureAnimatedBackgroundLayers( container: HTMLElement, menu: HTMLElement, speed: number, ): void { - const count = countAnimatedLayers(container); - if (count >= 3) { + if (container.querySelectorAll(layerSelector).length >= 3) { updateAnimationSpeed(speed); return; } - container - .querySelectorAll(`:scope > div.bg.${ANIMATED_BG_MARKER}`) - .forEach((el) => el.remove()); + container.querySelectorAll(layerSelector).forEach((el) => el.remove()); for (const classes of LAYER_CLASSES) { const bk = document.createElement("div"); @@ -46,9 +40,7 @@ export function ensureAnimatedBackgroundLayers( } export function removeAnimatedBackgroundLayers(): void { - document - .querySelectorAll(`div.bg.${ANIMATED_BG_MARKER}`) - .forEach((el) => el.remove()); + document.querySelectorAll(`div.bg.${ANIMATED_BG_MARKER}`).forEach((el) => el.remove()); } export async function syncAnimatedBackground( diff --git a/src/plugins/built-in/animatedBackground/index.ts b/src/plugins/built-in/animatedBackground/index.ts index ed8abfb3..2172774a 100644 --- a/src/plugins/built-in/animatedBackground/index.ts +++ b/src/plugins/built-in/animatedBackground/index.ts @@ -29,6 +29,7 @@ class AnimatedBackgroundPluginClass extends BasePlugin { } const instance = new AnimatedBackgroundPluginClass(); +const resync = (api: PluginAPI) => () => void syncAnimatedBackground(api); const animatedBackgroundPlugin: Plugin = { id: "animated-background", @@ -43,23 +44,15 @@ const animatedBackgroundPlugin: Plugin = { await syncAnimatedBackground(api); const speedUnregister = api.settings.onChange("speed", updateAnimationSpeed); - - const pageChangeUnregister = api.seqta.onPageChange(() => { - void syncAnimatedBackground(api); - }); - + const pageChangeUnregister = api.seqta.onPageChange(resync(api)); const pageshowHandler = (event: PageTransitionEvent) => { if (event.persisted) void syncAnimatedBackground(api); }; window.addEventListener("pageshow", pageshowHandler); - const containerObserver = new MutationObserver(() => { - void syncAnimatedBackground(api); - }); + const containerObserver = new MutationObserver(resync(api)); const container = document.getElementById("container"); - if (container) { - containerObserver.observe(container, { childList: true }); - } + if (container) containerObserver.observe(container, { childList: true }); return () => { speedUnregister.unregister(); diff --git a/src/plugins/built-in/backgroundMusic/index.ts b/src/plugins/built-in/backgroundMusic/index.ts index 7e573f99..1b4cb711 100644 --- a/src/plugins/built-in/backgroundMusic/index.ts +++ b/src/plugins/built-in/backgroundMusic/index.ts @@ -36,132 +36,107 @@ const store = localforage.createInstance({ storeName: "music", }); -const HINT_ID = "bsplus-bg-music-hint"; +const GESTURE_EVENTS = ["pointerdown", "keydown", "touchstart"] as const; +const gestureOpts: AddEventListenerOptions = { capture: true, passive: true }; -let currentAudio: HTMLAudioElement | null = null; -let currentObjectUrl: string | null = null; -let pendingGestureCancel: (() => void) | null = null; -let visibilityResumeTimeout: number | null = null; -let hintElement: HTMLElement | null = null; -let isPlaying = false; +let audio: HTMLAudioElement | null = null; +let objectUrl: string | null = null; +let gestureCleanup: (() => void) | null = null; +let resumeTimer: ReturnType | null = null; +let hintEl: HTMLElement | null = null; +let playing = false; -async function loadAudioBlob(): Promise { +const clamp = (v: number) => Math.max(0, Math.min(1, v)); + +async function loadBlob(): Promise { const blob = await store.getItem("audio-blob"); - return blob && blob instanceof Blob ? blob : null; + return blob instanceof Blob ? blob : null; } -function stopAndCleanupAudio(): void { - if (currentAudio) { - currentAudio.pause(); - currentAudio.src = ""; - currentAudio.remove(); - currentAudio = null; - } - if (currentObjectUrl) { - URL.revokeObjectURL(currentObjectUrl); - currentObjectUrl = null; - } - isPlaying = false; +function clearHint(): void { + hintEl?.remove(); + hintEl = null; } -function hideAutoplayHint(): void { - if (hintElement) { - hintElement.remove(); - hintElement = null; - } +function disarmGesture(): void { + gestureCleanup?.(); + gestureCleanup = null; } -function showAutoplayHint(onActivate: () => void): void { - hideAutoplayHint(); +function onPlayStarted(): void { + playing = true; + clearHint(); + disarmGesture(); +} + +function stopAudio(): void { + audio?.pause(); + audio?.remove(); + audio = null; + if (objectUrl) URL.revokeObjectURL(objectUrl); + objectUrl = null; + playing = false; +} + +function showHint(onActivate: () => void): void { + clearHint(); const hint = document.createElement("button"); - hint.id = HINT_ID; + hint.id = "bsplus-bg-music-hint"; hint.type = "button"; hint.className = "bsplus-bg-music-hint"; hint.textContent = "Tap to start background music"; - hint.addEventListener("pointerdown", (event) => { - event.preventDefault(); + hint.addEventListener("pointerdown", (e) => { + e.preventDefault(); onActivate(); }); - document.body.appendChild(hint); - hintElement = hint; -} - -function disarmGesturePlayback(): void { - if (pendingGestureCancel) { - pendingGestureCancel(); - pendingGestureCancel = null; - } + document.body.append(hint); + hintEl = hint; } /** Prepare