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} diff --git a/src/plugins/built-in/globalSearch/src/utils/hotkeyUtils.ts b/src/plugins/built-in/globalSearch/src/utils/hotkeyUtils.ts index 884f51d5..d22660d6 100644 --- a/src/plugins/built-in/globalSearch/src/utils/hotkeyUtils.ts +++ b/src/plugins/built-in/globalSearch/src/utils/hotkeyUtils.ts @@ -1,3 +1,7 @@ +export function getDefaultSearchHotkey(): string { + return navigator.platform.toUpperCase().includes("MAC") ? "cmd+k" : "ctrl+k"; +} + export interface ParsedHotkey { ctrl: boolean; meta: boolean; diff --git a/src/plugins/built-in/globalSearch/src/utils/versionCheck.ts b/src/plugins/built-in/globalSearch/src/utils/versionCheck.ts index 37618793..6bef9dba 100644 --- a/src/plugins/built-in/globalSearch/src/utils/versionCheck.ts +++ b/src/plugins/built-in/globalSearch/src/utils/versionCheck.ts @@ -1,13 +1,15 @@ import browser from "webextension-polyfill"; import { resetSearchIndexes } from "../indexing/resetIndexes"; +import { verboseDebug, verboseLog } from "@/utils/verboseLog"; -import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; const VERSION_STORAGE_KEY = "betterseqta-global-search-version"; const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version"; -/** - * Gets the current extension version from the manifest - */ +const isAssetLoadError = (e: unknown) => { + const msg = (e as { message?: string })?.message ?? ""; + return msg.includes("preload CSS") || msg.includes("MIME type"); +}; + export function getCurrentVersion(): string { try { return browser.runtime.getManifest().version; @@ -17,9 +19,6 @@ export function getCurrentVersion(): string { } } -/** - * Gets the last stored version from localStorage - */ export function getStoredVersion(): string | null { try { return localStorage.getItem(VERSION_STORAGE_KEY); @@ -29,9 +28,6 @@ export function getStoredVersion(): string | null { } } -/** - * Stores the current version in localStorage - */ export function storeVersion(version: string): void { try { localStorage.setItem(VERSION_STORAGE_KEY, version); @@ -43,34 +39,19 @@ export function storeVersion(version: string): void { /** * Checks if the extension has been updated and clears caches + resets the - * search index if needed. - * - * The reset is intentionally aggressive: every manifest version bump - * triggers a full IndexedDB wipe so changes to indexer extraction logic, - * job sets, or item shape can never serve stale results from an older - * build. The next indexing pass will repopulate from scratch in the - * background. Re-population is bounded by the per-job rate limits in - * `api.ts` so it can't hammer SEQTA after an update. - * - * Returns true if an update was detected. + * search index if needed. Returns true if an update was detected. */ export async function checkAndHandleUpdate(): Promise { const currentVersion = getCurrentVersion(); const storedVersion = getStoredVersion(); - // First run: just remember the version, don't reset (the user likely - // just installed the extension; the index is already empty). if (!storedVersion) { - verboseDebug( - `[Version Check] First run detected, storing version ${currentVersion}`, - ); + verboseDebug(`[Version Check] First run detected, storing version ${currentVersion}`); storeVersion(currentVersion); return false; } - if (storedVersion === currentVersion) { - return false; - } + if (storedVersion === currentVersion) return false; verboseLog( `[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, resetting search index...`, @@ -80,57 +61,40 @@ export async function checkAndHandleUpdate(): Promise { try { await resetSearchIndexes(); - verboseLog( - "[Version Check] Search index reset; next indexing pass will repopulate from scratch.", - ); + verboseLog("[Version Check] Search index reset; next indexing pass will repopulate from scratch."); } catch (e) { console.warn("[Version Check] resetSearchIndexes failed:", e); } storeVersion(currentVersion); - return true; } -/** - * Clears all search-related caches - */ export async function clearAllCaches(): Promise { try { - // Clear search result cache (in-memory Map) - if (typeof window !== 'undefined') { - // Dispatch event to clear caches in other modules - window.dispatchEvent(new CustomEvent('betterseqta-clear-search-cache')); - window.dispatchEvent(new CustomEvent('betterseqta-clear-embedding-cache')); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("betterseqta-clear-search-cache")); + window.dispatchEvent(new CustomEvent("betterseqta-clear-embedding-cache")); } - - // Also try to directly clear caches if modules are already loaded - // Use setTimeout to avoid blocking and handle CSS preload errors + setTimeout(async () => { try { const { clearSearchCache } = await import("../search/searchUtils"); clearSearchCache(); - } 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")) { - verboseDebug("[Version Check] Could not clear search cache:", e); - } + } catch (e) { + if (!isAssetLoadError(e)) verboseDebug("[Version Check] Could not clear search cache:", e); } - + try { const { clearEmbeddingCache } = await import("../search/vector/vectorSearch"); clearEmbeddingCache(); - } 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")) { - verboseDebug("[Version Check] Could not clear embedding cache:", e); - } + } catch (e) { + if (!isAssetLoadError(e)) verboseDebug("[Version Check] Could not clear embedding cache:", e); } }, 50); - + verboseDebug("[Version Check] All caches cleared"); } catch (e) { console.error("[Version Check] Error clearing caches:", e); } } - diff --git a/src/plugins/built-in/gradeAnalytics/core/index.ts b/src/plugins/built-in/gradeAnalytics/core/index.ts index 9b8c1b7c..dbd382ec 100644 --- a/src/plugins/built-in/gradeAnalytics/core/index.ts +++ b/src/plugins/built-in/gradeAnalytics/core/index.ts @@ -12,14 +12,11 @@ import { MenuOptionsOpen, } from "@/seqta/utils/Openers/OpenMenuOptions"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; -import { - applyMenuItemVisibility, -} from "@/seqta/utils/menuItemVisibility"; +import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility"; import { loadAnalyticsPage } from "../loadAnalyticsPage"; import styles from "../styles.css?inline"; const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics; - const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item"; const gradeAnalyticsPlugin: Plugin<{}> = { @@ -48,30 +45,21 @@ const gradeAnalyticsPlugin: Plugin<{}> = { analyticsItem.dataset.betterseqta = "true"; analyticsItem.innerHTML = ``; - const placeAnalyticsItem = () => { + const syncAnalyticsMenu = () => { insertMenuItemAfterKey(menuList, analyticsItem, "courses"); + ensureAnalyticsMenuOrder(); + if (settingsState.menuorder.length > 0) { + ChangeMenuItemPositions(settingsState.menuorder); + } + processMenuItemNode(analyticsItem); + applyMenuItemVisibility(); }; - placeAnalyticsItem(); - ensureAnalyticsMenuOrder(); - if (settingsState.menuorder.length > 0) { - ChangeMenuItemPositions(settingsState.menuorder); - } - - processMenuItemNode(analyticsItem); - applyMenuItemVisibility(); + syncAnalyticsMenu(); const menuObserver = new MutationObserver(() => { - if (MenuOptionsOpen) return; - if (!menuList.contains(analyticsItem)) { - placeAnalyticsItem(); - ensureAnalyticsMenuOrder(); - if (settingsState.menuorder.length > 0) { - ChangeMenuItemPositions(settingsState.menuorder); - } - processMenuItemNode(analyticsItem); - applyMenuItemVisibility(); - } + if (MenuOptionsOpen || menuList.contains(analyticsItem)) return; + syncAnalyticsMenu(); }); menuObserver.observe(menuList, { childList: true }); diff --git a/src/plugins/built-in/gradeAnalytics/ui.ts b/src/plugins/built-in/gradeAnalytics/ui.ts index cf320d70..507a3773 100644 --- a/src/plugins/built-in/gradeAnalytics/ui.ts +++ b/src/plugins/built-in/gradeAnalytics/ui.ts @@ -89,11 +89,10 @@ function syncThemeFromPage(target: HTMLElement) { const computed = getComputedStyle(document.documentElement); for (const name of THEME_CSS_VARS) { - let value = computed.getPropertyValue(name).trim(); - value = document.documentElement.style.getPropertyValue(name).trim(); - if (value) { - target.style.setProperty(name, value); - } + const value = + document.documentElement.style.getPropertyValue(name).trim() || + computed.getPropertyValue(name).trim(); + if (value) target.style.setProperty(name, value); } const accent = resolvePageAccentColor(); @@ -113,11 +112,7 @@ function syncThemeFromPage(target: HTMLElement) { target.style.setProperty("--better-main", palette.accent); target.style.setProperty("--bsplus-theme-btn-primary-bg", palette.accent); target.style.setProperty("--bsplus-theme-btn-primary-color", palette.onAccent); - - target.classList.toggle( - "dark", - document.documentElement.classList.contains("dark"), - ); + target.classList.toggle("dark", document.documentElement.classList.contains("dark")); } function syncThemeToAnalyticsUi() { diff --git a/src/plugins/built-in/themes/theme-manager.ts b/src/plugins/built-in/themes/theme-manager.ts index 9063bd16..106ce06d 100644 --- a/src/plugins/built-in/themes/theme-manager.ts +++ b/src/plugins/built-in/themes/theme-manager.ts @@ -32,6 +32,11 @@ import { validateThemeDom, validateThemeScript, } from "./theme-runtime"; +import { + base64ToBlob, + blobToBase64Data, + stripBase64Prefix, +} from "./themeImageUrl"; type ThemeContent = { id: string; @@ -652,10 +657,10 @@ export class ThemeManager { let coverImageBlob = null; if (themeData.coverImage) { try { - const strippedCoverImage = this.stripBase64Prefix( - themeData.coverImage, + coverImageBlob = base64ToBlob( + stripBase64Prefix(themeData.coverImage), + "image/png", ); - coverImageBlob = this.base64ToBlob(strippedCoverImage); } catch (e) { console.warn("[ThemeManager] Failed to process cover image:", e); // Continue without cover image @@ -673,7 +678,7 @@ export class ThemeManager { } return { ...image, - blob: this.base64ToBlob(this.stripBase64Prefix(image.data)), + blob: base64ToBlob(stripBase64Prefix(image.data), "image/png"), }; } catch (e) { console.warn("[ThemeManager] Failed to process image:", e); @@ -858,13 +863,13 @@ export class ThemeManager { CustomImages.map(async (image) => ({ id: image.id, variableName: image.variableName, - data: await this.blobToBase64(image.blob), + data: await blobToBase64Data(image.blob), })), ); // Convert cover image to base64 const coverImageBase64 = coverImage - ? await this.blobToBase64(coverImage) + ? await blobToBase64Data(coverImage) : null; // Create shareable theme data with only necessary fields @@ -1044,51 +1049,6 @@ export class ThemeManager { } } - // Utility methods - private stripBase64Prefix(base64String: string): string { - if (!base64String) return ""; - - const prefixRegex = /^data:[^;]+;base64,/; - try { - return prefixRegex.test(base64String) - ? base64String.replace(prefixRegex, "") - : base64String; - } catch (err) { - console.error("[ThemeManager] Error stripping base64 prefix:", err); - return ""; - } - } - - private base64ToBlob(base64: string): Blob { - try { - const byteString = atob(base64); - const ab = new ArrayBuffer(byteString.length); - const ia = new Uint8Array(ab); - - for (let i = 0; i < byteString.length; i++) { - ia[i] = byteString.charCodeAt(i); - } - - return new Blob([ab], { type: "image/png" }); - } catch (err) { - console.error("[ThemeManager] Error converting base64 to blob:", err); - return new Blob(); - } - } - - private async blobToBase64(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => { - const base64String = reader.result as string; - const base64Data = base64String.split(",")[1]; - resolve(base64Data); - }; - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - } - private saveThemeFile(data: object, fileName: string): void { try { const fileData = JSON.stringify(data, null, 2); diff --git a/src/plugins/built-in/themes/themeImageUrl.ts b/src/plugins/built-in/themes/themeImageUrl.ts index 81aba7f2..85848b12 100644 --- a/src/plugins/built-in/themes/themeImageUrl.ts +++ b/src/plugins/built-in/themes/themeImageUrl.ts @@ -3,15 +3,16 @@ * blob: URLs are tied to the origin where createObjectURL ran (page), while * settings UI runs in extension shadow DOM (moz-extension://). */ +import base64ToBlob from "@/seqta/utils/base64ToBlob"; + +export { base64ToBlob }; + export function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => { - if (typeof reader.result === "string") { - resolve(reader.result); - } else { - reject(new Error("FileReader did not return a string")); - } + if (typeof reader.result === "string") resolve(reader.result); + else reject(new Error("FileReader did not return a string")); }; reader.onerror = () => reject(reader.error ?? new Error("FileReader failed")); @@ -27,12 +28,7 @@ export function blobToBase64Data(blob: Blob): Promise { }); } -export function themeCssUrlValue(url: string): string { - return `url("${url.replace(/"/g, "%22")}")`; -} - -export function releaseThemeImageUrl(url: string): void { - if (url.startsWith("blob:")) { - URL.revokeObjectURL(url); - } +export function stripBase64Prefix(base64String: string): string { + if (!base64String) return ""; + return base64String.replace(/^data:[^;]+;base64,/, ""); } diff --git a/src/plugins/built-in/timetable/index.ts b/src/plugins/built-in/timetable/index.ts index 88e089d1..d761c91b 100644 --- a/src/plugins/built-in/timetable/index.ts +++ b/src/plugins/built-in/timetable/index.ts @@ -1,6 +1,6 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import type { Plugin } from "../../core/types"; -import { attachTimetableColorisRecovery } from "@/seqta/utils/timetableColoris"; +import { attachTimetableColorisRecovery } from "@/seqta/utils/patchSeqtaMenuUpdateColours"; import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat"; import { waitForElm } from "@/seqta/utils/waitForElm"; import { verboseLog } from "@/utils/verboseLog"; diff --git a/src/plugins/built-in/timetableEdit/index.ts b/src/plugins/built-in/timetableEdit/index.ts index f2ad0bc3..3c9b7621 100644 --- a/src/plugins/built-in/timetableEdit/index.ts +++ b/src/plugins/built-in/timetableEdit/index.ts @@ -250,13 +250,16 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = { if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff; } - const captureClick = () => { - lastClickedCi = ci; - lastClickedEntry = { roomEl, teacherEl, item }; - lastSyncedQuickbarCi = null; - scheduleQuickbarSync(); - }; - entry.addEventListener("click", captureClick, true); + entry.addEventListener( + "click", + () => { + lastClickedCi = ci; + lastClickedEntry = { roomEl, teacherEl, item }; + lastSyncedQuickbarCi = null; + scheduleQuickbarSync(); + }, + true, + ); }; const processAllEntries = () => { @@ -266,9 +269,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = { }; const getVisibleClassQuickbar = (): HTMLElement | null => { - const quickbar = document.querySelector( - ".timetablepage .quickbar.below.visible, .timetablepage .quickbar.above.visible, .timetablepage .quickbar.visible", - ); + const quickbar = document.querySelector(".timetablepage .quickbar.visible"); if (!quickbar || quickbar.getAttribute("data-type") !== "class") return null; return quickbar as HTMLElement; }; diff --git a/src/plugins/core/dynamicLoader.ts b/src/plugins/core/dynamicLoader.ts index 665d1ccb..df0cf166 100644 --- a/src/plugins/core/dynamicLoader.ts +++ b/src/plugins/core/dynamicLoader.ts @@ -1,9 +1,6 @@ import type { Plugin, PluginSettings } from "./types"; import { verboseInfo } from "@/utils/verboseLog"; -/** - * Interface for lazy-loaded plugin definitions - */ export interface LazyPlugin { id: string; name: string; @@ -14,70 +11,45 @@ export interface LazyPlugin disableToggle?: boolean; defaultEnabled?: boolean; beta?: boolean; - - // Instead of a run function, we have a loader that imports the actual plugin loader: () => Promise<{ default: Plugin }>; } -/** - * Converts a lazy plugin into a regular plugin by wrapping the run function - * with dynamic import logic - */ +const ASSET_LOAD_ERRORS = ["MIME type", "NS_ERROR_CORRUPTED_CONTENT", "preload CSS"]; + +function isAssetLoadError(error: unknown): boolean { + const msg = (error as { message?: string })?.message ?? ""; + return ASSET_LOAD_ERRORS.some((token) => msg.includes(token)); +} + export function createLazyPlugin( - lazyPlugin: LazyPlugin + lazyPlugin: LazyPlugin, ): Plugin { + const { loader, ...meta } = lazyPlugin; return { - id: lazyPlugin.id, - name: lazyPlugin.name, - description: lazyPlugin.description, - version: lazyPlugin.version, - settings: lazyPlugin.settings, - styles: lazyPlugin.styles, - disableToggle: lazyPlugin.disableToggle, - defaultEnabled: lazyPlugin.defaultEnabled, - beta: lazyPlugin.beta, - + ...meta, run: async (api) => { verboseInfo(`[BetterSEQTA+] Dynamically loading plugin "${lazyPlugin.id}"...`); - try { - // Dynamically import the actual plugin implementation - const { default: actualPlugin } = await lazyPlugin.loader(); - + const { default: actualPlugin } = await loader(); verboseInfo(`[BetterSEQTA+] Successfully loaded plugin "${lazyPlugin.id}"`); - - // Execute the actual plugin's run function return await actualPlugin.run(api); - } catch (error: any) { - const msg = error?.message ?? ""; - // Handle content-script asset loading failures gracefully (Firefox MIME - // errors, CSS preload blocked on host page, corrupted chunks). - if ( - msg.includes("MIME type") || - msg.includes("NS_ERROR_CORRUPTED_CONTENT") || - msg.includes("preload CSS") - ) { + } catch (error) { + if (isAssetLoadError(error)) { console.error( - `[BetterSEQTA+] Failed to load plugin "${lazyPlugin.id}" due to module/asset loading restrictions. ` + - `This may be a build configuration issue. Error:`, - error + `[BetterSEQTA+] Failed to load plugin "${lazyPlugin.id}" due to module/asset loading restrictions:`, + error, ); - // Don't throw - allow the extension to continue functioning without this plugin return; } console.error(`[BetterSEQTA+] Failed to dynamically load plugin "${lazyPlugin.id}":`, error); throw error; } - } + }, }; } -/** - * Helper function to create a lazy plugin definition - */ export function defineLazyPlugin( - config: LazyPlugin + config: LazyPlugin, ): Plugin { return createLazyPlugin(config); } - diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index 3dffaa97..61134a0e 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -30,7 +30,7 @@ import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue"; import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes"; -import { attachTimetableColorisRecovery } from "@/seqta/utils/timetableColoris"; +import { attachTimetableColorisRecovery } from "@/seqta/utils/patchSeqtaMenuUpdateColours"; // JSON content import { observeMenuItemPosition } from "@/seqta/utils/sidebarMenuIcons"; diff --git a/src/seqta/ui/AddBetterSEQTAElements.ts b/src/seqta/ui/AddBetterSEQTAElements.ts index 8b55238f..c4bf9959 100644 --- a/src/seqta/ui/AddBetterSEQTAElements.ts +++ b/src/seqta/ui/AddBetterSEQTAElements.ts @@ -464,14 +464,11 @@ function GetLightDarkModeString() { } async function addDarkLightToggle(parent?: Element) { - 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!, @@ -508,8 +505,8 @@ async function addDarkLightToggle(parent?: Element) { const svgElement = lightDarkModeButtonElement.querySelector("svg")!; svgElement.innerHTML = settingsState.DarkMode - ? SUN_ICON_SVG - : MOON_ICON_SVG; + ? LUCIDE_SUN_ICON_SVG + : LUCIDE_MOON_ICON_SVG; darklightText!.innerText = GetLightDarkModeString(); }); } @@ -553,10 +550,7 @@ function scheduleSidebarAccessibilityUpdate() { cancelAnimationFrame(sidebarTabOrderAnimationFrame); } - // Double rAF: SEQTA applies `.active` / updates `.sub` on the next frame - // after a click. Running earlier hid the submenu with `aria-hidden` while - // focus was still on a