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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-28 10:30:25 +09:30
parent dcb4dd2f5e
commit 4fda63dedd
76 changed files with 2003 additions and 4149 deletions
+6 -45
View File
@@ -1,59 +1,20 @@
// ref: https://stackoverflow.com/a/76920975 // ref: https://stackoverflow.com/a/76920975
import type { Plugin } from "vite"; import type { Plugin } from "vite";
/** /** Exit with code 1 on build failure; do not exit on success (multi-target builds). */
* 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.
*/
export default function ClosePlugin(): Plugin { export default function ClosePlugin(): Plugin {
return { return {
/** name: "ClosePlugin",
* 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.
*/
buildEnd(error) { buildEnd(error) {
if (error) { if (error) {
console.error("Error bundling"); console.error("Error bundling", error);
console.error(error); process.exit(1);
process.exit(1); // Exit with status 1 indicating an error
} else { } 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() { closeBundle() {
console.log("Bundle closed"); // Log successful closure of the bundle console.log("Bundle closed");
// Do not process.exit here — it can mask Vite render errors and break
// multi-target builds (`npm run build` runs chrome then firefox).
}, },
}; };
} }
+4 -15
View File
@@ -1,12 +1,6 @@
import type { Plugin } from "vite"; import type { Plugin } from "vite";
/** /** Relative chunk/CSS URLs via chrome.runtime.getURL for content-script dynamic imports. */
* 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 { export function extensionChunkUrls(): Plugin {
return { return {
name: "extension-chunk-urls", name: "extension-chunk-urls",
@@ -17,16 +11,11 @@ export function extensionChunkUrls(): Plugin {
renderBuiltUrl(filename, { hostType, type }) { renderBuiltUrl(filename, { hostType, type }) {
const path = filename.replace(/^\//, ""); const path = filename.replace(/^\//, "");
if (type === "chunk" && hostType === "js") { if (type === "chunk" && hostType === "js") {
return { return { runtime: `chrome.runtime.getURL(${JSON.stringify(path)})` };
runtime: `chrome.runtime.getURL(${JSON.stringify(path)})`,
};
} }
// Rewrite CSS preloads from JS dynamic imports (content scripts). // JS-triggered CSS preloads only — extension HTML pages need static hrefs.
// Do not rewrite hostType "css" — extension HTML pages need static hrefs.
if (type === "asset" && hostType === "js" && path.endsWith(".css")) { if (type === "asset" && hostType === "js" && path.endsWith(".css")) {
return { return { runtime: `chrome.runtime.getURL(${JSON.stringify(path)})` };
runtime: `chrome.runtime.getURL(${JSON.stringify(path)})`,
};
} }
}, },
}, },
+20 -59
View File
@@ -1,71 +1,32 @@
// vite-plugin-inline-worker-dev.ts
// vite-plugin-inline-worker-dev.ts
import { Plugin } from "vite"; import { Plugin } from "vite";
import fs from "fs/promises";
import { build } from "esbuild"; import { build } from "esbuild";
/** /** Bundle worker entry points imported with `?inlineWorker` as Blob-backed Workers in dev. */
* 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.
*/
export default function InlineWorkerDevPlugin(): Plugin { export default function InlineWorkerDevPlugin(): Plugin {
return { return {
/**
* The unique name of this Vite plugin.
* @type {string}
*/
name: "vite:inline-worker-dev", 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<string | null>} 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) { async load(id) {
if (id.includes("?inlineWorker")) { if (!id.includes("?inlineWorker")) return null;
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"],
});
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. const workerCode = result.outputFiles[0].text;
// This code is what gets returned to Vite and replaces the original import. return `
const workerBlobCode = ` const code = ${JSON.stringify(workerCode)};
const code = ${JSON.stringify(workerCode)}; export default function InlineWorker() {
export default function InlineWorker() { const blob = new Blob([code], { type: 'application/javascript' });
const blob = new Blob([code], { type: 'application/javascript' }); return new Worker(URL.createObjectURL(blob), { type: 'module' });
return new Worker(URL.createObjectURL(blob), { type: 'module' }); }
} `;
`;
return workerBlobCode;
}
return null; // Let Vite handle other modules normally
}, },
}; };
} }
+1
View File
@@ -101,6 +101,7 @@
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"d3-shape": "^3.2.0", "d3-shape": "^3.2.0",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"@huggingface/transformers": "^3.8.1",
"embeddia": "^1.3.0", "embeddia": "^1.3.0",
"embla-carousel-autoplay": "^8.5.2", "embla-carousel-autoplay": "^8.5.2",
"embla-carousel-svelte": "^8.5.2", "embla-carousel-svelte": "^8.5.2",
+22 -55
View File
@@ -1,15 +1,8 @@
/** /**
* layerchart ships raw `.svelte` sources in `dist/`. Vite/Svelte compilation is * Pre-compile layerchart `.svelte` sources to `.js` so Rollup/Vite CI builds succeed.
* unreliable for this package on CI (Rollup parses vendor sources as JS). Compile
* to plain `.js` at install/build time and rewrite internal imports.
*/ */
import { compile } from "svelte/compiler"; import { compile } from "svelte/compiler";
import { import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; 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 layerchartRoot = join(root, "node_modules", "layerchart");
const layerchartDist = join(layerchartRoot, "dist"); const layerchartDist = join(layerchartRoot, "dist");
const stampPath = join(layerchartDist, ".bsplus-compiled"); const stampPath = join(layerchartDist, ".bsplus-compiled");
const COMPILE_ALGO_VERSION = "2";
const importSuffixPattern = /\.svelte(?=['"])/g;
function exists(path) { const exists = (path) => {
try { try {
statSync(path); statSync(path);
return true; return true;
} catch { } catch {
return false; return false;
} }
} };
if (!exists(layerchartDist)) { if (!exists(layerchartDist)) {
console.log("compile-layerchart-vendor: layerchart not installed, skipping"); console.log("compile-layerchart-vendor: layerchart not installed, skipping");
process.exit(0); process.exit(0);
} }
const COMPILE_ALGO_VERSION = "2";
const layerchartVersion = JSON.parse( const layerchartVersion = JSON.parse(
readFileSync(join(layerchartRoot, "package.json"), "utf8"), readFileSync(join(layerchartRoot, "package.json"), "utf8"),
).version; ).version;
const stampContent = `${layerchartVersion}\n${COMPILE_ALGO_VERSION}`; const stampContent = `${layerchartVersion}\n${COMPILE_ALGO_VERSION}`;
if ( if (exists(stampPath) && readFileSync(stampPath, "utf8").trim() === stampContent) {
exists(stampPath) && console.log(`compile-layerchart-vendor: layerchart@${layerchartVersion} already compiled, skipping`);
readFileSync(stampPath, "utf8").trim() === stampContent
) {
console.log(
`compile-layerchart-vendor: layerchart@${layerchartVersion} already compiled, skipping`,
);
process.exit(0); process.exit(0);
} }
function walkFiles(dir, files = []) { const walkFiles = (dir, files = []) => {
for (const name of readdirSync(dir)) { for (const name of readdirSync(dir)) {
if (name === "node_modules") continue; if (name === "node_modules") continue;
const path = join(dir, name); const path = join(dir, name);
if (statSync(path).isDirectory()) { if (statSync(path).isDirectory()) walkFiles(path, files);
walkFiles(path, files); else files.push(path);
} else {
files.push(path);
}
} }
return files; return files;
} };
const importSuffixPattern = /\.svelte(?=['"])/g; const patchSvelteImports = (content) => content.replace(importSuffixPattern, ".js");
const stripRollupBreakingSyntax = (code) =>
function patchSvelteImports(content) { code
return content.replace(importSuffixPattern, ".js");
}
/** Rollup CJS resolver chokes on TS optional params (`name?`) in vendor `.js`. */
function stripRollupBreakingSyntax(code) {
return code
.replace(/(\w+)\?(?=\s*[,)\]])/g, "$1") .replace(/(\w+)\?(?=\s*[,)\]])/g, "$1")
.replace(/(\w+)\?(?=\s*:)/g, "$1"); .replace(/(\w+)\?(?=\s*:)/g, "$1");
}
const svelteFiles = walkFiles(layerchartDist).filter((f) => f.endsWith(".svelte")); const svelteFiles = walkFiles(layerchartDist).filter((f) => f.endsWith(".svelte"));
for (const sveltePath of svelteFiles) { for (const sveltePath of svelteFiles) {
const source = readFileSync(sveltePath, "utf8"); const source = readFileSync(sveltePath, "utf8");
if (!source.includes("<script")) continue; if (!source.includes("<script")) continue;
const compiled = compile(source, { const compiled = compile(source, {
filename: sveltePath, filename: sveltePath,
generate: "client", generate: "client",
css: "injected", css: "injected",
}); });
writeFileSync(
const jsPath = sveltePath.replace(/\.svelte$/, ".js"); sveltePath.replace(/\.svelte$/, ".js"),
const jsCode = stripRollupBreakingSyntax(patchSvelteImports(compiled.js.code)); stripRollupBreakingSyntax(patchSvelteImports(compiled.js.code)),
writeFileSync(jsPath, jsCode); );
} }
const patchable = walkFiles(layerchartDist).filter((f) => for (const filePath of walkFiles(layerchartDist).filter((f) => /\.(js|svelte|ts|mjs)$/.test(f))) {
/\.(js|svelte|ts|mjs)$/.test(f),
);
for (const filePath of patchable) {
const content = readFileSync(filePath, "utf8"); const content = readFileSync(filePath, "utf8");
if (!content.includes(".svelte")) continue; if (!content.includes(".svelte")) continue;
const patched = patchSvelteImports(content); const patched = patchSvelteImports(content);
if (patched !== content) { if (patched !== content) writeFileSync(filePath, patched);
writeFileSync(filePath, patched);
}
} }
writeFileSync(stampPath, stampContent); writeFileSync(stampPath, stampContent);
console.log(`compile-layerchart-vendor: compiled ${svelteFiles.length} Svelte files`);
console.log(
`compile-layerchart-vendor: compiled ${svelteFiles.length} Svelte files`,
);
+13 -9
View File
@@ -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 { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const transformersDist = join( const require = createRequire(import.meta.url);
root,
"node_modules", const transformersDist = dirname(require.resolve("@huggingface/transformers"));
"@huggingface",
"transformers",
"dist",
);
const outDir = join(root, "src", "public", "resources", "ort"); const outDir = join(root, "src", "public", "resources", "ort");
mkdirSync(outDir, { recursive: true }); mkdirSync(outDir, { recursive: true });
@@ -20,5 +17,12 @@ const ortFiles = [
]; ];
for (const file of 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));
} }
+20 -35
View File
@@ -1,15 +1,8 @@
/** /**
* Package Chrome/Firefox build folders into zip files Windows Explorer can open. * Package Chrome/Firefox build folders into Windows-friendly zip files.
* Git Bash `tar -a` on CI often produces zips that fail to unzip on Windows.
*/ */
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { import { appendFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
appendFileSync,
existsSync,
mkdirSync,
readFileSync,
unlinkSync,
} from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -18,29 +11,27 @@ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
function zipDirectory(sourceRel, outRel) { function zipDirectory(sourceRel, outRel) {
const sourceDir = join(root, sourceRel); const sourceDir = join(root, sourceRel);
const outZip = join(root, outRel); 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 }); mkdirSync(dirname(outZip), { recursive: true });
if (existsSync(outZip)) { if (existsSync(outZip)) unlinkSync(outZip);
unlinkSync(outZip);
}
if (process.platform === "win32") { if (process.platform === "win32") {
const ps = [ const esc = (s) => s.replace(/'/g, "''");
"Add-Type -AssemblyName System.IO.Compression.FileSystem", execFileSync(
`[IO.Compression.ZipFile]::CreateFromDirectory('${sourceDir.replace(/'/g, "''")}', '${outZip.replace(/'/g, "''")}')`, "powershell",
].join("; "); [
execFileSync("powershell", ["-NoProfile", "-Command", ps], { "-NoProfile",
stdio: "inherit", "-Command",
}); [
"Add-Type -AssemblyName System.IO.Compression.FileSystem",
`[IO.Compression.ZipFile]::CreateFromDirectory('${esc(sourceDir)}', '${esc(outZip)}')`,
].join("; "),
],
{ stdio: "inherit" },
);
} else { } else {
execFileSync("zip", ["-r", "-q", outZip, "."], { execFileSync("zip", ["-r", "-q", outZip, "."], { cwd: sourceDir, stdio: "inherit" });
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 version = process.argv[2] || pkg.version;
const updateChannel = process.env.UPDATE_CHANNEL || "stable"; const updateChannel = process.env.UPDATE_CHANNEL || "stable";
const buildLabel = process.env.BUILD_LABEL || ""; const buildLabel = process.env.BUILD_LABEL || "";
const base = const base =
updateChannel === "nightly" && buildLabel updateChannel === "nightly" && buildLabel
? `betterseqtaplus-nightly-${buildLabel}` ? `betterseqtaplus-nightly-${buildLabel}`
@@ -59,13 +49,8 @@ const firefoxZip = `dist/${base}-firefox.zip`;
zipDirectory("dist/chrome", chromeZip); zipDirectory("dist/chrome", chromeZip);
zipDirectory("dist/firefox", firefoxZip); zipDirectory("dist/firefox", firefoxZip);
console.log(`Packaged ${chromeZip}\nPackaged ${firefoxZip}`);
console.log(`Packaged ${chromeZip}`);
console.log(`Packaged ${firefoxZip}`);
if (process.env.GITHUB_OUTPUT) { if (process.env.GITHUB_OUTPUT) {
appendFileSync( appendFileSync(process.env.GITHUB_OUTPUT, `chrome_zip=${chromeZip}\nfirefox_zip=${firefoxZip}\n`);
process.env.GITHUB_OUTPUT,
`chrome_zip=${chromeZip}\nfirefox_zip=${firefoxZip}\n`,
);
} }
+2 -7
View File
@@ -94,6 +94,7 @@ select[size="1"] {
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
-moz-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-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-position: right 0.9rem center !important;
background-repeat: no-repeat !important; background-repeat: no-repeat !important;
@@ -101,16 +102,10 @@ select[size="1"] {
padding-right: 2.6rem !important; 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 { select::-ms-expand {
display: none; display: none;
} }
/* OS option panels on Windows/Edge are often light even in dark mode */
select option { select option {
background-color: #ffffff !important; background-color: #ffffff !important;
color: #18181b !important; color: #18181b !important;
@@ -123,8 +118,8 @@ select option {
.dark select:not([multiple]):not([size]), .dark select:not([multiple]):not([size]),
.dark select[size="1"] { .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; 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 { #container {
background: var(--auto-background) !important; background: var(--auto-background) !important;
+46 -103
View File
@@ -11,20 +11,14 @@
let activeIndex = $state(0); let activeIndex = $state(0);
let root: HTMLDivElement | undefined = $state(); let root: HTMLDivElement | undefined = $state();
let trigger: HTMLButtonElement | undefined = $state(); let trigger: HTMLButtonElement | undefined = $state();
let listbox: HTMLUListElement | undefined = $state(); let listbox: HTMLDivElement | undefined = $state();
const selectedLabel = $derived( const selectedLabel = $derived(
options.find((option) => option.value === value)?.label ?? value, options.find((option) => option.value === value)?.label ?? value,
); );
const selectedIndex = $derived(
options.findIndex((option) => option.value === value),
);
const activeDescendantId = $derived( const activeDescendantId = $derived(
isOpen && options[activeIndex] isOpen && options[activeIndex] ? optionId(options[activeIndex].value) : undefined,
? optionId(options[activeIndex].value)
: undefined,
); );
function optionId(optionValue: string): string { function optionId(optionValue: string): string {
@@ -33,24 +27,13 @@
function openMenu(preferredIndex?: number) { function openMenu(preferredIndex?: number) {
isOpen = true; isOpen = true;
activeIndex = const selectedIndex = options.findIndex((option) => option.value === value);
preferredIndex ?? activeIndex = preferredIndex ?? (selectedIndex >= 0 ? selectedIndex : 0);
(selectedIndex >= 0 ? selectedIndex : 0);
} }
function closeMenu(returnFocus = true) { function closeMenu(returnFocus = true) {
isOpen = false; isOpen = false;
if (returnFocus) { if (returnFocus) trigger?.focus();
trigger?.focus();
}
}
function toggleOpen() {
if (isOpen) {
closeMenu();
} else {
openMenu();
}
} }
function selectValue(nextValue: string) { function selectValue(nextValue: string) {
@@ -58,41 +41,27 @@
closeMenu(); closeMenu();
} }
function selectActive() {
const option = options[activeIndex];
if (option) {
selectValue(option.value);
}
}
function moveActive(delta: number) { function moveActive(delta: number) {
if (!options.length) return; if (!options.length) return;
activeIndex = (activeIndex + delta + options.length) % options.length; activeIndex = (activeIndex + delta + options.length) % options.length;
} }
function onTriggerKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent, inListbox = false) {
switch (event.key) { switch (event.key) {
case "ArrowDown": case "ArrowDown":
case "ArrowUp": {
event.preventDefault(); event.preventDefault();
if (isOpen) { const delta = event.key === "ArrowDown" ? 1 : -1;
moveActive(1); if (isOpen || inListbox) moveActive(delta);
} else { else openMenu();
openMenu();
}
break;
case "ArrowUp":
event.preventDefault();
if (isOpen) {
moveActive(-1);
} else {
openMenu();
}
break; break;
}
case "Enter": case "Enter":
case " ": case " ":
event.preventDefault(); event.preventDefault();
if (isOpen) { if (isOpen) {
selectActive(); const option = options[activeIndex];
if (option) selectValue(option.value);
} else { } else {
openMenu(); openMenu();
} }
@@ -103,38 +72,20 @@
closeMenu(); closeMenu();
} }
break; break;
}
}
function onListboxKeydown(event: KeyboardEvent) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveActive(1);
break;
case "ArrowUp":
event.preventDefault();
moveActive(-1);
break;
case "Home": case "Home":
event.preventDefault(); if (inListbox) {
activeIndex = 0; event.preventDefault();
activeIndex = 0;
}
break; break;
case "End": case "End":
event.preventDefault(); if (inListbox) {
activeIndex = Math.max(0, options.length - 1); event.preventDefault();
break; activeIndex = Math.max(0, options.length - 1);
case "Enter": }
case " ":
event.preventDefault();
selectActive();
break;
case "Escape":
event.preventDefault();
closeMenu();
break; break;
case "Tab": case "Tab":
closeMenu(false); if (inListbox) closeMenu(false);
break; break;
} }
} }
@@ -145,8 +96,7 @@
queueMicrotask(() => listbox?.focus()); queueMicrotask(() => listbox?.focus());
const onPointerDown = (event: PointerEvent) => { const onPointerDown = (event: PointerEvent) => {
const path = event.composedPath(); if (root && event.composedPath().includes(root)) return;
if (root && path.includes(root)) return;
closeMenu(false); closeMenu(false);
}; };
@@ -163,8 +113,8 @@
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={isOpen} aria-expanded={isOpen}
aria-controls={listboxId} aria-controls={listboxId}
onclick={toggleOpen} onclick={() => (isOpen ? closeMenu() : openMenu())}
onkeydown={onTriggerKeydown} onkeydown={(event) => handleKeydown(event)}
> >
<span class="select-label">{selectedLabel}</span> <span class="select-label">{selectedLabel}</span>
<span class="select-icon" aria-hidden="true"> <span class="select-icon" aria-hidden="true">
@@ -179,35 +129,32 @@
</button> </button>
{#if isOpen} {#if isOpen}
<ul <div
bind:this={listbox} bind:this={listbox}
id={listboxId} id={listboxId}
class="select-menu" class="select-menu"
role="listbox" role="listbox"
tabindex="-1" tabindex="-1"
aria-activedescendant={activeDescendantId} aria-activedescendant={activeDescendantId}
onkeydown={onListboxKeydown} onkeydown={(event) => handleKeydown(event, true)}
> >
{#each options as option, index (option.value)} {#each options as option, index (option.value)}
<li <button
type="button"
id={optionId(option.value)} id={optionId(option.value)}
role="option" role="option"
aria-selected={option.value === value} aria-selected={option.value === value}
class="select-option"
class:is-selected={option.value === value}
class:is-active={index === activeIndex}
tabindex="-1"
onclick={() => selectValue(option.value)}
onmouseenter={() => (activeIndex = index)}
> >
<button {option.label}
type="button" </button>
class="select-option"
class:is-selected={option.value === value}
class:is-active={index === activeIndex}
tabindex="-1"
onclick={() => selectValue(option.value)}
onmouseenter={() => (activeIndex = index)}
>
{option.label}
</button>
</li>
{/each} {/each}
</ul> </div>
{/if} {/if}
</div> </div>
@@ -239,14 +186,14 @@
box-shadow 180ms ease; box-shadow 180ms ease;
} }
.select-trigger:hover { .select-trigger:hover,
.select-trigger:focus-visible {
outline: none;
background: var(--theme-secondary, #e5e7eb); background: var(--theme-secondary, #e5e7eb);
border-color: var(--theme-offset-bg, var(--theme-secondary, #d4d4d8)); border-color: var(--theme-offset-bg, var(--theme-secondary, #d4d4d8));
} }
.select-trigger:focus-visible { .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%); 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); box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent);
} }
@@ -268,9 +215,11 @@
left: 0; left: 0;
right: 0; right: 0;
z-index: 50; z-index: 50;
display: flex;
flex-direction: column;
gap: 0.125rem;
margin: 0; margin: 0;
padding: 0.5rem; padding: 0.5rem;
list-style: none;
border: 1px solid var(--theme-offset-bg, var(--theme-secondary, #e5e7eb)); border: 1px solid var(--theme-offset-bg, var(--theme-secondary, #e5e7eb));
border-radius: 14px; border-radius: 14px;
background: var(--theme-primary, #ffffff); background: var(--theme-primary, #ffffff);
@@ -279,9 +228,6 @@
0 8px 10px -6px rgb(0 0 0 / 0.2); 0 8px 10px -6px rgb(0 0 0 / 0.2);
max-height: 18rem; max-height: 18rem;
overflow-y: auto; overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.125rem;
} }
.select-menu:focus-visible { .select-menu:focus-visible {
@@ -310,12 +256,9 @@
.select-option:hover, .select-option:hover,
.select-option:focus-visible, .select-option:focus-visible,
.select-option.is-active { .select-option.is-active,
.select-option.is-selected {
outline: none; outline: none;
background: var(--theme-secondary, #e5e7eb); background: var(--theme-secondary, #e5e7eb);
} }
.select-option.is-selected {
background: var(--theme-secondary, #e5e7eb);
}
</style> </style>
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { LUCIDE_MOON_PATH } from "@/lib/icons/lucideMoon";
let { class: className = "w-5 h-5" }: { class?: string } = $props(); let { class: className = "w-5 h-5" }: { class?: string } = $props();
</script> </script>
@@ -9,5 +11,5 @@
class={className} class={className}
aria-hidden="true" aria-hidden="true"
> >
<path d="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" /> <path d={LUCIDE_MOON_PATH} />
</svg> </svg>
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { LUCIDE_SUN_PATH } from "@/lib/icons/lucideSun";
let { class: className = "w-5 h-5" }: { class?: string } = $props(); let { class: className = "w-5 h-5" }: { class?: string } = $props();
</script> </script>
@@ -9,5 +11,5 @@
class={className} class={className}
aria-hidden="true" aria-hidden="true"
> >
<path d="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" /> <path d={LUCIDE_SUN_PATH} />
</svg> </svg>
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,10 @@
let imageBackgrounds = $derived(backgrounds.filter(bg => bg.type === 'image')); let imageBackgrounds = $derived(backgrounds.filter(bg => bg.type === 'image'));
let videoBackgrounds = $derived(backgrounds.filter(bg => bg.type === 'video')); 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() { async function getTheme() {
return localStorage.getItem('selectedBackground'); return localStorage.getItem('selectedBackground');
} }
@@ -41,11 +45,7 @@
await writeData(fileId, fileType, blob); await writeData(fileId, fileType, blob);
backgrounds = [...backgrounds, { id: fileId, type: fileType, blob, url: URL.createObjectURL(blob) }]; backgrounds = [...backgrounds, { id: fileId, type: fileType, blob, url: URL.createObjectURL(blob) }];
} catch (e) { } catch (e) {
if (e instanceof Error) { setError(e);
error = e.message;
} else {
error = 'An unknown error occurred';
}
} }
} }
@@ -78,11 +78,7 @@
selectNoBackground(); selectNoBackground();
} }
} catch (e) { } catch (e) {
if (e instanceof Error) { setError(e);
error = e.message;
} else {
error = 'An unknown error occurred';
}
} }
} }
@@ -105,11 +101,7 @@
selectNoBackground(); selectNoBackground();
} }
} catch (e) { } catch (e) {
if (e instanceof Error) { error = e instanceof Error ? `Failed to delete background: ${e.message}` : 'An unknown error occurred';
error = `Failed to delete background: ${e.message}`;
} else {
error = 'An unknown error occurred';
}
} }
} }
@@ -1,11 +1,7 @@
<script lang="ts"> <script lang="ts">
import { blobToDataUrl } from '@/plugins/built-in/themes/themeImageUrl' import { blobToDataUrl } from '@/plugins/built-in/themes/themeImageUrl'
let { let { source, alt = '', class: className = '' } = $props<{
source,
alt = '',
class: className = '',
} = $props<{
source: string | Blob | null | undefined source: string | Blob | null | undefined
alt?: string alt?: string
class?: string class?: string
@@ -24,7 +20,7 @@
return return
} }
let cancelled = false let cancelled = false
blobToDataUrl(value).then((url) => { void blobToDataUrl(value).then((url) => {
if (!cancelled) src = url if (!cancelled) src = url
}) })
return () => { return () => {
@@ -34,5 +30,5 @@
</script> </script>
{#if src} {#if src}
<img src={src} alt={alt} class={className} /> <img {src} {alt} class={className} />
{/if} {/if}
+24 -28
View File
@@ -315,40 +315,36 @@
<h2 class="text-sm font-bold">Maximum Subjects</h2> <h2 class="text-sm font-bold">Maximum Subjects</h2>
<p class="text-xs">Number of subjects to include, ordered by soonest due date</p> <p class="text-xs">Number of subjects to include, ordered by soonest due date</p>
</div> </div>
<div> <Select
<Select value={String($settingsState.homeUpcomingSubjectsMax ?? 5)}
value={String($settingsState.homeUpcomingSubjectsMax ?? 5)} onChange={(value: string) => (settingsState.homeUpcomingSubjectsMax = Number(value))}
onChange={(value: string) => (settingsState.homeUpcomingSubjectsMax = Number(value))} options={[
options={[ { value: "0", label: "All" },
{ value: "0", label: "All" }, { value: "3", label: "3" },
{ value: "3", label: "3" }, { value: "5", label: "5" },
{ value: "5", label: "5" }, { value: "7", label: "7" },
{ value: "7", label: "7" }, { value: "10", label: "10" },
{ value: "10", label: "10" }, { value: "15", label: "15" },
{ value: "15", label: "15" }, ]}
]} />
/>
</div>
</div> </div>
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50"> <div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
<div class="pr-4"> <div class="pr-4">
<h2 class="text-sm font-bold">Maximum Assessments per Subject</h2> <h2 class="text-sm font-bold">Maximum Assessments per Subject</h2>
<p class="text-xs">Assessments shown for each included subject</p> <p class="text-xs">Assessments shown for each included subject</p>
</div> </div>
<div> <Select
<Select value={String($settingsState.homeUpcomingAssessmentsPerSubjectMax ?? 0)}
value={String($settingsState.homeUpcomingAssessmentsPerSubjectMax ?? 0)} onChange={(value: string) => (settingsState.homeUpcomingAssessmentsPerSubjectMax = Number(value))}
onChange={(value: string) => (settingsState.homeUpcomingAssessmentsPerSubjectMax = Number(value))} options={[
options={[ { value: "0", label: "All" },
{ value: "0", label: "All" }, { value: "1", label: "1" },
{ value: "1", label: "1" }, { value: "2", label: "2" },
{ value: "2", label: "2" }, { value: "3", label: "3" },
{ value: "3", label: "3" }, { value: "5", label: "5" },
{ value: "5", label: "5" }, { value: "10", label: "10" },
{ value: "10", label: "10" }, ]}
]} />
/>
</div>
</div> </div>
</div> </div>
</div> </div>
+8 -17
View File
@@ -76,26 +76,17 @@
await themeManager.disableTheme(); await themeManager.disableTheme();
if (themeID) { if (themeID) {
const tempTheme = await themeManager.getTheme(themeID) const tempTheme = await themeManager.getTheme(themeID);
if (!tempTheme) return;
if (!tempTheme) return
// convert temptheme to LoadedCustomTheme
const loadedTheme = {
...tempTheme,
CustomImages: tempTheme.CustomImages.map(image => ({
...image
}))
}
theme = { theme = {
...loadedTheme, ...tempTheme,
adaptiveCssVariables: loadedTheme.adaptiveCssVariables ?? [], adaptiveCssVariables: tempTheme.adaptiveCssVariables ?? [],
forceTheme: forceTheme:
loadedTheme.forceTheme ?? tempTheme.forceTheme ??
(loadedTheme.forceDark !== undefined ? true : undefined), (tempTheme.forceDark !== undefined ? true : undefined),
} };
themeLoaded = true themeLoaded = true;
} else { } else {
themeLoaded = true themeLoaded = true
} }
+6 -6
View File
@@ -1,6 +1,6 @@
/** /** Material "dark_mode" moon icon — filled style to match SEQTA menu bar icons. */
* 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 = `
<path fill="currentColor" d="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 =
`.trim(); `<path fill="currentColor" d="${LUCIDE_MOON_PATH}"/>`;
+6 -6
View File
@@ -1,6 +1,6 @@
/** /** Material "light_mode" sun icon — filled style to match SEQTA menu bar icons. */
* 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 = `
<path fill="currentColor" d="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 =
`.trim(); `<path fill="currentColor" d="${LUCIDE_SUN_PATH}"/>`;
@@ -9,32 +9,26 @@ const LAYER_CLASSES = [
["bg", "bg3", ANIMATED_BG_MARKER], ["bg", "bg3", ANIMATED_BG_MARKER],
] as const; ] as const;
const layerSelector = `:scope > div.bg.${ANIMATED_BG_MARKER}`;
export function updateAnimationSpeed(speed: number) { export function updateAnimationSpeed(speed: number) {
const bgElements = document.querySelectorAll(`.bg.${ANIMATED_BG_MARKER}`); document.querySelectorAll(`.bg.${ANIMATED_BG_MARKER}`).forEach((element, index) => {
Array.from(bgElements).forEach((element, index) => {
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5; const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`; (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( export function ensureAnimatedBackgroundLayers(
container: HTMLElement, container: HTMLElement,
menu: HTMLElement, menu: HTMLElement,
speed: number, speed: number,
): void { ): void {
const count = countAnimatedLayers(container); if (container.querySelectorAll(layerSelector).length >= 3) {
if (count >= 3) {
updateAnimationSpeed(speed); updateAnimationSpeed(speed);
return; return;
} }
container container.querySelectorAll(layerSelector).forEach((el) => el.remove());
.querySelectorAll(`:scope > div.bg.${ANIMATED_BG_MARKER}`)
.forEach((el) => el.remove());
for (const classes of LAYER_CLASSES) { for (const classes of LAYER_CLASSES) {
const bk = document.createElement("div"); const bk = document.createElement("div");
@@ -46,9 +40,7 @@ export function ensureAnimatedBackgroundLayers(
} }
export function removeAnimatedBackgroundLayers(): void { export function removeAnimatedBackgroundLayers(): void {
document document.querySelectorAll(`div.bg.${ANIMATED_BG_MARKER}`).forEach((el) => el.remove());
.querySelectorAll(`div.bg.${ANIMATED_BG_MARKER}`)
.forEach((el) => el.remove());
} }
export async function syncAnimatedBackground( export async function syncAnimatedBackground(
@@ -29,6 +29,7 @@ class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
} }
const instance = new AnimatedBackgroundPluginClass(); const instance = new AnimatedBackgroundPluginClass();
const resync = (api: PluginAPI<typeof settings>) => () => void syncAnimatedBackground(api);
const animatedBackgroundPlugin: Plugin<typeof settings> = { const animatedBackgroundPlugin: Plugin<typeof settings> = {
id: "animated-background", id: "animated-background",
@@ -43,23 +44,15 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
await syncAnimatedBackground(api); await syncAnimatedBackground(api);
const speedUnregister = api.settings.onChange("speed", updateAnimationSpeed); const speedUnregister = api.settings.onChange("speed", updateAnimationSpeed);
const pageChangeUnregister = api.seqta.onPageChange(resync(api));
const pageChangeUnregister = api.seqta.onPageChange(() => {
void syncAnimatedBackground(api);
});
const pageshowHandler = (event: PageTransitionEvent) => { const pageshowHandler = (event: PageTransitionEvent) => {
if (event.persisted) void syncAnimatedBackground(api); if (event.persisted) void syncAnimatedBackground(api);
}; };
window.addEventListener("pageshow", pageshowHandler); window.addEventListener("pageshow", pageshowHandler);
const containerObserver = new MutationObserver(() => { const containerObserver = new MutationObserver(resync(api));
void syncAnimatedBackground(api);
});
const container = document.getElementById("container"); const container = document.getElementById("container");
if (container) { if (container) containerObserver.observe(container, { childList: true });
containerObserver.observe(container, { childList: true });
}
return () => { return () => {
speedUnregister.unregister(); speedUnregister.unregister();
+124 -179
View File
@@ -36,132 +36,107 @@ const store = localforage.createInstance({
storeName: "music", 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 audio: HTMLAudioElement | null = null;
let currentObjectUrl: string | null = null; let objectUrl: string | null = null;
let pendingGestureCancel: (() => void) | null = null; let gestureCleanup: (() => void) | null = null;
let visibilityResumeTimeout: number | null = null; let resumeTimer: ReturnType<typeof setTimeout> | null = null;
let hintElement: HTMLElement | null = null; let hintEl: HTMLElement | null = null;
let isPlaying = false; let playing = false;
async function loadAudioBlob(): Promise<Blob | null> { const clamp = (v: number) => Math.max(0, Math.min(1, v));
async function loadBlob(): Promise<Blob | null> {
const blob = await store.getItem<Blob>("audio-blob"); const blob = await store.getItem<Blob>("audio-blob");
return blob && blob instanceof Blob ? blob : null; return blob instanceof Blob ? blob : null;
} }
function stopAndCleanupAudio(): void { function clearHint(): void {
if (currentAudio) { hintEl?.remove();
currentAudio.pause(); hintEl = null;
currentAudio.src = "";
currentAudio.remove();
currentAudio = null;
}
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = null;
}
isPlaying = false;
} }
function hideAutoplayHint(): void { function disarmGesture(): void {
if (hintElement) { gestureCleanup?.();
hintElement.remove(); gestureCleanup = null;
hintElement = null;
}
} }
function showAutoplayHint(onActivate: () => void): void { function onPlayStarted(): void {
hideAutoplayHint(); 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"); const hint = document.createElement("button");
hint.id = HINT_ID; hint.id = "bsplus-bg-music-hint";
hint.type = "button"; hint.type = "button";
hint.className = "bsplus-bg-music-hint"; hint.className = "bsplus-bg-music-hint";
hint.textContent = "Tap to start background music"; hint.textContent = "Tap to start background music";
hint.addEventListener("pointerdown", (event) => { hint.addEventListener("pointerdown", (e) => {
event.preventDefault(); e.preventDefault();
onActivate(); onActivate();
}); });
document.body.appendChild(hint); document.body.append(hint);
hintElement = hint; hintEl = hint;
}
function disarmGesturePlayback(): void {
if (pendingGestureCancel) {
pendingGestureCancel();
pendingGestureCancel = null;
}
} }
/** Prepare <audio> so play() can run synchronously inside a user-gesture handler. */ /** Prepare <audio> so play() can run synchronously inside a user-gesture handler. */
async function prepareAudioElement(volume: number): Promise<boolean> { async function prepareAudio(vol: number): Promise<boolean> {
const blob = await loadAudioBlob(); const blob = await loadBlob();
if (!blob) { if (!blob) {
stopAndCleanupAudio(); stopAudio();
hideAutoplayHint(); clearHint();
return false; return false;
} }
if (!audio) {
if (!currentAudio) { stopAudio();
stopAndCleanupAudio(); objectUrl = URL.createObjectURL(blob);
currentObjectUrl = URL.createObjectURL(blob); audio = new Audio(objectUrl);
const audio = new Audio(currentObjectUrl);
audio.loop = true; audio.loop = true;
audio.volume = Math.max(0, Math.min(1, volume));
audio.preload = "auto"; audio.preload = "auto";
audio.style.display = "none"; audio.style.display = "none";
document.body.appendChild(audio); document.body.append(audio);
currentAudio = audio;
} else {
currentAudio.volume = Math.max(0, Math.min(1, volume));
} }
audio.volume = clamp(vol);
return true; return true;
} }
/** /** Call synchronously from a user-gesture handler (no await before this). */
* Must be called synchronously from a user-gesture handler (no await before this). function playPrepared(vol: number): void {
*/ if (!audio) return;
function playPreparedAudio(volume: number): boolean { audio.volume = clamp(vol);
if (!currentAudio) return false; void audio.play().then(onPlayStarted).catch(() => {
currentAudio.volume = Math.max(0, Math.min(1, volume)); playing = false;
});
}
async function tryAutoplay(vol: number): Promise<boolean> {
if (!(await prepareAudio(vol)) || !audio) return false;
try { try {
const result = currentAudio.play(); await audio.play();
void result onPlayStarted();
.then(() => {
isPlaying = true;
hideAutoplayHint();
disarmGesturePlayback();
})
.catch(() => {
isPlaying = false;
});
return true; return true;
} catch { } catch {
isPlaying = false; playing = false;
return false; return false;
} }
} }
async function tryAutoplay(volume: number): Promise<boolean> { function armGesture(onGesture: () => void): void {
const ready = await prepareAudioElement(volume); disarmGesture();
if (!ready || !currentAudio) return false;
try {
await currentAudio.play();
isPlaying = true;
hideAutoplayHint();
disarmGesturePlayback();
return true;
} catch {
isPlaying = false;
return false;
}
}
function armGesturePlayback(onGesture: () => void): void {
disarmGesturePlayback();
const listener = (event: Event) => { const listener = (event: Event) => {
if (event.type === "keydown") { if (event.type === "keydown") {
const key = (event as KeyboardEvent).key; const key = (event as KeyboardEvent).key;
@@ -169,21 +144,23 @@ function armGesturePlayback(onGesture: () => void): void {
} }
onGesture(); onGesture();
}; };
for (const type of GESTURE_EVENTS) {
const options: AddEventListenerOptions = { capture: true, passive: true }; document.addEventListener(type, listener, gestureOpts);
const types = ["pointerdown", "keydown", "touchstart"] as const;
for (const type of types) {
document.addEventListener(type, listener, options);
} }
gestureCleanup = () => {
pendingGestureCancel = () => { for (const type of GESTURE_EVENTS) {
for (const type of types) { document.removeEventListener(type, listener, gestureOpts);
document.removeEventListener(type, listener, options);
} }
hideAutoplayHint(); clearHint();
}; };
showHint(onGesture);
}
showAutoplayHint(onGesture); function clearResumeTimer(): void {
if (resumeTimer !== null) {
clearTimeout(resumeTimer);
resumeTimer = null;
}
} }
const backgroundMusicPlugin: Plugin<typeof settings> = { const backgroundMusicPlugin: Plugin<typeof settings> = {
@@ -199,41 +176,33 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
run: async (api) => { run: async (api) => {
await api.storage.loaded; await api.storage.loaded;
const getVolume = () => type BgSettings = { volume?: number; pauseOnHidden?: boolean };
(api.settings as { volume?: number }).volume ?? 0.5; const s = () => api.settings as BgSettings;
const vol = () => s().volume ?? 0.5;
const pauseOnHidden = () => s().pauseOnHidden ?? true;
const gestureStart = () => { const gestureStart = () => {
if (!currentAudio) return; if (audio) playPrepared(vol());
playPreparedAudio(getVolume());
}; };
const ensurePlayback = async () => { const ensurePlayback = async () => {
const vol = getVolume(); if (!(await prepareAudio(vol()))) return;
const ready = await prepareAudioElement(vol); if (playing && audio && !audio.paused) {
if (!ready) return; clearHint();
disarmGesture();
if (isPlaying && currentAudio && !currentAudio.paused) {
hideAutoplayHint();
disarmGesturePlayback();
return; return;
} }
if (!(await tryAutoplay(vol()))) armGesture(gestureStart);
const autoplayed = await tryAutoplay(vol);
if (!autoplayed) {
armGesturePlayback(gestureStart);
}
}; };
api.settings.onChange("volume" as never, (value: unknown) => { api.settings.onChange("volume" as never, (value: unknown) => {
const vol = typeof value === "number" ? value : 0.5; if (typeof value === "number" && audio) audio.volume = clamp(value);
if (currentAudio) currentAudio.volume = Math.max(0, Math.min(1, vol));
}); });
api.settings.onChange("pauseOnHidden" as never, (value: unknown) => { api.settings.onChange("pauseOnHidden" as never, (value: unknown) => {
const pauseOnHidden = typeof value === "boolean" ? value : true;
if ( if (
!pauseOnHidden && value === false &&
currentAudio?.paused && audio?.paused &&
document.visibilityState === "visible" document.visibilityState === "visible"
) { ) {
void ensurePlayback(); void ensurePlayback();
@@ -242,73 +211,49 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
await ensurePlayback(); await ensurePlayback();
const visHandler = () => { const onVisibility = () => {
const pauseOnHidden =
(api.settings as { pauseOnHidden?: boolean }).pauseOnHidden ?? true;
if (document.visibilityState === "hidden") { if (document.visibilityState === "hidden") {
if (!pauseOnHidden || !currentAudio) return; if (!pauseOnHidden() || !audio) return;
if (visibilityResumeTimeout !== null) { clearResumeTimer();
clearTimeout(visibilityResumeTimeout); audio.pause();
visibilityResumeTimeout = null; playing = false;
}
currentAudio.pause();
isPlaying = false;
return; return;
} }
if (!audio) {
if (!currentAudio) {
void ensurePlayback(); void ensurePlayback();
return; return;
} }
if (!pauseOnHidden()) return;
if (!pauseOnHidden) return; clearResumeTimer();
resumeTimer = setTimeout(() => {
if (visibilityResumeTimeout !== null) { resumeTimer = null;
clearTimeout(visibilityResumeTimeout); void tryAutoplay(vol());
}
visibilityResumeTimeout = window.setTimeout(() => {
visibilityResumeTimeout = null;
void tryAutoplay(getVolume());
}, 200); }, 200);
}; };
document.addEventListener("visibilitychange", visHandler);
const pageshowHandler = () => void ensurePlayback(); const onUpdated = () => void ensurePlayback();
window.addEventListener("pageshow", pageshowHandler); const onStop = () => {
disarmGesture();
const uploadedHandler = () => void ensurePlayback(); stopAudio();
window.addEventListener( clearHint();
"betterseqta-background-music-updated",
uploadedHandler,
);
const stopHandler = () => {
disarmGesturePlayback();
stopAndCleanupAudio();
hideAutoplayHint();
}; };
window.addEventListener("betterseqta-background-music-stop", stopHandler); const teardown = () => {
document.removeEventListener("visibilitychange", onVisibility);
return () => { window.removeEventListener("pageshow", onUpdated);
document.removeEventListener("visibilitychange", visHandler); window.removeEventListener("betterseqta-background-music-updated", onUpdated);
window.removeEventListener("pageshow", pageshowHandler); window.removeEventListener("betterseqta-background-music-stop", onStop);
window.removeEventListener( clearResumeTimer();
"betterseqta-background-music-updated", disarmGesture();
uploadedHandler, clearHint();
); stopAudio();
window.removeEventListener(
"betterseqta-background-music-stop",
stopHandler,
);
disarmGesturePlayback();
hideAutoplayHint();
if (visibilityResumeTimeout !== null) {
clearTimeout(visibilityResumeTimeout);
visibilityResumeTimeout = null;
}
stopAndCleanupAudio();
}; };
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("pageshow", onUpdated);
window.addEventListener("betterseqta-background-music-updated", onUpdated);
window.addEventListener("betterseqta-background-music-stop", onStop);
return teardown;
}, },
}; };
@@ -8,9 +8,7 @@
border-radius: 999px; border-radius: 999px;
background: color-mix(in srgb, var(--theme-primary, #1a1a1a) 92%, black 8%); background: color-mix(in srgb, var(--theme-primary, #1a1a1a) 92%, black 8%);
color: var(--text-primary, #fff); color: var(--text-primary, #fff);
font-size: 0.8125rem; font: 600 0.8125rem/1.25 system-ui, sans-serif;
font-weight: 600;
line-height: 1.25;
cursor: pointer; cursor: pointer;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.35); box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
animation: bsplus-bg-music-hint-in 220ms ease-out; animation: bsplus-bg-music-hint-in 220ms ease-out;
@@ -21,12 +19,6 @@
} }
@keyframes bsplus-bg-music-hint-in { @keyframes bsplus-bg-music-hint-in {
from { from { opacity: 0; transform: translateY(6px); }
opacity: 0; to { opacity: 1; transform: translateY(0); }
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
+2 -7
View File
@@ -11,16 +11,11 @@ import {
resetSearchIndexes, resetSearchIndexes,
notifyOpenTabsResetSearchIndex, notifyOpenTabsResetSearchIndex,
} from "./src/indexing/resetIndexes"; } from "./src/indexing/resetIndexes";
import { getDefaultSearchHotkey } from "./src/utils/hotkeyUtils";
// Platform-aware default hotkey
const getDefaultHotkey = () => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
return isMac ? "cmd+k" : "ctrl+k";
};
const settings = defineSettings({ const settings = defineSettings({
searchHotkey: hotkeySetting({ searchHotkey: hotkeySetting({
default: getDefaultHotkey(), default: getDefaultSearchHotkey(),
title: "Search Hotkey", title: "Search Hotkey",
description: "Keyboard shortcut to open the search", description: "Keyboard shortcut to open the search",
}), }),
@@ -15,7 +15,6 @@
import HighlightedText from '../utils/HighlightedText.svelte'; import HighlightedText from '../utils/HighlightedText.svelte';
import { matchesHotkey } from '../utils/hotkeyUtils'; import { matchesHotkey } from '../utils/hotkeyUtils';
import browser from 'webextension-polyfill'; import browser from 'webextension-polyfill';
import { verboseDebug } from '@/utils/verboseLog';
const { const {
transparencyEffects, transparencyEffects,
@@ -33,12 +32,6 @@
const dynamicIdToItemMap = $state(new Map<string, IndexItem>()); const dynamicIdToItemMap = $state(new Map<string, IndexItem>());
const commandIdToItemMap = $state(new Map<string, StaticCommandItem>()); const commandIdToItemMap = $state(new Map<string, StaticCommandItem>());
let isIndexing = $state(false);
let completedJobs = $state(0);
let totalJobs = $state(0);
let indexingStatus = $state<string | null>(null);
let indexingDetail = $state<string | null>(null);
let commandPalleteOpen = $state(false); let commandPalleteOpen = $state(false);
let searchTerm = $state(''); let searchTerm = $state('');
let selectedIndex = $state(0); let selectedIndex = $state(0);
@@ -119,17 +112,6 @@
}); });
onMount(() => { onMount(() => {
const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing, status, detail } = event.detail;
completedJobs = completed;
totalJobs = total;
isIndexing = indexing;
indexingStatus = status || null;
indexingDetail = detail || null;
};
window.addEventListener('indexing-progress', progressHandler as EventListener);
const itemsUpdatedHandler = (event: Event) => { const itemsUpdatedHandler = (event: Event) => {
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail; const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail;
@@ -168,7 +150,6 @@
}; };
return () => { return () => {
window.removeEventListener('indexing-progress', progressHandler as EventListener);
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler); window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
}; };
}); });
@@ -184,8 +165,6 @@
dynamicItems.forEach(item => dynamicIdToItemMap.set(item.id, item)); dynamicItems.forEach(item => dynamicIdToItemMap.set(item.id, item));
commands.forEach(item => commandIdToItemMap.set(item.id, item)); commands.forEach(item => commandIdToItemMap.set(item.id, item));
verboseDebug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`);
} }
const performSearch = async () => { const performSearch = async () => {
@@ -2,7 +2,6 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
export interface BaseCommandItem { export interface BaseCommandItem {
id: string; id: string;
text: string; text: string;
@@ -106,7 +105,6 @@ async function navigateToSpecificLesson(lesson: any) {
if (lessonDate === todayDateString && lessonPeriod === normalizedLessonPeriod) { if (lessonDate === todayDateString && lessonPeriod === normalizedLessonPeriod) {
// Found the exact matching lesson, click it // Found the exact matching lesson, click it
(lessonElement as HTMLElement).click(); (lessonElement as HTMLElement).click();
verboseLog(`Navigated to exact lesson: ${lessonDate} ${lessonPeriod}`);
return true; return true;
} }
} }
@@ -1,13 +1,5 @@
import type { Plugin } from "@/plugins/core/types"; import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings"; import { verboseDebug, verboseLog } from "@/utils/verboseLog";
import {
booleanSetting,
buttonSetting,
defineSettings,
hotkeySetting,
Setting,
} from "@/plugins/core/settingsHelpers";
import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import { runIndexing, ensureSchemaCurrent } from "../indexing/indexer"; import { runIndexing, ensureSchemaCurrent } from "../indexing/indexer";
@@ -23,161 +15,21 @@ import {
installPassiveObserver, installPassiveObserver,
} from "../indexing/passiveObserver"; } from "../indexing/passiveObserver";
// Platform-aware default hotkey const globalSearchPlugin: Plugin<{}> = {
const getDefaultHotkey = () => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
return isMac ? "cmd+k" : "ctrl+k";
};
const settings = defineSettings({
searchHotkey: hotkeySetting({
default: getDefaultHotkey(),
title: "Search Hotkey",
description: "Keyboard shortcut to open the search",
}),
showRecentFirst: booleanSetting({
default: true,
title: "Show Recent First",
description: "Sort dynamic content by most recent first",
}),
transparencyEffects: booleanSetting({
default: true,
title: "Transparency Effects",
description: "Enable transparency effects for the search bar",
}),
runIndexingOnLoad: booleanSetting({
default: true,
title: "Index on Page Load",
description: "Run content indexing when SEQTA loads",
}),
passiveIndexing: booleanSetting({
default: true,
title: "Index Browsed Content",
description:
"Capture safe text from SEQTA pages you visit so they're searchable. Sensitive routes (settings, files, login) are always excluded.",
}),
resetIndex: buttonSetting({
title: "Reset Index",
description: "Reset the search index and storage",
trigger: async () => {
const confirmed = confirm(
"Reset the search index and all stored Global Search data?\n\nAfter this, reload this SEQTA tab so indexing can run again and rebuild the index.",
);
if (confirmed) {
try {
// Import resetDatabase function to properly close connections
const { resetDatabase } = await import("../indexing/db");
// Reset the vector worker first
try {
const workerManager = VectorWorkerManager.getInstance();
await workerManager.resetWorker();
verboseLog("Vector worker reset successfully");
} catch (e) {
console.warn("Failed to reset vector worker:", e);
}
// Close all database connections properly before deletion
try {
await resetDatabase();
} catch (e) {
console.warn("Failed to reset betterseqta-index database:", e);
}
// Wait a bit for connections to fully close
await new Promise(resolve => setTimeout(resolve, 100));
// Delete embeddiaDB (vector search database)
const deleteDb = (dbName: string) => {
return new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => {
verboseLog(`Successfully deleted database: ${dbName}`);
resolve();
};
req.onerror = () => {
console.error(`Error deleting database ${dbName}:`, req.error);
reject(req.error);
};
req.onblocked = () => {
console.warn(`Database ${dbName} deletion blocked - connections still open`);
// Wait and retry once
setTimeout(() => {
const retryReq = indexedDB.deleteDatabase(dbName);
retryReq.onsuccess = () => {
verboseLog(`Successfully deleted database on retry: ${dbName}`);
resolve();
};
retryReq.onerror = () => reject(retryReq.error);
retryReq.onblocked = () => {
reject(new Error(`One database is open, failed to remove: ${dbName}. Please close other tabs and try again.`));
};
}, 500);
};
});
};
try {
await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index");
alert(
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
);
} catch (e) {
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
}
} catch (e) {
alert("Failed to reset index: " + String(e));
}
}
},
}),
});
class GlobalSearchPlugin extends BasePlugin<typeof settings> {
@Setting(settings.searchHotkey)
searchHotkey!: string;
@Setting(settings.showRecentFirst)
showRecentFirst!: boolean;
@Setting(settings.transparencyEffects)
transparencyEffects!: boolean;
@Setting(settings.runIndexingOnLoad)
runIndexingOnLoad!: boolean;
@Setting(settings.passiveIndexing)
passiveIndexing!: boolean;
@Setting(settings.resetIndex)
resetIndex!: () => void;
}
const settingsInstance = new GlobalSearchPlugin();
const globalSearchPlugin: Plugin<typeof settings> = {
id: "global-search", id: "global-search",
name: "Global Search", name: "Global Search",
description: "Quick search for everything in SEQTA", description: "Quick search for everything in SEQTA",
version: "1.0.0", version: "1.0.0",
settings: settingsInstance.settings, settings: {},
disableToggle: true, disableToggle: true,
defaultEnabled: false, defaultEnabled: false,
styles: styles, styles,
run: async (api) => { run: async (api) => {
const appRef = { current: null }; const appRef = { current: null };
installResetIndexMessageListener(); 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,
// we want the database wipe to complete before `IndexedDbManager`
// grabs a handle on `embeddiaDB`, otherwise the delete request comes
// back blocked.
try { try {
const wasUpdated = await checkAndHandleUpdate(); const wasUpdated = await checkAndHandleUpdate();
if (wasUpdated) { if (wasUpdated) {
@@ -186,25 +38,21 @@ const globalSearchPlugin: Plugin<typeof settings> = {
); );
} }
} catch (error: any) { } catch (error: any) {
// Firefox sometimes refuses CSS preloads or asset reads; we never const msg = error?.message ?? "";
// want this path to take the whole plugin down.
if ( if (
error?.message?.includes("preload CSS") || msg.includes("preload CSS") ||
error?.message?.includes("MIME type") || msg.includes("MIME type") ||
error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT") msg.includes("NS_ERROR_CORRUPTED_CONTENT")
) { ) {
verboseDebug( verboseDebug(
"[Global Search] Version check skipped due to asset loading restrictions:", "[Global Search] Version check skipped due to asset loading restrictions:",
error.message, msg,
); );
} else { } else {
console.warn("[Global Search] Failed to check for updates:", error); console.warn("[Global Search] Failed to check for updates:", error);
} }
} }
// 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 { try {
await ensureSchemaCurrent(); await ensureSchemaCurrent();
} catch (error) { } catch (error) {
@@ -218,69 +66,25 @@ const globalSearchPlugin: Plugin<typeof settings> = {
}); });
} catch (error) { } catch (error) {
console.error("Failed to create IndexedDB:", error); console.error("Failed to create IndexedDB:", error);
// Continue execution - the search might still work without persistence
} }
initVectorSearch(); initVectorSearch();
// Warm up vector worker in background to improve initial response time (skip in Firefox)
setTimeout(async () => { setTimeout(async () => {
try { try {
// Only initialize worker if vector search is supported
const { isVectorSearchSupported } = await import("../utils/browserDetection"); const { isVectorSearchSupported } = await import("../utils/browserDetection");
if (isVectorSearchSupported()) { if (isVectorSearchSupported()) VectorWorkerManager.getInstance();
VectorWorkerManager.getInstance();
} else {
verboseDebug("[Global Search] Skipping vector worker warm-up (Firefox detected - using text search only)");
}
} catch (error) { } catch (error) {
console.warn("[Global Search] Vector worker warm-up failed:", error); console.warn("[Global Search] Vector worker warm-up failed:", error);
} }
}, 1000); }, 1000);
// Add debug helpers to window for troubleshooting
// @ts-ignore // @ts-ignore
window.globalSearchDebug = { window.globalSearchDebug = {
resetWorker: async () => { resetWorker: () => VectorWorkerManager.getInstance().resetWorker(),
const workerManager = VectorWorkerManager.getInstance(); passiveItems: getStoredPassiveItems,
await workerManager.resetWorker(); runSelfTests: async () =>
verboseLog("Vector worker reset via debug helper"); (await import("../indexing/selfTests")).runGlobalSearchSelfTests(),
},
checkWorkerStatus: () => {
const workerManager = VectorWorkerManager.getInstance();
verboseLog("Streaming active:", workerManager.isStreamingActive());
},
passiveItems: async () => {
const items = await getStoredPassiveItems();
verboseLog(`Captured ${items.length} passive items`);
return items;
},
runSelfTests: async () => {
const { runGlobalSearchSelfTests } = await import(
"../indexing/selfTests"
);
return runGlobalSearchSelfTests();
},
checkIndexedDBSize: async () => {
try {
const estimate = await navigator.storage.estimate();
verboseLog("Storage estimate:", estimate);
// Check embeddiaDB size
const dbRequest = indexedDB.open("embeddiaDB");
dbRequest.onsuccess = () => {
const db = dbRequest.result;
const transaction = db.transaction(["embeddiaObjectStore"], "readonly");
const store = transaction.objectStore("embeddiaObjectStore");
const countRequest = store.count();
countRequest.onsuccess = () => {
verboseLog("embeddiaDB item count:", countRequest.result);
};
};
} catch (e) {
console.error("Error checking storage:", e);
}
}
}; };
if (api.settings.passiveIndexing) { if (api.settings.passiveIndexing) {
@@ -293,23 +97,18 @@ const globalSearchPlugin: Plugin<typeof settings> = {
if (api.settings.runIndexingOnLoad && !isIndexingPaused()) { if (api.settings.runIndexingOnLoad && !isIndexingPaused()) {
setTimeout(async () => { setTimeout(async () => {
if (isIndexingPaused()) return; if (!isIndexingPaused()) await runIndexing();
await runIndexing();
}, 2000); }, 2000);
} }
const title = document.querySelector("#title"); const title = document.querySelector("#title");
if (title) { if (title) {
void mountSearchBar(title, api, appRef); void mountSearchBar(title, api, appRef);
} else { } else {
const titleElement = await waitForElm("#title", true, 100, 60); void mountSearchBar(await waitForElm("#title", true, 100, 60), api, appRef);
void mountSearchBar(titleElement, api, appRef);
} }
return () => { return () => cleanupSearchBar(appRef);
cleanupSearchBar(appRef);
};
}, },
}; };
@@ -36,29 +36,9 @@ export async function mountSearchBar(
const searchButton = document.createElement("div"); const searchButton = document.createElement("div");
searchButton.className = "search-trigger"; searchButton.className = "search-trigger";
const searchIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); const searchIcon = document.createElement("span");
searchIcon.setAttribute("xmlns", "http://www.w3.org/2000/svg"); searchIcon.innerHTML =
searchIcon.setAttribute("width", "16"); '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
searchIcon.setAttribute("height", "16");
searchIcon.setAttribute("viewBox", "0 0 24 24");
searchIcon.setAttribute("fill", "none");
searchIcon.setAttribute("stroke", "currentColor");
searchIcon.setAttribute("stroke-width", "2");
searchIcon.setAttribute("stroke-linecap", "round");
searchIcon.setAttribute("stroke-linejoin", "round");
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", "11");
circle.setAttribute("cy", "11");
circle.setAttribute("r", "8");
searchIcon.appendChild(circle);
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", "21");
line.setAttribute("y1", "21");
line.setAttribute("x2", "16.65");
line.setAttribute("y2", "16.65");
searchIcon.appendChild(line);
const searchLabel = document.createElement("p"); const searchLabel = document.createElement("p");
searchLabel.textContent = "Quick search..."; searchLabel.textContent = "Quick search...";
@@ -245,9 +225,7 @@ export async function mountSearchBar(
const updateSearchButtonDisplay = () => { const updateSearchButtonDisplay = () => {
hotkeySpan.textContent = hotkeyDisplay; hotkeySpan.textContent = hotkeyDisplay;
if (!searchButton.contains(searchIcon)) { searchButton.replaceChildren(searchIcon, searchLabel, hotkeySpan);
searchButton.replaceChildren(searchIcon, searchLabel, hotkeySpan);
}
}; };
updateSearchButtonDisplay(); updateSearchButtonDisplay();
@@ -282,7 +260,7 @@ export async function mountSearchBar(
try { try {
const { default: renderSvelte } = await import("@/interface/renderInShadow"); const { default: renderSvelte } = await import("@/interface/renderInShadow");
appRef.current = renderSvelte(SearchBar, searchRootShadow, { appRef.current = renderSvelte(SearchBar, searchRootShadow, {
transparencyEffects: api.settings.transparencyEffects ? true : false, transparencyEffects: api.settings.transparencyEffects,
showRecentFirst: api.settings.showRecentFirst, showRecentFirst: api.settings.showRecentFirst,
searchHotkey: currentHotkey, searchHotkey: currentHotkey,
}); });
@@ -3,7 +3,7 @@ import type { IndexItem } from "./types";
import ReactFiber from "@/seqta/utils/ReactFiber"; import ReactFiber from "@/seqta/utils/ReactFiber";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseLog } from '@/utils/verboseLog';
interface MessageMetadata { interface MessageMetadata {
messageId: number; messageId: number;
author: string; author: string;
@@ -55,39 +55,24 @@ function setupUpgradeHandler(
}; };
} }
function openAtVersion(version: number, extraStore?: string): Promise<IDBDatabase> { function openDatabase(version?: number, extraStore?: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let request: IDBOpenDBRequest; let request: IDBOpenDBRequest;
try { try {
request = indexedDB.open(DB_NAME, version); request =
version != null
? indexedDB.open(DB_NAME, version)
: indexedDB.open(DB_NAME);
} catch (error) { } catch (error) {
reject(error); reject(error);
return; return;
} }
setupUpgradeHandler(request, extraStore); setupUpgradeHandler(request, extraStore);
request.onsuccess = () => { request.onsuccess = () => {
attachConnection(request.result); attachConnection(request.result);
resolve(request.result); resolve(request.result);
}; };
request.onerror = () => reject(request.error);
});
}
function openAtCurrentVersion(): Promise<IDBDatabase> {
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); request.onerror = () => reject(request.error);
}); });
} }
@@ -154,7 +139,7 @@ async function openDBInternal(): Promise<IDBDatabase> {
const storedVersion = getCurrentVersion(); const storedVersion = getCurrentVersion();
try { try {
return await openAtVersion(storedVersion); return await openDatabase(storedVersion);
} catch (error) { } catch (error) {
const domError = error as DOMException | undefined; const domError = error as DOMException | undefined;
@@ -164,7 +149,7 @@ async function openDBInternal(): Promise<IDBDatabase> {
); );
invalidateConnection(); invalidateConnection();
try { try {
return await openAtCurrentVersion(); return await openDatabase();
} catch (fallbackError) { } catch (fallbackError) {
console.warn("[DB] Fallback open failed, recreating database:", fallbackError); console.warn("[DB] Fallback open failed, recreating database:", fallbackError);
} }
@@ -173,7 +158,7 @@ async function openDBInternal(): Promise<IDBDatabase> {
} }
await wipeDatabase(); await wipeDatabase();
return openAtVersion(1); return openDatabase(1);
} }
} }
@@ -188,19 +173,26 @@ function openDB(): Promise<IDBDatabase> {
return dbPromise; return dbPromise;
} }
async function getStore(store: string, mode: IDBTransactionMode = "readonly") { function idbRequest<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function objectStore(
store: string,
mode: IDBTransactionMode = "readonly",
): Promise<IDBObjectStore> {
const db = await openDB(); const db = await openDB();
if (!db.objectStoreNames.contains(store)) { if (!db.objectStoreNames.contains(store)) {
await upgradeDB(store); await upgradeDB(store);
const upgradedDb = await openDB(); const upgradedDb = await openDB();
const tx = upgradedDb.transaction(store, mode); return upgradedDb.transaction(store, mode).objectStore(store);
return tx.objectStore(store);
} }
const tx = db.transaction(store, mode); return db.transaction(store, mode).objectStore(store);
return tx.objectStore(store);
} }
async function upgradeDB(newStore: string): Promise<void> { async function upgradeDB(newStore: string): Promise<void> {
@@ -209,7 +201,7 @@ async function upgradeDB(newStore: string): Promise<void> {
let baseVersion = 0; let baseVersion = 0;
try { try {
const db = await openAtCurrentVersion(); const db = await openDatabase();
baseVersion = db.version; baseVersion = db.version;
db.close(); db.close();
cachedDb = null; cachedDb = null;
@@ -218,10 +210,8 @@ async function upgradeDB(newStore: string): Promise<void> {
console.warn("[DB] Could not probe database version before upgrade:", error); console.warn("[DB] Could not probe database version before upgrade:", error);
} }
const newVersion = baseVersion + 1;
try { try {
await openAtVersion(newVersion, newStore); await openDatabase(baseVersion + 1, newStore);
} catch (error) { } catch (error) {
console.error("Error upgrading database:", error); console.error("Error upgrading database:", error);
throw error; throw error;
@@ -230,12 +220,8 @@ async function upgradeDB(newStore: string): Promise<void> {
export async function getAll(store: string): Promise<any[]> { export async function getAll(store: string): Promise<any[]> {
try { try {
const s = await getStore(store); const s = await objectStore(store);
return new Promise((resolve, reject) => { return await idbRequest(s.getAll());
const req = s.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch (error) { } catch (error) {
console.error(`Error in getAll for store ${store}:`, error); console.error(`Error in getAll for store ${store}:`, error);
return []; return [];
@@ -244,12 +230,8 @@ export async function getAll(store: string): Promise<any[]> {
export async function get(store: string, key: string): Promise<any> { export async function get(store: string, key: string): Promise<any> {
try { try {
const s = await getStore(store); const s = await objectStore(store);
return new Promise((resolve, reject) => { return await idbRequest(s.get(key));
const req = s.get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch (error) { } catch (error) {
console.error(`Error in get for store ${store}, key ${key}:`, error); console.error(`Error in get for store ${store}, key ${key}:`, error);
return null; return null;
@@ -262,21 +244,14 @@ export async function put(
key?: string, key?: string,
): Promise<void> { ): Promise<void> {
try { try {
const s = await getStore(store, "readwrite"); const s = await objectStore(store, "readwrite");
return new Promise((resolve, reject) => { await idbRequest(key ? s.put(value, key) : s.put(value));
const req = key ? s.put(value, key) : s.put(value);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (error) { } catch (error) {
console.error(`Error in put for store ${store}:`, error); console.error(`Error in put for store ${store}:`, error);
throw error; throw error;
} }
} }
/**
* Apply puts and deletes in a single readwrite transaction.
*/
export async function applyStoreDiff( export async function applyStoreDiff(
store: string, store: string,
puts: Array<{ key: string; value: any }>, puts: Array<{ key: string; value: any }>,
@@ -286,15 +261,10 @@ export async function applyStoreDiff(
try { try {
const db = await openDB(); const db = await openDB();
if (!db.objectStoreNames.contains(store)) { if (!db.objectStoreNames.contains(store)) {
await upgradeDB(store); await upgradeDB(store);
const upgradedDb = await openDB();
await runStoreDiffTransaction(upgradedDb, store, puts, removeKeys);
return;
} }
await runStoreDiffTransaction(await openDB(), store, puts, removeKeys);
await runStoreDiffTransaction(db, store, puts, removeKeys);
} catch (error) { } catch (error) {
console.error(`Error in applyStoreDiff for store ${store}:`, error); console.error(`Error in applyStoreDiff for store ${store}:`, error);
throw error; throw error;
@@ -309,13 +279,13 @@ function runStoreDiffTransaction(
): Promise<void> { ): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tx = db.transaction(store, "readwrite"); const tx = db.transaction(store, "readwrite");
const objectStore = tx.objectStore(store); const objectStoreRef = tx.objectStore(store);
for (const key of removeKeys) { for (const key of removeKeys) {
objectStore.delete(key); objectStoreRef.delete(key);
} }
for (const { key, value } of puts) { for (const { key, value } of puts) {
objectStore.put(value, key); objectStoreRef.put(value, key);
} }
tx.oncomplete = () => resolve(); tx.oncomplete = () => resolve();
@@ -326,12 +296,8 @@ function runStoreDiffTransaction(
export async function remove(store: string, key: string): Promise<void> { export async function remove(store: string, key: string): Promise<void> {
try { try {
const s = await getStore(store, "readwrite"); const s = await objectStore(store, "readwrite");
return new Promise((resolve, reject) => { await idbRequest(s.delete(key));
const req = s.delete(key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (error) { } catch (error) {
console.error(`Error in remove for store ${store}, key ${key}:`, error); console.error(`Error in remove for store ${store}, key ${key}:`, error);
throw error; throw error;
@@ -340,12 +306,8 @@ export async function remove(store: string, key: string): Promise<void> {
export async function clear(store: string): Promise<void> { export async function clear(store: string): Promise<void> {
try { try {
const s = await getStore(store, "readwrite"); const s = await objectStore(store, "readwrite");
return new Promise((resolve, reject) => { await idbRequest(s.clear());
const req = s.clear();
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (error) { } catch (error) {
console.error(`Error in clear for store ${store}:`, error); console.error(`Error in clear for store ${store}:`, error);
throw error; throw error;
@@ -358,7 +320,7 @@ export async function resetDatabase(): Promise<void> {
const db = await dbPromise; const db = await dbPromise;
db.close(); db.close();
} catch { } catch {
// Database might not be open yet, that's okay // Database might not be open yet
} }
} }
@@ -1,6 +1,6 @@
import { applyStoreDiff, get, getAll, put, remove } from "./db"; import { applyStoreDiff, get, getAll, put, remove } from "./db";
import { jobs } from "./jobs"; import { jobs } from "./jobs";
import { decorateIndexItems } from "./renderComponents"; import { decorateIndexItems, publishDynamicItemsUpdate } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types"; import type { IndexItem, Job, JobContext } from "./types";
import { VectorWorkerManager } from "./worker/vectorWorkerManager"; import { VectorWorkerManager } from "./worker/vectorWorkerManager";
import { loadDynamicItems } from "../utils/dynamicItems"; import { loadDynamicItems } from "../utils/dynamicItems";
@@ -9,7 +9,7 @@ import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion";
import { resetSearchIndexes } from "./resetIndexes"; import { resetSearchIndexes } from "./resetIndexes";
import { isIndexingPaused } from "./indexingPause"; import { isIndexingPaused } from "./indexingPause";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseDebug } from '@/utils/verboseLog';
const META_STORE = "meta"; const META_STORE = "meta";
const LOCK_KEY = "bsq-indexer-lock"; const LOCK_KEY = "bsq-indexer-lock";
const HEARTBEAT_INTERVAL = 10000; const HEARTBEAT_INTERVAL = 10000;
@@ -51,7 +51,6 @@ async function ensureSchemaCurrent(): Promise<void> {
export { ensureSchemaCurrent }; export { ensureSchemaCurrent };
/* ─────────── Progressmeta helpers ─────────── */
async function loadProgress<T = any>(jobId: string): Promise<T | undefined> { async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
const rec = await get(META_STORE, `progress:${jobId}`); const rec = await get(META_STORE, `progress:${jobId}`);
return rec?.progress as T | undefined; return rec?.progress as T | undefined;
@@ -60,7 +59,6 @@ async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
async function saveProgress<T = any>(jobId: string, progress: T): Promise<void> { async function saveProgress<T = any>(jobId: string, progress: T): Promise<void> {
await put(META_STORE, { progress }, `progress:${jobId}`); await put(META_STORE, { progress }, `progress:${jobId}`);
} }
/* ───────────────────────────────────────────── */
let heartbeatTimer: ReturnType<typeof setInterval> | null = null; let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let isIndexingActive = false; let isIndexingActive = false;
@@ -145,6 +143,16 @@ async function updateLastRunMeta(jobId: string): Promise<void> {
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId); await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
} }
async function tryClaimLock(lockId: string): Promise<boolean> {
localStorage.setItem(LOCK_KEY, lockId);
await new Promise((resolve) => setTimeout(resolve, 50));
if (localStorage.getItem(LOCK_KEY) === lockId) {
isIndexingActive = true;
return true;
}
return false;
}
async function acquireLock(): Promise<boolean> { async function acquireLock(): Promise<boolean> {
if (isIndexingActive) { if (isIndexingActive) {
verboseDebug("[Indexer] Already indexing in this tab"); verboseDebug("[Indexer] Already indexing in this tab");
@@ -153,38 +161,28 @@ async function acquireLock(): Promise<boolean> {
const lockId = `${Date.now()}-${Math.random()}`; const lockId = `${Date.now()}-${Math.random()}`;
const startTime = Date.now(); const startTime = Date.now();
while (Date.now() - startTime < LOCK_ACQUIRE_TIMEOUT) { while (Date.now() - startTime < LOCK_ACQUIRE_TIMEOUT) {
const currentLock = localStorage.getItem(LOCK_KEY); const currentLock = localStorage.getItem(LOCK_KEY);
const currentTime = Date.now(); const currentTime = Date.now();
if (!currentLock) { if (!currentLock) {
localStorage.setItem(LOCK_KEY, lockId); if (await tryClaimLock(lockId)) return true;
await new Promise(resolve => setTimeout(resolve, 50));
if (localStorage.getItem(LOCK_KEY) === lockId) {
isIndexingActive = true;
return true;
}
} else { } else {
try { try {
const [timestamp] = currentLock.split('-'); const [timestamp] = currentLock.split("-");
const lockTime = parseInt(timestamp, 10); const lockTime = parseInt(timestamp, 10);
if (isNaN(lockTime) || currentTime - lockTime > LOCK_TIMEOUT) { if (isNaN(lockTime) || currentTime - lockTime > LOCK_TIMEOUT) {
localStorage.setItem(LOCK_KEY, lockId); if (await tryClaimLock(lockId)) return true;
await new Promise(resolve => setTimeout(resolve, 50));
if (localStorage.getItem(LOCK_KEY) === lockId) {
isIndexingActive = true;
return true;
}
} }
} catch (e) { } catch (e) {
console.warn("[Indexer] Error parsing lock:", e); console.warn("[Indexer] Error parsing lock:", e);
} }
} }
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
} }
return false; return false;
} }
@@ -252,6 +250,69 @@ export async function loadAllStoredItems(): Promise<IndexItem[]> {
return all; return all;
} }
function dispatchVectorProgress(
progress: {
status?: string;
total?: number;
processed?: number;
message?: string;
},
completedJobs: number,
totalSteps: number,
): number {
let detailMessage = progress.message || "";
let completed = completedJobs;
if (
progress.status === "processing" &&
progress.total &&
progress.processed !== undefined
) {
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
} else if (progress.status === "complete") {
detailMessage = "Vectorization complete";
completed++;
dispatchProgress(completed, totalSteps, false, "Indexing finished", detailMessage);
return completed;
} else if (progress.status === "error") {
dispatchProgress(
completed,
totalSteps,
false,
"Vectorization failed",
`Vectorization error: ${progress.message}`,
);
return completed;
} else if (progress.status === "cancelled") {
dispatchProgress(
completed,
totalSteps,
false,
"Vectorization cancelled",
`Vectorization cancelled: ${progress.message}`,
);
return completed;
} else if (progress.status === "started") {
detailMessage = `Vectorization started for ${progress.total} items`;
}
if (
progress.status !== "complete" &&
progress.status !== "error" &&
progress.status !== "cancelled"
) {
dispatchProgress(
completed,
totalSteps,
true,
"Vectorization in progress",
detailMessage,
);
}
return completed;
}
export async function runIndexing(): Promise<void> { export async function runIndexing(): Promise<void> {
if (isIndexingPaused()) { if (isIndexingPaused()) {
verboseDebug( verboseDebug(
@@ -415,55 +476,8 @@ export async function runIndexing(): Promise<void> {
try { try {
const workerManager = VectorWorkerManager.getInstance(); const workerManager = VectorWorkerManager.getInstance();
await workerManager.processItems(newItemsToVectorize, (progress) => { await workerManager.processItems(newItemsToVectorize, (progress) => {
let detailMessage = progress.message || ""; completedJobs = dispatchVectorProgress(progress, completedJobs, totalSteps);
if ( });
progress.status === "processing" &&
progress.total &&
progress.processed !== undefined
) {
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
} else if (progress.status === "complete") {
detailMessage = "Vectorization complete";
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Indexing finished",
detailMessage
);
} else if (progress.status === "error") {
detailMessage = `Vectorization error: ${progress.message}`;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization failed",
detailMessage,
);
} else if (progress.status === "started") {
detailMessage = `Vectorization started for ${progress.total} items`;
} else if (progress.status === "cancelled") {
detailMessage = `Vectorization cancelled: ${progress.message}`;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization cancelled",
detailMessage,
);
}
if (progress.status !== "complete" && progress.status !== "error" && progress.status !== "cancelled") {
dispatchProgress(
completedJobs,
totalSteps,
true,
"Vectorization in progress",
detailMessage,
);
}
});
verboseDebug( verboseDebug(
"%c[Indexer] Vectorization task for stored items sent to worker.", "%c[Indexer] Vectorization task for stored items sent to worker.",
"color: green", "color: green",
@@ -1,6 +1,6 @@
import type { IndexItem, Job } from "../types"; import type { IndexItem, Job } from "../types";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseDebug } from '@/utils/verboseLog';
const fetchJSON = async (url: string, body: any) => { const fetchJSON = async (url: string, body: any) => {
const res = await fetch(`${location.origin}${url}`, { const res = await fetch(`${location.origin}${url}`, {
method: "POST", method: "POST",
@@ -3,7 +3,7 @@ import { seqtaFetchPayload } from "../api";
import { buildIndexItem } from "../extract"; import { buildIndexItem } from "../extract";
import { htmlToPlainText } from "../utils"; import { htmlToPlainText } from "../utils";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseDebug } from '@/utils/verboseLog';
/** /**
* Indexes per-subject course content from `/seqta/student/load/courses`. * Indexes per-subject course content from `/seqta/student/load/courses`.
* *
@@ -1,7 +1,7 @@
import type { IndexItem, Job } from "../types"; import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api"; import { seqtaFetchPayload } from "../api";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseDebug } from '@/utils/verboseLog';
/** /**
* Indexes file metadata from `/seqta/student/load/documents`. * Indexes file metadata from `/seqta/student/load/documents`.
* *
@@ -3,7 +3,7 @@ import { seqtaFetchPayload } from "../api";
import { htmlToPlainText } from "../utils"; import { htmlToPlainText } from "../utils";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseDebug } from '@/utils/verboseLog';
/** /**
* Indexes student folio entries from `/seqta/student/folio`. * Indexes student folio entries from `/seqta/student/folio`.
* *
@@ -3,7 +3,7 @@ import { seqtaFetchPayload } from "../api";
import { extractTextFromValue } from "../extract"; import { extractTextFromValue } from "../extract";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseDebug } from '@/utils/verboseLog';
/** /**
* Indexes student goals from `/seqta/student/load/goals`. * Indexes student goals from `/seqta/student/load/goals`.
* *
@@ -2,10 +2,8 @@ import type { IndexItem, Job } from "../types";
import { htmlToPlainText } from "../utils"; import { htmlToPlainText } from "../utils";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
import { VectorWorkerManager } from "../worker/vectorWorkerManager"; import { VectorWorkerManager } from "../worker/vectorWorkerManager";
import { loadDynamicItems } from "../../utils/dynamicItems";
import { loadAllStoredItems } from "../indexer"; import { loadAllStoredItems } from "../indexer";
import { renderComponentMap } from "../renderComponents"; import { publishDynamicItemsUpdate } from "../renderComponents";
import { jobs } from "../jobs";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
const RATE_LIMIT_CONFIG = { const RATE_LIMIT_CONFIG = {
@@ -605,44 +603,10 @@ export const messagesJob: Job = {
if (processedItems.length > 0) { if (processedItems.length > 0) {
try { try {
const currentItems = await loadAllStoredItems(); publishDynamicItemsUpdate(
// Create new objects to avoid XrayWrapper issues in Firefox await loadAllStoredItems(),
const itemsWithComponents = currentItems.map((item) => { "messages",
try { processedItems.length,
const jobDef =
jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) {
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
} else if (renderComponentMap[item.renderComponentId]) {
renderComponent = renderComponentMap[item.renderComponentId];
}
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch (e) {
// Fallback to shallow copy if deep clone fails
return { ...item, renderComponent };
}
} catch (error) {
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
return item;
}
});
loadDynamicItems(itemsWithComponents);
window.dispatchEvent(
new CustomEvent("dynamic-items-updated", {
detail: {
incremental: true,
jobId: "messages",
newItemCount: processedItems.length,
streaming: true,
},
}),
); );
} catch (error) { } catch (error) {
console.warn( console.warn(
@@ -3,7 +3,7 @@ import { seqtaFetchPayload } from "../api";
import { htmlToPlainText } from "../utils"; import { htmlToPlainText } from "../utils";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseDebug } from '@/utils/verboseLog';
/** /**
* Indexes daily notices from `/seqta/student/load/notices`. * Indexes daily notices from `/seqta/student/load/notices`.
* *
@@ -3,12 +3,10 @@ import { htmlToPlainText } from "../utils";
import { fetchMessageContent } from "./messages"; import { fetchMessageContent } from "./messages";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
import { VectorWorkerManager } from "../worker/vectorWorkerManager"; import { VectorWorkerManager } from "../worker/vectorWorkerManager";
import { loadDynamicItems } from "../../utils/dynamicItems";
import { loadAllStoredItems } from "../indexer"; import { loadAllStoredItems } from "../indexer";
import { renderComponentMap } from "../renderComponents"; import { publishDynamicItemsUpdate } from "../renderComponents";
import { jobs } from "../jobs";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseLog } from '@/utils/verboseLog';
const NOTIFICATIONS_RATE_LIMIT = { const NOTIFICATIONS_RATE_LIMIT = {
baseDelay: 150, baseDelay: 150,
maxDelay: 3000, maxDelay: 3000,
@@ -373,44 +371,10 @@ export const notificationsJob: Job = {
if (items.length > 0) { if (items.length > 0) {
try { try {
const currentItems = await loadAllStoredItems(); publishDynamicItemsUpdate(
// Create new objects to avoid XrayWrapper issues in Firefox await loadAllStoredItems(),
const itemsWithComponents = currentItems.map((item) => { "notifications",
try { items.length,
const jobDef =
jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) {
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
} else if (renderComponentMap[item.renderComponentId]) {
renderComponent = renderComponentMap[item.renderComponentId];
}
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch (e) {
// Fallback to shallow copy if deep clone fails
return { ...item, renderComponent };
}
} catch (error) {
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
return item;
}
});
loadDynamicItems(itemsWithComponents);
window.dispatchEvent(
new CustomEvent("dynamic-items-updated", {
detail: {
incremental: true,
jobId: "notifications",
newItemCount: items.length,
streaming: true,
},
}),
); );
} catch (error) { } catch (error) {
console.warn( console.warn(
@@ -1,7 +1,7 @@
import type { IndexItem, Job } from "../types"; import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api"; import { seqtaFetchPayload } from "../api";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseDebug } from '@/utils/verboseLog';
/** /**
* Indexes the user's external portal entries from `/seqta/student/load/portals`. * Indexes the user's external portal entries from `/seqta/student/load/portals`.
* *
@@ -1,7 +1,7 @@
import type { IndexItem, Job } from "../types"; import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api"; import { seqtaFetchPayload } from "../api";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseDebug } from '@/utils/verboseLog';
/** /**
* Indexes report metadata from `/seqta/student/load/reports`. * Indexes report metadata from `/seqta/student/load/reports`.
* *
@@ -1,6 +1,6 @@
import type { IndexItem, Job } from "../types"; import type { IndexItem, Job } from "../types";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseDebug } from '@/utils/verboseLog';
const fetchSubjects = async () => { const fetchSubjects = async () => {
const res = await fetch(`${location.origin}/seqta/student/load/subjects`, { const res = await fetch(`${location.origin}/seqta/student/load/subjects`, {
method: "POST", method: "POST",
@@ -6,7 +6,7 @@ import {
pickId, pickId,
pickTitle, pickTitle,
} from "./extract"; } from "./extract";
import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog"; import { verboseDebug } from "@/utils/verboseLog";
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api"; import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
import { mergeDynamicItems } from "../utils/dynamicItems"; import { mergeDynamicItems } from "../utils/dynamicItems";
import { decorateIndexItems } from "./renderComponents"; import { decorateIndexItems } from "./renderComponents";
@@ -453,6 +453,28 @@ async function flushDynamicItems(): Promise<void> {
/* fetch hook */ /* fetch hook */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
async function handleCapturedPayload(
route: string,
requestBody: unknown,
payload: unknown,
): Promise<void> {
const items = synthesizeItems(
{ route, requestBody, observedAt: Date.now() },
payload,
);
if (items.length > 0) {
await persistItems(items);
}
}
function parseSeqtaPayload(json: unknown): unknown | null {
if (!json || typeof json !== "object") return null;
const body = json as { status?: string; payload?: unknown };
if (body.status && body.status !== "200") return null;
if (body.payload === undefined || body.payload === null) return null;
return body.payload;
}
async function consumeResponse( async function consumeResponse(
response: Response, response: Response,
url: string, url: string,
@@ -462,35 +484,18 @@ async function consumeResponse(
const route = normalizeSeqtaPath(url); const route = normalizeSeqtaPath(url);
if (isSensitiveSeqtaPath(route)) return; if (isSensitiveSeqtaPath(route)) return;
if (!looksLikeJsonContentType(response.headers.get("content-type"))) return;
const contentType = response.headers.get("content-type"); let body: unknown;
if (!looksLikeJsonContentType(contentType)) return;
let body: any;
try { try {
body = await response.clone().json(); body = await response.clone().json();
} catch { } catch {
return; return;
} }
if (!body || typeof body !== "object") return; const payload = parseSeqtaPayload(body);
if (body.status && body.status !== "200") return; if (payload === null) return;
await handleCapturedPayload(route, requestBody, payload);
const payload = body.payload;
if (payload === undefined || payload === null) return;
const items = synthesizeItems(
{
route,
requestBody,
observedAt: Date.now(),
},
payload,
);
if (items.length > 0) {
await persistItems(items);
}
} }
function tryParseJson(value: unknown): unknown { function tryParseJson(value: unknown): unknown {
@@ -578,31 +583,20 @@ export function installPassiveObserver(): void {
this.addEventListener("load", () => { this.addEventListener("load", () => {
try { try {
if (this.status < 200 || this.status >= 300) return; if (this.status < 200 || this.status >= 300) return;
const ct = this.getResponseHeader("content-type"); if (!looksLikeJsonContentType(this.getResponseHeader("content-type"))) {
if (!looksLikeJsonContentType(ct)) return; return;
}
const route = normalizeSeqtaPath(url); const route = normalizeSeqtaPath(url);
if (isSensitiveSeqtaPath(route)) return; if (isSensitiveSeqtaPath(route)) return;
let json: any; let json: unknown;
try { try {
json = JSON.parse(this.responseText); json = JSON.parse(this.responseText);
} catch { } catch {
return; return;
} }
if (!json || typeof json !== "object") return; const payload = parseSeqtaPayload(json);
if (json.status && json.status !== "200") return; if (payload === null) return;
const payload = json.payload; void handleCapturedPayload(route, parsed, payload);
if (payload === undefined || payload === null) return;
const items = synthesizeItems(
{
route,
requestBody: parsed,
observedAt: Date.now(),
},
payload,
);
if (items.length > 0) {
void persistItems(items);
}
} catch (e) { } catch (e) {
verboseDebug("[Passive Observer] xhr load error:", e); verboseDebug("[Passive Observer] xhr load error:", e);
} }
@@ -5,6 +5,7 @@ import SubjectItem from "../components/items/SubjectItem.svelte";
import GenericItem from "../components/items/GenericItem.svelte"; import GenericItem from "../components/items/GenericItem.svelte";
import type { IndexItem } from "./types"; import type { IndexItem } from "./types";
import { jobs } from "./jobs"; import { jobs } from "./jobs";
import { loadDynamicItems } from "../utils/dynamicItems";
export const renderComponentMap: Record<string, typeof SvelteComponent> = { export const renderComponentMap: Record<string, typeof SvelteComponent> = {
assessment: AssessmentItem as unknown as typeof SvelteComponent, assessment: AssessmentItem as unknown as typeof SvelteComponent,
@@ -58,3 +59,21 @@ export function decorateIndexItems(items: IndexItem[]): IndexItem[] {
} }
}); });
} }
export function publishDynamicItemsUpdate(
items: IndexItem[],
jobId: string,
newItemCount: number,
): void {
loadDynamicItems(decorateIndexItems(items));
window.dispatchEvent(
new CustomEvent("dynamic-items-updated", {
detail: {
incremental: true,
jobId,
newItemCount,
streaming: true,
},
}),
);
}
@@ -7,7 +7,6 @@ export const RESET_INDEX_MESSAGE = "global-search-reset-index";
let resetMessageListenerInstalled = false; let resetMessageListenerInstalled = false;
/** Notify open SEQTA tabs to pause indexing and wipe page-origin stores. */
export async function notifyOpenTabsResetSearchIndex(): Promise<void> { export async function notifyOpenTabsResetSearchIndex(): Promise<void> {
const tabs = await browser.tabs.query({}); const tabs = await browser.tabs.query({});
await Promise.allSettled( await Promise.allSettled(
@@ -19,7 +18,6 @@ export async function notifyOpenTabsResetSearchIndex(): Promise<void> {
); );
} }
/** Content scripts: handle reset broadcast from the settings popup. */
export function installResetIndexMessageListener(): void { export function installResetIndexMessageListener(): void {
if (resetMessageListenerInstalled) return; if (resetMessageListenerInstalled) return;
resetMessageListenerInstalled = true; resetMessageListenerInstalled = true;
@@ -44,36 +42,6 @@ export function installResetIndexMessageListener(): void {
}); });
} }
/**
* Hard-reset of all global-search persistence.
*
* This module is intentionally dependency-free (no imports from `db.ts`,
* the worker manager, embeddia, or any heavy bundle) so it can be
* statically imported from:
*
* - The always-loaded plugin shell (`lazy.ts`) for the manual
* "Reset Index" settings button. Statically importing means the button
* keeps working across extension updates there's no chunk hash to
* chase via dynamic import, which previously produced
* `Failed to fetch dynamically imported module: .../assets/<chunk>.js`
* when an older settings page tried to load a chunk that the new build
* had already replaced.
*
* - The version-check path (`utils/versionCheck.ts`) for the auto-reset
* that fires whenever the extension's manifest version changes.
*
* The function:
* 1. Notifies in-process modules to drop in-memory caches and any open
* IndexedDB connections via custom DOM events (best effort).
* 2. Deletes the structured `betterseqta-index` and the vector
* `embeddiaDB` databases.
* 3. Clears version-tracking localStorage keys so the next indexing
* pass treats the world as fresh.
*
* It never throws on partial failure: each step is wrapped in try/catch
* so a stuck connection on one DB doesn't block the other.
*/
const STRUCTURED_DB = "betterseqta-index"; const STRUCTURED_DB = "betterseqta-index";
const VECTOR_DB = "embeddiaDB"; const VECTOR_DB = "embeddiaDB";
const STRUCTURED_VERSION_KEY = "betterseqta-index-version"; const STRUCTURED_VERSION_KEY = "betterseqta-index-version";
@@ -137,11 +105,9 @@ export async function resetSearchIndexes(): Promise<void> {
); );
} }
} catch { } catch {
/* ignore — events are best-effort */ /* ignore */
} }
// Give listeners a tick to close any open IDB connections; otherwise
// the delete request below comes back with `onblocked`.
await delay(300); await delay(300);
await Promise.allSettled([ await Promise.allSettled([
@@ -7,7 +7,6 @@ import {
pickId, pickId,
buildIndexItem, buildIndexItem,
} from "./extract"; } from "./extract";
import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog";
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api"; import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
import { import {
coursesPayload, coursesPayload,
@@ -318,10 +317,6 @@ export async function runGlobalSearchSelfTests(): Promise<SelfTestReport> {
`[Global Search Self-Tests] ${report.failed} failed / ${report.passed} passed`, `[Global Search Self-Tests] ${report.failed} failed / ${report.passed} passed`,
report.failures, report.failures,
); );
} else {
verboseInfo(
`[Global Search Self-Tests] All ${report.passed} cases passed`,
);
} }
return report; return report;
} }
@@ -1,112 +1,82 @@
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseDebug } from '@/utils/verboseLog';
/**
* Check which items are already vectorized in embeddia's IndexedDB
* Returns a Set of item IDs that are already indexed
*/
export async function getVectorizedItemIds(): Promise<Set<string>> {
return new Promise((resolve) => {
const request = indexedDB.open("embeddiaDB");
request.onerror = () => {
verboseDebug("Could not open embeddiaDB, assuming no items are vectorized");
resolve(new Set());
};
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains("embeddiaObjectStore")) {
verboseDebug("embeddiaObjectStore not found, assuming no items are vectorized");
db.close();
resolve(new Set());
return;
}
try {
const transaction = db.transaction(["embeddiaObjectStore"], "readonly");
const store = transaction.objectStore("embeddiaObjectStore");
const getAllRequest = store.getAllKeys();
getAllRequest.onsuccess = () => {
const vectorizedIds = new Set<string>();
getAllRequest.result.forEach(key => {
if (typeof key === 'string') {
vectorizedIds.add(key);
}
});
verboseDebug(`Found ${vectorizedIds.size} already vectorized items in embeddia DB`);
db.close();
resolve(vectorizedIds);
};
getAllRequest.onerror = () => {
console.warn("Error reading vectorized item keys, assuming no items are vectorized");
db.close();
resolve(new Set());
};
} catch (error) {
console.warn("Error accessing embeddia store, assuming no items are vectorized:", error);
db.close();
resolve(new Set());
}
};
});
}
const EMBEDDIA_DB = "embeddiaDB"; const EMBEDDIA_DB = "embeddiaDB";
const EMBEDDIA_STORE = "embeddiaObjectStore"; const EMBEDDIA_STORE = "embeddiaObjectStore";
/** function openEmbeddiaDb(): Promise<IDBDatabase | null> {
* Remove vector embeddings for the given item ids from embeddiaDB.
*/
export async function removeVectorEmbeddings(ids: string[]): Promise<void> {
if (ids.length === 0) return;
return new Promise((resolve) => { return new Promise((resolve) => {
const request = indexedDB.open(EMBEDDIA_DB); const request = indexedDB.open(EMBEDDIA_DB);
request.onerror = () => resolve(null);
request.onerror = () => resolve(); request.onsuccess = () => resolve(request.result);
request.onsuccess = () => {
const db = request.result;
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
db.close();
resolve();
return;
}
try {
const transaction = db.transaction([EMBEDDIA_STORE], "readwrite");
const store = transaction.objectStore(EMBEDDIA_STORE);
for (const id of ids) {
store.delete(id);
}
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => {
db.close();
resolve();
};
} catch (error) {
console.warn("[Indexer] Failed to remove vector embeddings:", error);
db.close();
resolve();
}
};
}); });
} }
/** export async function getVectorizedItemIds(): Promise<Set<string>> {
* Delete vector embeddings that no longer exist in the structured index. const db = await openEmbeddiaDb();
* Returns the number of orphaned embeddings removed. if (!db) {
*/ verboseDebug("Could not open embeddiaDB, assuming no items are vectorized");
return new Set();
}
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
verboseDebug("embeddiaObjectStore not found, assuming no items are vectorized");
db.close();
return new Set();
}
try {
const store = db
.transaction([EMBEDDIA_STORE], "readonly")
.objectStore(EMBEDDIA_STORE);
const keys = await new Promise<IDBValidKey[]>((resolve, reject) => {
const req = store.getAllKeys();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
const vectorizedIds = new Set<string>();
for (const key of keys) {
if (typeof key === "string") vectorizedIds.add(key);
}
verboseDebug(`Found ${vectorizedIds.size} already vectorized items in embeddia DB`);
db.close();
return vectorizedIds;
} catch (error) {
console.warn("Error accessing embeddia store, assuming no items are vectorized:", error);
db.close();
return new Set();
}
}
export async function removeVectorEmbeddings(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const db = await openEmbeddiaDb();
if (!db) return;
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
db.close();
return;
}
try {
const tx = db.transaction([EMBEDDIA_STORE], "readwrite");
const store = tx.objectStore(EMBEDDIA_STORE);
for (const id of ids) {
store.delete(id);
}
await new Promise<void>((resolve) => {
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
} catch (error) {
console.warn("[Indexer] Failed to remove vector embeddings:", error);
} finally {
db.close();
}
}
export async function pruneOrphanVectorEmbeddings( export async function pruneOrphanVectorEmbeddings(
liveItemIds: Set<string>, liveItemIds: Set<string>,
): Promise<number> { ): Promise<number> {
@@ -1,7 +1,7 @@
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia"; import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
import type { IndexItem } from "../types"; import type { IndexItem } from "../types";
import { verboseDebug, verboseInfo, verboseLog } from "./workerVerboseLog"; import { verboseDebug } from "./workerVerboseLog";
let ortWasmBase: string | null = null; let ortWasmBase: string | null = null;
@@ -16,20 +16,26 @@ let initializationFailed = false;
let currentAbortController: AbortController | null = null; let currentAbortController: AbortController | null = null;
let loadedItemIds = new Set<string>(); let loadedItemIds = new Set<string>();
// Detect Firefox in worker context
function isFirefoxWorker(): boolean { function isFirefoxWorker(): boolean {
try { try {
// Check for Firefox-specific APIs or user agent return typeof navigator !== "undefined" &&
if (typeof navigator !== "undefined") { navigator.userAgent.toLowerCase().includes("firefox");
return navigator.userAgent.toLowerCase().includes("firefox");
}
// In worker context, check for Firefox-specific behavior
return false;
} catch { } catch {
return false; return false;
} }
} }
function postVectorUnavailable(message: string): void {
self.postMessage({
type: "progress",
data: { status: "complete", message },
});
}
function vectorUnavailable(): boolean {
return initializationFailed || isFirefoxWorker();
}
let streamingSession: { let streamingSession: {
isActive: boolean; isActive: boolean;
totalExpected: number; totalExpected: number;
@@ -118,31 +124,20 @@ async function startStreamingSession(
totalExpected: number, totalExpected: number,
batchSize: number = 5, batchSize: number = 5,
) { ) {
if (initializationFailed || isFirefoxWorker()) { if (vectorUnavailable()) {
self.postMessage({ postVectorUnavailable(
type: "progress", "Vector search not available in Firefox - using text search only",
data: { );
status: "complete",
message: "Vector search not available in Firefox - using text search only",
},
});
return; return;
} }
if (!vectorIndex) { if (!vectorIndex) {
console.warn( console.warn(
"Streaming requested but vector index not ready. Attempting init.", "Streaming requested but vector index not ready. Attempting init.",
); );
await initWorker(); await initWorker();
if (!vectorIndex || initializationFailed) { if (!vectorIndex || initializationFailed) {
self.postMessage({ postVectorUnavailable("Vector index not available - using text search only");
type: "progress",
data: {
status: "complete",
message:
"Vector index not available - using text search only",
},
});
return; return;
} }
} }
@@ -355,14 +350,8 @@ async function endStreamingSession() {
async function processItems(items: IndexItem[], signal: AbortSignal) { async function processItems(items: IndexItem[], signal: AbortSignal) {
verboseDebug("Worker received process request."); verboseDebug("Worker received process request.");
if (initializationFailed || isFirefoxWorker()) { if (vectorUnavailable()) {
self.postMessage({ postVectorUnavailable("Vector search not available - using text search only");
type: "progress",
data: {
status: "complete",
message: "Vector search not available - using text search only",
},
});
return; return;
} }
@@ -372,14 +361,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) {
); );
await initWorker(); await initWorker();
if (!vectorIndex || initializationFailed) { if (!vectorIndex || initializationFailed) {
self.postMessage({ postVectorUnavailable("Vector index not available - using text search only");
type: "progress",
data: {
status: "complete",
message:
"Vector index not available - using text search only",
},
});
return; return;
} }
} }
@@ -4,7 +4,7 @@ import { isVectorSearchSupported } from "../../utils/browserDetection";
import { getOrtWasmBaseUrl } from "@/lib/transformersExtension"; import { getOrtWasmBaseUrl } from "@/lib/transformersExtension";
import vectorWorker from "./vectorWorker.ts?inlineWorker"; import vectorWorker from "./vectorWorker.ts?inlineWorker";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog'; import { verboseDebug, verboseLog } from '@/utils/verboseLog';
export type ProgressCallback = (data: { export type ProgressCallback = (data: {
status: "started" | "processing" | "complete" | "error" | "cancelled"; status: "started" | "processing" | "complete" | "error" | "cancelled";
total?: number; total?: number;
@@ -3,11 +3,3 @@
export function verboseDebug(...args: unknown[]): void { export function verboseDebug(...args: unknown[]): void {
if (typeof console !== "undefined") console.debug(...args); 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);
}
@@ -12,7 +12,6 @@ function toFiniteNumber(value: unknown): number | undefined {
return undefined; return undefined;
} }
/** Same SPA destination as handlers for `course` / `subjectcourse` / passive `courses`. */
function shouldDedupeAsSameCourseSPA(item: IndexItem): boolean { function shouldDedupeAsSameCourseSPA(item: IndexItem): boolean {
if (item.actionId === "subjectassessment") return false; if (item.actionId === "subjectassessment") return false;
if (item.metadata?.type === "assessments") return false; if (item.metadata?.type === "assessments") return false;
@@ -90,7 +89,6 @@ function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem {
const bP = isPassiveLike(b); const bP = isPassiveLike(b);
if (aP && !bP) return b; if (aP && !bP) return b;
if (!aP && bP) return a; if (!aP && bP) return a;
// Prefer curated job row (courses store) vs other categories
if (a.category === "courses" && b.category !== "courses") return a; if (a.category === "courses" && b.category !== "courses") return a;
if (b.category === "courses" && a.category !== "courses") return b; if (b.category === "courses" && a.category !== "courses") return b;
if (a.renderComponentId === "course" && b.renderComponentId !== "course") if (a.renderComponentId === "course" && b.renderComponentId !== "course")
@@ -107,15 +105,12 @@ function pickBetterAssessmentDuplicate(a: IndexItem, b: IndexItem): IndexItem {
const bP = isPassiveLike(b); const bP = isPassiveLike(b);
if (aP && !bP) return b; if (aP && !bP) return b;
if (!aP && bP) return a; if (!aP && bP) return a;
if (a.category === "assignments" && b.category !== "assignments") return a; if (a.category === "assignments" && b.category !== "assignments") return a;
if (b.category === "assignments" && a.category !== "assignments") return b; if (b.category === "assignments" && a.category !== "assignments") return b;
const aPm = hasProgrammeMetaclass(a); const aPm = hasProgrammeMetaclass(a);
const bPm = hasProgrammeMetaclass(b); const bPm = hasProgrammeMetaclass(b);
if (aPm && !bPm) return a; if (aPm && !bPm) return a;
if (!aPm && bPm) return b; if (!aPm && bPm) return b;
const ad = typeof a.dateAdded === "number" ? a.dateAdded : 0; const ad = typeof a.dateAdded === "number" ? a.dateAdded : 0;
const bd = typeof b.dateAdded === "number" ? b.dateAdded : 0; const bd = typeof b.dateAdded === "number" ? b.dateAdded : 0;
return ad >= bd ? a : b; return ad >= bd ? a : b;
@@ -126,35 +121,30 @@ function pickBetterSearchDuplicate(
b: IndexItem, b: IndexItem,
key: string, key: string,
): IndexItem { ): IndexItem {
if (key.startsWith("assessment:")) { return key.startsWith("assessment:")
return pickBetterAssessmentDuplicate(a, b); ? pickBetterAssessmentDuplicate(a, b)
} : pickBetterCourseNavDuplicate(a, b);
return pickBetterCourseNavDuplicate(a, b);
} }
/** function dedupeByCanonicalKey<T>(
* Collapses multiple index rows that open the same course or assessment hash items: T[],
* route (e.g. `course` job + passive `/load/courses`, or assignments job + getKey: (item: T) => string | undefined,
* passive `/assessment/list/past`) so search shows one hit. pickWinner: (a: T, b: T, key: string) => T,
*/ ): T[] {
export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] { const winners = new Map<string, T>();
const winners = new Map<string, IndexItem>();
for (const item of items) { for (const item of items) {
const key = searchDedupeKey(item); const key = getKey(item);
if (!key) continue; if (!key) continue;
const prev = winners.get(key); const prev = winners.get(key);
winners.set( winners.set(key, prev ? pickWinner(prev, item, key) : item);
key,
prev ? pickBetterSearchDuplicate(prev, item, key) : item,
);
} }
const seenCanon = new Set<string>(); const seenCanon = new Set<string>();
const out: IndexItem[] = []; const out: T[] = [];
for (const item of items) { for (const item of items) {
const key = searchDedupeKey(item); const key = getKey(item);
if (!key) { if (!key) {
out.push(item); out.push(item);
continue; continue;
@@ -167,15 +157,15 @@ export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
return out; return out;
} }
export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
return dedupeByCanonicalKey(items, searchDedupeKey, pickBetterSearchDuplicate);
}
function dynamicSearchKey(row: CombinedResult): string | undefined { function dynamicSearchKey(row: CombinedResult): string | undefined {
if (row.type !== "dynamic") return undefined; if (row.type !== "dynamic") return undefined;
return searchDedupeKey(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 SPA route using a stale passive id.
*/
export function dedupeCombinedResultsByCourseNav( export function dedupeCombinedResultsByCourseNav(
results: CombinedResult[], results: CombinedResult[],
): CombinedResult[] { ): CombinedResult[] {
@@ -10,7 +10,7 @@ import {
isStrongLexicalMatch, isStrongLexicalMatch,
STRONG_LEXICAL_THRESHOLD, STRONG_LEXICAL_THRESHOLD,
} from "./lexicalMatch"; } from "./lexicalMatch";
import { verboseDebug, verboseInfo, verboseLog } from "@/utils/verboseLog"; import { verboseDebug } from "@/utils/verboseLog";
/** Same normalization as lexical matching (trim + lowercase). */ /** Same normalization as lexical matching (trim + lowercase). */
function normSearchKey(s: string): string { function normSearchKey(s: string): string {
@@ -63,26 +63,19 @@ function syntheticIndexFromCommand(cmd: StaticCommandItem): IndexItem {
}; };
} }
// Search result cache for better performance
const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>(); const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>();
const CACHE_TTL = 1000 * 60 * 5; // 5 minutes const CACHE_TTL = 1000 * 60 * 5;
const MAX_CACHE_SIZE = 100; const MAX_CACHE_SIZE = 100;
function getCachedResults(query: string): CombinedResult[] | null { function getCachedResults(query: string): CombinedResult[] | null {
const cached = searchCache.get(query); const cached = searchCache.get(query);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return cached && Date.now() - cached.timestamp < CACHE_TTL ? cached.results : null;
return cached.results;
}
return null;
} }
function setCachedResults(query: string, results: CombinedResult[]) { function setCachedResults(query: string, results: CombinedResult[]) {
// Limit cache size
if (searchCache.size >= MAX_CACHE_SIZE) { if (searchCache.size >= MAX_CACHE_SIZE) {
const firstKey = searchCache.keys().next().value; const firstKey = searchCache.keys().next().value;
if (firstKey !== undefined) { if (firstKey !== undefined) searchCache.delete(firstKey);
searchCache.delete(firstKey);
}
} }
searchCache.set(query, { results, timestamp: Date.now() }); searchCache.set(query, { results, timestamp: Date.now() });
} }
@@ -95,11 +88,8 @@ export function clearSearchCache(): void {
verboseDebug("[Search] Search result cache cleared"); verboseDebug("[Search] Search result cache cleared");
} }
// Listen for cache clear events (e.g., on extension update) if (typeof window !== "undefined") {
if (typeof window !== 'undefined') { window.addEventListener("betterseqta-clear-search-cache", clearSearchCache);
window.addEventListener('betterseqta-clear-search-cache', () => {
clearSearchCache();
});
} }
/** Rebuild Fuse when incremental delta exceeds this count. */ /** Rebuild Fuse when incremental delta exceeds this count. */
@@ -3,14 +3,13 @@ import type { IndexItem } from "../../indexing/types";
import type { SearchResult } from "embeddia"; import type { SearchResult } from "embeddia";
import { isVectorSearchSupported } from "../../utils/browserDetection"; import { isVectorSearchSupported } from "../../utils/browserDetection";
import { ensureTransformersEnv } from "@/lib/transformersExtension"; import { ensureTransformersEnv } from "@/lib/transformersExtension";
import { verboseDebug } from "@/utils/verboseLog";
import { verboseDebug, verboseInfo, verboseLog } from '@/utils/verboseLog';
let vectorIndex: EmbeddingIndex | null = null; let vectorIndex: EmbeddingIndex | null = null;
let initializationAttempted = false; let initializationAttempted = false;
let initializationFailed = false; let initializationFailed = false;
export async function initVectorSearch() { export async function initVectorSearch() {
// Skip initialization if already attempted and failed, or if not supported
if (initializationFailed || !isVectorSearchSupported()) { if (initializationFailed || !isVectorSearchSupported()) {
if (!isVectorSearchSupported()) { if (!isVectorSearchSupported()) {
verboseDebug("[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");
@@ -18,9 +17,7 @@ export async function initVectorSearch() {
return; return;
} }
if (initializationAttempted) { if (initializationAttempted) return;
return;
}
initializationAttempted = true; initializationAttempted = true;
@@ -41,66 +38,40 @@ export interface VectorSearchResult extends SearchResult {
object: IndexItem & { embedding: number[] }; object: IndexItem & { embedding: number[] };
} }
// Cache for query embeddings to avoid recomputing
const embeddingCache = new Map<string, number[]>(); const embeddingCache = new Map<string, number[]>();
const MAX_EMBEDDING_CACHE_SIZE = 50; const MAX_EMBEDDING_CACHE_SIZE = 50;
function getCachedEmbedding(query: string): number[] | null {
const cached = embeddingCache.get(query);
if (cached) {
return cached;
}
return null;
}
function setCachedEmbedding(query: string, embedding: number[]) { function setCachedEmbedding(query: string, embedding: number[]) {
// Limit cache size
if (embeddingCache.size >= MAX_EMBEDDING_CACHE_SIZE) { if (embeddingCache.size >= MAX_EMBEDDING_CACHE_SIZE) {
const firstKey = embeddingCache.keys().next().value; const firstKey = embeddingCache.keys().next().value;
if (firstKey !== undefined) { if (firstKey !== undefined) embeddingCache.delete(firstKey);
embeddingCache.delete(firstKey);
}
} }
embeddingCache.set(query, embedding); embeddingCache.set(query, embedding);
} }
/**
* Clears the embedding cache
*/
export function clearEmbeddingCache(): void { export function clearEmbeddingCache(): void {
embeddingCache.clear(); embeddingCache.clear();
verboseDebug("[Vector Search] Embedding cache cleared"); verboseDebug("[Vector Search] Embedding cache cleared");
} }
// Listen for cache clear events (e.g., on extension update) if (typeof window !== "undefined") {
if (typeof window !== 'undefined') { window.addEventListener("betterseqta-clear-embedding-cache", clearEmbeddingCache);
window.addEventListener('betterseqta-clear-embedding-cache', () => {
clearEmbeddingCache();
});
} }
export async function searchVectors( export async function searchVectors(
query: string, query: string,
topK: number = 20, topK: number = 20,
): Promise<VectorSearchResult[]> { ): Promise<VectorSearchResult[]> {
// Return empty array if vector search is not supported or failed to initialize if (!isVectorSearchSupported() || initializationFailed) return [];
if (!isVectorSearchSupported() || initializationFailed) {
return [];
}
if (!vectorIndex) { if (!vectorIndex) {
await initVectorSearch(); await initVectorSearch();
if (!vectorIndex) { if (!vectorIndex) return [];
return [];
}
} }
// Normalize query for caching
const normalizedQuery = query.trim().toLowerCase().slice(0, 100); const normalizedQuery = query.trim().toLowerCase().slice(0, 100);
let queryEmbedding = embeddingCache.get(normalizedQuery);
// Check cache first
let queryEmbedding = getCachedEmbedding(normalizedQuery);
if (!queryEmbedding) { if (!queryEmbedding) {
try { try {
queryEmbedding = await getEmbedding(normalizedQuery); queryEmbedding = await getEmbedding(normalizedQuery);
@@ -113,19 +84,15 @@ export async function searchVectors(
try { try {
const results = await vectorIndex!.search(queryEmbedding, { const results = await vectorIndex!.search(queryEmbedding, {
topK: Math.min(topK * 2, 30), // Get more results, filter later topK: Math.min(topK * 2, 30),
useStorage: "indexedDB", useStorage: "indexedDB",
dedupeEntries: true, dedupeEntries: true,
}); });
// Filter results with a similarity below 0.80 (slightly more permissive) return results
// and sort by similarity descending
const filteredResults = results
.filter((r) => r.similarity > 0.80) .filter((r) => r.similarity > 0.80)
.sort((a, b) => b.similarity - a.similarity) .sort((a, b) => b.similarity - a.similarity)
.slice(0, topK); .slice(0, topK) as VectorSearchResult[];
return filteredResults as VectorSearchResult[];
} catch (e) { } catch (e) {
console.warn("[Vector Search] Search failed:", e); console.warn("[Vector Search] Search failed:", e);
return []; return [];
@@ -133,14 +100,10 @@ export async function searchVectors(
} }
export async function refreshVectorCache() { export async function refreshVectorCache() {
if (!isVectorSearchSupported() || initializationFailed) { if (!isVectorSearchSupported() || initializationFailed) return;
return;
} if (!vectorIndex) await initVectorSearch();
if (!vectorIndex) {
await initVectorSearch();
}
if (vectorIndex) { if (vectorIndex) {
try { try {
vectorIndex.clearIndexedDBCache(); vectorIndex.clearIndexedDBCache();
@@ -7,11 +7,10 @@
matches?: readonly FuseResultMatch[]; matches?: readonly FuseResultMatch[];
}>(); }>();
const segments = $derived(getSegments(text, term, matches)); const segments = $derived(buildSegments(text, term, matches));
// Build highlight map (copied and adapted from highlightMatch) function buildSegments(text: string, term: string, matches = undefined) {
function getSegments(text: string, term: string, matches = undefined) { if (!term.trim() || !matches?.length) return [{ text, highlight: false }];
if (!term.trim() || !matches || matches.length === 0) return [{ text, highlight: false }];
try { try {
const fieldMatches = matches.find( const fieldMatches = matches.find(
@@ -19,39 +18,29 @@
match.key === 'text' || match.key === 'text' ||
(match.key === 'allContent' && match.value?.includes(text)), (match.key === 'allContent' && match.value?.includes(text)),
); );
if (!fieldMatches || !fieldMatches.indices || fieldMatches.indices.length === 0) { if (!fieldMatches?.indices?.length) return [{ text, highlight: false }];
return [{ text, highlight: false }];
} const highlightMap = new Array<boolean>(text.length).fill(false);
const highlightMap = new Array(text.length).fill(false); for (const [start, end] of fieldMatches.indices) {
fieldMatches.indices.forEach((indices) => {
const start = indices[0];
const end = indices[1];
if (fieldMatches.key === 'allContent') { if (fieldMatches.key === 'allContent') {
const allContent = fieldMatches.value; const textPos = fieldMatches.value?.indexOf(text) ?? -1;
const textPos = allContent?.indexOf(text) ?? -1; if (textPos < 0) continue;
if (textPos >= 0) { const relStart = start - textPos;
const relStart = start - textPos; const relEnd = end - textPos;
const relEnd = end - textPos; if (relEnd < 0 || relStart >= text.length) continue;
if (relEnd >= 0 && relStart < text.length) { for (let i = Math.max(0, relStart); i <= Math.min(text.length - 1, relEnd); i++) {
for (let i = Math.max(0, relStart); i <= Math.min(text.length - 1, relEnd); i++) { highlightMap[i] = true;
highlightMap[i] = true;
}
}
}
} else {
if (start >= 0 && end < text.length) {
for (let i = start; i <= end; i++) {
highlightMap[i] = true;
}
} }
} else if (start >= 0 && end < text.length) {
for (let i = start; i <= end; i++) highlightMap[i] = true;
} }
}); }
// Build segments
const segments: { text: string; highlight: boolean }[] = []; const segments: { text: string; highlight: boolean }[] = [];
let current = ''; let current = '';
let currentHighlight = highlightMap[0] || false; let currentHighlight = highlightMap[0] ?? false;
for (let i = 0; i < text.length; i++) { for (let i = 0; i < text.length; i++) {
const isHighlight = highlightMap[i] || false; const isHighlight = highlightMap[i] ?? false;
if (isHighlight !== currentHighlight) { if (isHighlight !== currentHighlight) {
segments.push({ text: current, highlight: currentHighlight }); segments.push({ text: current, highlight: currentHighlight });
current = ''; current = '';
@@ -59,22 +48,20 @@
} }
current += text[i]; current += text[i];
} }
if (current) { if (current) segments.push({ text: current, highlight: currentHighlight });
segments.push({ text: current, highlight: currentHighlight });
}
return segments; return segments;
} catch (e) { } catch {
return [{ text, highlight: false }]; return [{ text, highlight: false }];
} }
} }
</script> </script>
<span> <span>
{#each segments as segment} {#each segments as segment, i (i)}
{#if segment.highlight} {#if segment.highlight}
<span class="highlight">{segment.text}</span> <span class="highlight">{segment.text}</span>
{:else} {:else}
{segment.text} {segment.text}
{/if} {/if}
{/each} {/each}
</span> </span>
@@ -1,3 +1,7 @@
export function getDefaultSearchHotkey(): string {
return navigator.platform.toUpperCase().includes("MAC") ? "cmd+k" : "ctrl+k";
}
export interface ParsedHotkey { export interface ParsedHotkey {
ctrl: boolean; ctrl: boolean;
meta: boolean; meta: boolean;
@@ -1,13 +1,15 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { resetSearchIndexes } from "../indexing/resetIndexes"; 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_STORAGE_KEY = "betterseqta-global-search-version";
const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version"; const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version";
/** const isAssetLoadError = (e: unknown) => {
* Gets the current extension version from the manifest const msg = (e as { message?: string })?.message ?? "";
*/ return msg.includes("preload CSS") || msg.includes("MIME type");
};
export function getCurrentVersion(): string { export function getCurrentVersion(): string {
try { try {
return browser.runtime.getManifest().version; 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 { export function getStoredVersion(): string | null {
try { try {
return localStorage.getItem(VERSION_STORAGE_KEY); 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 { export function storeVersion(version: string): void {
try { try {
localStorage.setItem(VERSION_STORAGE_KEY, version); 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 * Checks if the extension has been updated and clears caches + resets the
* search index if needed. * search index if needed. Returns true if an update was detected.
*
* 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.
*/ */
export async function checkAndHandleUpdate(): Promise<boolean> { export async function checkAndHandleUpdate(): Promise<boolean> {
const currentVersion = getCurrentVersion(); const currentVersion = getCurrentVersion();
const storedVersion = getStoredVersion(); 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) { if (!storedVersion) {
verboseDebug( verboseDebug(`[Version Check] First run detected, storing version ${currentVersion}`);
`[Version Check] First run detected, storing version ${currentVersion}`,
);
storeVersion(currentVersion); storeVersion(currentVersion);
return false; return false;
} }
if (storedVersion === currentVersion) { if (storedVersion === currentVersion) return false;
return false;
}
verboseLog( verboseLog(
`[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, resetting search index...`, `[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, resetting search index...`,
@@ -80,57 +61,40 @@ export async function checkAndHandleUpdate(): Promise<boolean> {
try { try {
await resetSearchIndexes(); await resetSearchIndexes();
verboseLog( verboseLog("[Version Check] Search index reset; next indexing pass will repopulate from scratch.");
"[Version Check] Search index reset; next indexing pass will repopulate from scratch.",
);
} catch (e) { } catch (e) {
console.warn("[Version Check] resetSearchIndexes failed:", e); console.warn("[Version Check] resetSearchIndexes failed:", e);
} }
storeVersion(currentVersion); storeVersion(currentVersion);
return true; return true;
} }
/**
* Clears all search-related caches
*/
export async function clearAllCaches(): Promise<void> { export async function clearAllCaches(): Promise<void> {
try { try {
// Clear search result cache (in-memory Map) if (typeof window !== "undefined") {
if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent("betterseqta-clear-search-cache"));
// Dispatch event to clear caches in other modules window.dispatchEvent(new CustomEvent("betterseqta-clear-embedding-cache"));
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 () => { setTimeout(async () => {
try { try {
const { clearSearchCache } = await import("../search/searchUtils"); const { clearSearchCache } = await import("../search/searchUtils");
clearSearchCache(); clearSearchCache();
} catch (e: any) { } catch (e) {
// Module might not be loaded yet, or CSS preload error - that's okay if (!isAssetLoadError(e)) verboseDebug("[Version Check] Could not clear search cache:", e);
if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) {
verboseDebug("[Version Check] Could not clear search cache:", e);
}
} }
try { try {
const { clearEmbeddingCache } = await import("../search/vector/vectorSearch"); const { clearEmbeddingCache } = await import("../search/vector/vectorSearch");
clearEmbeddingCache(); clearEmbeddingCache();
} catch (e: any) { } catch (e) {
// Module might not be loaded yet, or CSS preload error - that's okay if (!isAssetLoadError(e)) verboseDebug("[Version Check] Could not clear embedding cache:", e);
if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) {
verboseDebug("[Version Check] Could not clear embedding cache:", e);
}
} }
}, 50); }, 50);
verboseDebug("[Version Check] All caches cleared"); verboseDebug("[Version Check] All caches cleared");
} catch (e) { } catch (e) {
console.error("[Version Check] Error clearing caches:", e); console.error("[Version Check] Error clearing caches:", e);
} }
} }
@@ -12,14 +12,11 @@ import {
MenuOptionsOpen, MenuOptionsOpen,
} from "@/seqta/utils/Openers/OpenMenuOptions"; } from "@/seqta/utils/Openers/OpenMenuOptions";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
applyMenuItemVisibility,
} from "@/seqta/utils/menuItemVisibility";
import { loadAnalyticsPage } from "../loadAnalyticsPage"; import { loadAnalyticsPage } from "../loadAnalyticsPage";
import styles from "../styles.css?inline"; import styles from "../styles.css?inline";
const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics; const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics;
const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item"; const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item";
const gradeAnalyticsPlugin: Plugin<{}> = { const gradeAnalyticsPlugin: Plugin<{}> = {
@@ -48,30 +45,21 @@ const gradeAnalyticsPlugin: Plugin<{}> = {
analyticsItem.dataset.betterseqta = "true"; analyticsItem.dataset.betterseqta = "true";
analyticsItem.innerHTML = `<label>${ANALYTICS_MENU_ICON}<span>Analytics</span></label>`; analyticsItem.innerHTML = `<label>${ANALYTICS_MENU_ICON}<span>Analytics</span></label>`;
const placeAnalyticsItem = () => { const syncAnalyticsMenu = () => {
insertMenuItemAfterKey(menuList, analyticsItem, "courses"); insertMenuItemAfterKey(menuList, analyticsItem, "courses");
ensureAnalyticsMenuOrder();
if (settingsState.menuorder.length > 0) {
ChangeMenuItemPositions(settingsState.menuorder);
}
processMenuItemNode(analyticsItem);
applyMenuItemVisibility();
}; };
placeAnalyticsItem(); syncAnalyticsMenu();
ensureAnalyticsMenuOrder();
if (settingsState.menuorder.length > 0) {
ChangeMenuItemPositions(settingsState.menuorder);
}
processMenuItemNode(analyticsItem);
applyMenuItemVisibility();
const menuObserver = new MutationObserver(() => { const menuObserver = new MutationObserver(() => {
if (MenuOptionsOpen) return; if (MenuOptionsOpen || menuList.contains(analyticsItem)) return;
if (!menuList.contains(analyticsItem)) { syncAnalyticsMenu();
placeAnalyticsItem();
ensureAnalyticsMenuOrder();
if (settingsState.menuorder.length > 0) {
ChangeMenuItemPositions(settingsState.menuorder);
}
processMenuItemNode(analyticsItem);
applyMenuItemVisibility();
}
}); });
menuObserver.observe(menuList, { childList: true }); menuObserver.observe(menuList, { childList: true });
+5 -10
View File
@@ -89,11 +89,10 @@ function syncThemeFromPage(target: HTMLElement) {
const computed = getComputedStyle(document.documentElement); const computed = getComputedStyle(document.documentElement);
for (const name of THEME_CSS_VARS) { for (const name of THEME_CSS_VARS) {
let value = computed.getPropertyValue(name).trim(); const value =
value = document.documentElement.style.getPropertyValue(name).trim(); document.documentElement.style.getPropertyValue(name).trim() ||
if (value) { computed.getPropertyValue(name).trim();
target.style.setProperty(name, value); if (value) target.style.setProperty(name, value);
}
} }
const accent = resolvePageAccentColor(); const accent = resolvePageAccentColor();
@@ -113,11 +112,7 @@ function syncThemeFromPage(target: HTMLElement) {
target.style.setProperty("--better-main", palette.accent); target.style.setProperty("--better-main", palette.accent);
target.style.setProperty("--bsplus-theme-btn-primary-bg", palette.accent); target.style.setProperty("--bsplus-theme-btn-primary-bg", palette.accent);
target.style.setProperty("--bsplus-theme-btn-primary-color", palette.onAccent); 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() { function syncThemeToAnalyticsUi() {
+11 -51
View File
@@ -32,6 +32,11 @@ import {
validateThemeDom, validateThemeDom,
validateThemeScript, validateThemeScript,
} from "./theme-runtime"; } from "./theme-runtime";
import {
base64ToBlob,
blobToBase64Data,
stripBase64Prefix,
} from "./themeImageUrl";
type ThemeContent = { type ThemeContent = {
id: string; id: string;
@@ -652,10 +657,10 @@ export class ThemeManager {
let coverImageBlob = null; let coverImageBlob = null;
if (themeData.coverImage) { if (themeData.coverImage) {
try { try {
const strippedCoverImage = this.stripBase64Prefix( coverImageBlob = base64ToBlob(
themeData.coverImage, stripBase64Prefix(themeData.coverImage),
"image/png",
); );
coverImageBlob = this.base64ToBlob(strippedCoverImage);
} catch (e) { } catch (e) {
console.warn("[ThemeManager] Failed to process cover image:", e); console.warn("[ThemeManager] Failed to process cover image:", e);
// Continue without cover image // Continue without cover image
@@ -673,7 +678,7 @@ export class ThemeManager {
} }
return { return {
...image, ...image,
blob: this.base64ToBlob(this.stripBase64Prefix(image.data)), blob: base64ToBlob(stripBase64Prefix(image.data), "image/png"),
}; };
} catch (e) { } catch (e) {
console.warn("[ThemeManager] Failed to process image:", e); console.warn("[ThemeManager] Failed to process image:", e);
@@ -858,13 +863,13 @@ export class ThemeManager {
CustomImages.map(async (image) => ({ CustomImages.map(async (image) => ({
id: image.id, id: image.id,
variableName: image.variableName, variableName: image.variableName,
data: await this.blobToBase64(image.blob), data: await blobToBase64Data(image.blob),
})), })),
); );
// Convert cover image to base64 // Convert cover image to base64
const coverImageBase64 = coverImage const coverImageBase64 = coverImage
? await this.blobToBase64(coverImage) ? await blobToBase64Data(coverImage)
: null; : null;
// Create shareable theme data with only necessary fields // 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<string> {
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 { private saveThemeFile(data: object, fileName: string): void {
try { try {
const fileData = JSON.stringify(data, null, 2); const fileData = JSON.stringify(data, null, 2);
+9 -13
View File
@@ -3,15 +3,16 @@
* blob: URLs are tied to the origin where createObjectURL ran (page), while * blob: URLs are tied to the origin where createObjectURL ran (page), while
* settings UI runs in extension shadow DOM (moz-extension://). * settings UI runs in extension shadow DOM (moz-extension://).
*/ */
import base64ToBlob from "@/seqta/utils/base64ToBlob";
export { base64ToBlob };
export function blobToDataUrl(blob: Blob): Promise<string> { export function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
if (typeof reader.result === "string") { if (typeof reader.result === "string") resolve(reader.result);
resolve(reader.result); else reject(new Error("FileReader did not return a string"));
} else {
reject(new Error("FileReader did not return a string"));
}
}; };
reader.onerror = () => reader.onerror = () =>
reject(reader.error ?? new Error("FileReader failed")); reject(reader.error ?? new Error("FileReader failed"));
@@ -27,12 +28,7 @@ export function blobToBase64Data(blob: Blob): Promise<string> {
}); });
} }
export function themeCssUrlValue(url: string): string { export function stripBase64Prefix(base64String: string): string {
return `url("${url.replace(/"/g, "%22")}")`; if (!base64String) return "";
} return base64String.replace(/^data:[^;]+;base64,/, "");
export function releaseThemeImageUrl(url: string): void {
if (url.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import type { Plugin } from "../../core/types"; 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 { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import { verboseLog } from "@/utils/verboseLog"; import { verboseLog } from "@/utils/verboseLog";
+11 -10
View File
@@ -250,13 +250,16 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff; if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
} }
const captureClick = () => { entry.addEventListener(
lastClickedCi = ci; "click",
lastClickedEntry = { roomEl, teacherEl, item }; () => {
lastSyncedQuickbarCi = null; lastClickedCi = ci;
scheduleQuickbarSync(); lastClickedEntry = { roomEl, teacherEl, item };
}; lastSyncedQuickbarCi = null;
entry.addEventListener("click", captureClick, true); scheduleQuickbarSync();
},
true,
);
}; };
const processAllEntries = () => { const processAllEntries = () => {
@@ -266,9 +269,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
}; };
const getVisibleClassQuickbar = (): HTMLElement | null => { const getVisibleClassQuickbar = (): HTMLElement | null => {
const quickbar = document.querySelector( const quickbar = document.querySelector(".timetablepage .quickbar.visible");
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.above.visible, .timetablepage .quickbar.visible",
);
if (!quickbar || quickbar.getAttribute("data-type") !== "class") return null; if (!quickbar || quickbar.getAttribute("data-type") !== "class") return null;
return quickbar as HTMLElement; return quickbar as HTMLElement;
}; };
+17 -45
View File
@@ -1,9 +1,6 @@
import type { Plugin, PluginSettings } from "./types"; import type { Plugin, PluginSettings } from "./types";
import { verboseInfo } from "@/utils/verboseLog"; import { verboseInfo } from "@/utils/verboseLog";
/**
* Interface for lazy-loaded plugin definitions
*/
export interface LazyPlugin<T extends PluginSettings = PluginSettings, S = any> { export interface LazyPlugin<T extends PluginSettings = PluginSettings, S = any> {
id: string; id: string;
name: string; name: string;
@@ -14,70 +11,45 @@ export interface LazyPlugin<T extends PluginSettings = PluginSettings, S = any>
disableToggle?: boolean; disableToggle?: boolean;
defaultEnabled?: boolean; defaultEnabled?: boolean;
beta?: boolean; beta?: boolean;
// Instead of a run function, we have a loader that imports the actual plugin
loader: () => Promise<{ default: Plugin<T, S> }>; loader: () => Promise<{ default: Plugin<T, S> }>;
} }
/** const ASSET_LOAD_ERRORS = ["MIME type", "NS_ERROR_CORRUPTED_CONTENT", "preload CSS"];
* Converts a lazy plugin into a regular plugin by wrapping the run function
* with dynamic import logic function isAssetLoadError(error: unknown): boolean {
*/ const msg = (error as { message?: string })?.message ?? "";
return ASSET_LOAD_ERRORS.some((token) => msg.includes(token));
}
export function createLazyPlugin<T extends PluginSettings = PluginSettings, S = any>( export function createLazyPlugin<T extends PluginSettings = PluginSettings, S = any>(
lazyPlugin: LazyPlugin<T, S> lazyPlugin: LazyPlugin<T, S>,
): Plugin<T, S> { ): Plugin<T, S> {
const { loader, ...meta } = lazyPlugin;
return { return {
id: lazyPlugin.id, ...meta,
name: lazyPlugin.name,
description: lazyPlugin.description,
version: lazyPlugin.version,
settings: lazyPlugin.settings,
styles: lazyPlugin.styles,
disableToggle: lazyPlugin.disableToggle,
defaultEnabled: lazyPlugin.defaultEnabled,
beta: lazyPlugin.beta,
run: async (api) => { run: async (api) => {
verboseInfo(`[BetterSEQTA+] Dynamically loading plugin "${lazyPlugin.id}"...`); verboseInfo(`[BetterSEQTA+] Dynamically loading plugin "${lazyPlugin.id}"...`);
try { try {
// Dynamically import the actual plugin implementation const { default: actualPlugin } = await loader();
const { default: actualPlugin } = await lazyPlugin.loader();
verboseInfo(`[BetterSEQTA+] Successfully loaded plugin "${lazyPlugin.id}"`); verboseInfo(`[BetterSEQTA+] Successfully loaded plugin "${lazyPlugin.id}"`);
// Execute the actual plugin's run function
return await actualPlugin.run(api); return await actualPlugin.run(api);
} catch (error: any) { } catch (error) {
const msg = error?.message ?? ""; if (isAssetLoadError(error)) {
// 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")
) {
console.error( console.error(
`[BetterSEQTA+] Failed to load plugin "${lazyPlugin.id}" due to module/asset loading restrictions. ` + `[BetterSEQTA+] Failed to load plugin "${lazyPlugin.id}" due to module/asset loading restrictions:`,
`This may be a build configuration issue. Error:`, error,
error
); );
// Don't throw - allow the extension to continue functioning without this plugin
return; return;
} }
console.error(`[BetterSEQTA+] Failed to dynamically load plugin "${lazyPlugin.id}":`, error); console.error(`[BetterSEQTA+] Failed to dynamically load plugin "${lazyPlugin.id}":`, error);
throw error; throw error;
} }
} },
}; };
} }
/**
* Helper function to create a lazy plugin definition
*/
export function defineLazyPlugin<T extends PluginSettings = PluginSettings, S = any>( export function defineLazyPlugin<T extends PluginSettings = PluginSettings, S = any>(
config: LazyPlugin<T, S> config: LazyPlugin<T, S>,
): Plugin<T, S> { ): Plugin<T, S> {
return createLazyPlugin(config); return createLazyPlugin(config);
} }
+1 -1
View File
@@ -30,7 +30,7 @@ import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue"; import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes"; import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
import { attachTimetableColorisRecovery } from "@/seqta/utils/timetableColoris"; import { attachTimetableColorisRecovery } from "@/seqta/utils/patchSeqtaMenuUpdateColours";
// JSON content // JSON content
import { observeMenuItemPosition } from "@/seqta/utils/sidebarMenuIcons"; import { observeMenuItemPosition } from "@/seqta/utils/sidebarMenuIcons";
+7 -29
View File
@@ -464,14 +464,11 @@ function GetLightDarkModeString() {
} }
async function addDarkLightToggle(parent?: Element) { 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")!; const toggleTarget = parent ?? document.getElementById("content")!;
toggleTarget.append( toggleTarget.append(
stringToHTML(/* html */ ` stringToHTML(/* html */ `
<button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton"> <button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">${settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG}</svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">${settingsState.DarkMode ? LUCIDE_SUN_ICON_SVG : LUCIDE_MOON_ICON_SVG}</svg>
<div class="tooltiptext topmenutooltip" id="darklighttooliptext">${GetLightDarkModeString()}</div> <div class="tooltiptext topmenutooltip" id="darklighttooliptext">${GetLightDarkModeString()}</div>
</button> </button>
`).firstChild!, `).firstChild!,
@@ -508,8 +505,8 @@ async function addDarkLightToggle(parent?: Element) {
const svgElement = lightDarkModeButtonElement.querySelector("svg")!; const svgElement = lightDarkModeButtonElement.querySelector("svg")!;
svgElement.innerHTML = settingsState.DarkMode svgElement.innerHTML = settingsState.DarkMode
? SUN_ICON_SVG ? LUCIDE_SUN_ICON_SVG
: MOON_ICON_SVG; : LUCIDE_MOON_ICON_SVG;
darklightText!.innerText = GetLightDarkModeString(); darklightText!.innerText = GetLightDarkModeString();
}); });
} }
@@ -553,10 +550,7 @@ function scheduleSidebarAccessibilityUpdate() {
cancelAnimationFrame(sidebarTabOrderAnimationFrame); cancelAnimationFrame(sidebarTabOrderAnimationFrame);
} }
// Double rAF: SEQTA applies `.active` / updates `.sub` on the next frame // Double rAF: SEQTA applies drill state on the next frame after click.
// after a click. Running earlier hid the submenu with `aria-hidden` while
// focus was still on a <label> inside it, which broke routing and sent
// the SPA back to home.
sidebarTabOrderAnimationFrame = requestAnimationFrame(() => { sidebarTabOrderAnimationFrame = requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
sidebarTabOrderAnimationFrame = null; sidebarTabOrderAnimationFrame = null;
@@ -619,13 +613,7 @@ function handleSidebarKeyboardActivation(event: KeyboardEvent) {
} }
} }
/** /** Folder rows on the path to the currently open sidebar list. */
* Keyboard tab order for the drilled-in sidebar only.
* SEQTA already sets `aria-hidden` on off-screen menu rows; we must not
* override that or hide `.sub` ourselves doing so while a <label> inside
* the submenu still has focus breaks SEQTA's router and navigates to home.
*/
/** Every folder row on the path to the open list (e.g. Assessments → 2026_S1). */
function getDrillFolderChain( function getDrillFolderChain(
menu: HTMLElement, menu: HTMLElement,
visibleList: HTMLElement | null, visibleList: HTMLElement | null,
@@ -722,15 +710,6 @@ function updateSidebarAccessibility() {
} }
} }
function getVisibleSidebarEntries(menu = document.getElementById("menu")) {
if (!menu) return [] as HTMLElement[];
const visibleList = getVisibleSidebarList(menu);
if (!visibleList) return [] as HTMLElement[];
return getDirectSidebarEntries(visibleList);
}
function getDirectSidebarEntries(list: HTMLElement) { function getDirectSidebarEntries(list: HTMLElement) {
return Array.from(list.querySelectorAll(":scope > li, :scope > section")).filter( return Array.from(list.querySelectorAll(":scope > li, :scope > section")).filter(
(entry): entry is HTMLElement => entry instanceof HTMLElement, (entry): entry is HTMLElement => entry instanceof HTMLElement,
@@ -764,9 +743,8 @@ function getVisibleSidebarList(menu: HTMLElement) {
} }
function getSidebarListParentEntry(list: HTMLElement) { function getSidebarListParentEntry(list: HTMLElement) {
return list.closest(".sub")?.parentElement instanceof HTMLElement const sub = list.closest(".sub");
? (list.closest(".sub")!.parentElement as HTMLElement) return sub?.parentElement instanceof HTMLElement ? sub.parentElement : null;
: null;
} }
function focusFirstSidebarSubmenuEntry(parentEntry: HTMLElement) { function focusFirstSidebarSubmenuEntry(parentEntry: HTMLElement) {
+11 -458
View File
@@ -1,15 +1,11 @@
import { animate } from "motion";
import browser from "webextension-polyfill";
import LogoLight from "@/resources/icons/betterseqta-light-icon.png"; import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat"; import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
import debounce from "@/seqta/utils/debounce";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import stringToHTML from "@/seqta/utils/stringToHTML"; import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts"; import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
import { lessonsSubtitleForViewDate } from "@/seqta/utils/Loaders/timetableSubtitle";
import { import {
type EngageParentChild, type EngageParentChild,
type EngageParentTimetableItem, type EngageParentTimetableItem,
@@ -19,10 +15,8 @@ import {
toISODate, toISODate,
weekRangeContaining, weekRangeContaining,
} from "@/seqta/utils/Loaders/engageParentTimetable"; } from "@/seqta/utils/Loaders/engageParentTimetable";
import { import { resolveNoticeFilterTokens } from "@/seqta/utils/notices/noticeLabelFilters";
noticeMatchesLabelFilter, import { setupNoticesSection } from "@/seqta/utils/notices/noticeHomeUi";
resolveNoticeFilterTokens,
} from "@/seqta/utils/notices/noticeLabelFilters";
export function updateEngageHomeMenuActive(isHome: boolean): void { export function updateEngageHomeMenuActive(isHome: boolean): void {
const home = document.getElementById("homebutton"); const home = document.getElementById("homebutton");
@@ -47,37 +41,10 @@ let engageWeekItems: EngageParentTimetableItem[] = [];
let engageSelectedStudentId: string | null = null; let engageSelectedStudentId: string | null = null;
let engageListenersCleanup: (() => void) | null = null; let engageListenersCleanup: (() => void) | null = null;
function formatDateString(date: Date): string {
return `${date.toLocaleString("en-us", { weekday: "short" })} ${date.toLocaleDateString("en-au")}`;
}
function setEngageTimetableSubtitle(): void { function setEngageTimetableSubtitle(): void {
const el = document.getElementById("engage-home-lesson-subtitle"); const el = document.getElementById("engage-home-lesson-subtitle");
if (!el) return; if (!el) return;
el.textContent = lessonsSubtitleForViewDate(engageViewDate);
const today = new Date();
const isSameMonth =
today.getFullYear() === engageViewDate.getFullYear() &&
today.getMonth() === engageViewDate.getMonth();
if (isSameMonth) {
const dayDiff = today.getDate() - engageViewDate.getDate();
switch (dayDiff) {
case 0:
el.textContent = "Today's Lessons";
break;
case 1:
el.textContent = "Yesterday's Lessons";
break;
case -1:
el.textContent = "Tomorrow's Lessons";
break;
default:
el.textContent = formatDateString(engageViewDate);
}
} else {
el.textContent = formatDateString(engageViewDate);
}
} }
function makeEngageLessonDiv( function makeEngageLessonDiv(
@@ -254,422 +221,9 @@ function bindEngageTimetableUi(): void {
}; };
} }
/* ——— Notices (duplicated from Learn `LoadHomePage`; fetch uses `/seqta/parent/load/notices`.) ——— */
const ENGAGE_NOTICE_CONTAINER_ID = "engage-notice-container"; const ENGAGE_NOTICE_CONTAINER_ID = "engage-notice-container";
const ENGAGE_NOTICES_DATE_ID = "engage-notices-date"; const ENGAGE_NOTICES_DATE_ID = "engage-notices-date";
function processEngageNoticeColor(colour: unknown): string | undefined {
if (typeof colour !== "string") return undefined;
const rgb = GetThresholdOfColor(colour);
if (rgb < 100 && settingsState.DarkMode) {
return undefined;
}
return colour;
}
function processEngageNotices(response: any, labelArray: string[]): void {
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
if (!noticeContainer) return;
noticeContainer.classList.remove("loading");
noticeContainer.innerHTML = "";
const notices = response?.payload;
if (!Array.isArray(notices)) {
appendEngageNoticeEmptyState(noticeContainer, "No notices for today.");
return;
}
if (!notices.length) {
appendEngageNoticeEmptyState(noticeContainer, "No notices for today.");
return;
}
const fragment = document.createDocumentFragment();
notices.forEach((notice: any) => {
const shouldInclude =
settingsState.mockNotices || noticeMatchesLabelFilter(notice, labelArray);
if (shouldInclude) {
const colour = processEngageNoticeColor(notice.colour);
const noticeElement = createEngageNoticeElement(notice, colour);
fragment.appendChild(noticeElement);
}
});
if (fragment.childNodes.length === 0) {
appendEngageNoticeEmptyState(noticeContainer, "No notices for today.");
return;
}
noticeContainer.appendChild(fragment);
}
function appendEngageNoticeEmptyState(container: HTMLElement, message: string) {
const emptyState = document.createElement("div");
emptyState.classList.add("day-empty");
const img = document.createElement("img");
img.src = resolveExtensionAssetUrl(LogoLight);
const text = document.createElement("p");
text.innerText = message;
emptyState.append(img, text);
container.append(emptyState);
}
function createEngageNoticeElement(
notice: any,
colour: string | undefined,
): Node {
const textPreview =
notice.contents
.replace(/<[^>]*>/g, "")
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/\s+/g, " ")
.trim()
.substring(0, 150) + (notice.contents.length > 150 ? "..." : "");
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const htmlContent = `
<div class="notice-unified-content notice-card-state" data-notice-id="${noticeId}" style="--colour: ${colour || "#8e8e8e"}; position: relative; background: var(--background-primary); cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
${notice.label_title || "General"}
</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn" style="opacity: 0; pointer-events: none;">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${textPreview}</div>
</div>`;
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
element.addEventListener("click", () =>
openEngageNoticeModal(notice, colour, element),
);
return element;
}
function openEngageNoticeModal(
notice: any,
colour: string | undefined,
sourceElement: HTMLElement,
) {
const cleanContent = notice.contents
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " ");
document.getElementById("notice-modal")?.remove();
const sourceRect = sourceElement.getBoundingClientRect();
let scrollY = Math.round(window.scrollY);
let scrollX = Math.round(window.scrollX);
let sourceLeft = sourceRect.left;
let sourceTop = sourceRect.top;
let sourceWidth = sourceRect.width;
let sourceHeight = sourceRect.height;
const modalHtml = `
<div id="notice-modal" class="notice-modal-overlay" style="opacity: 0;">
<div class="notice-modal-transition" style="
position: fixed;
left: ${sourceLeft + scrollX}px;
top: ${sourceTop + scrollY}px;
width: ${sourceWidth}px;
height: ${sourceHeight}px;
transform-origin: center;
z-index: 10001;
">
<div class="notice-modal-content notice-transitioning">
<div class="notice-unified-content notice-card-state">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
${notice.label_title || "General"}
</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
</div>
</div>
</div>
</div>`;
const modal = stringToHTML(modalHtml).firstChild as HTMLElement;
const transitionContainer = modal.querySelector(
".notice-modal-transition",
) as HTMLElement;
const unifiedContent = modal.querySelector(
".notice-unified-content",
) as HTMLElement;
const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement;
document.body.appendChild(modal);
sourceElement.setAttribute("data-transitioning", "true");
sourceElement.style.opacity = "0";
sourceElement.style.transform = "scale(0.95)";
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let targetWidth = Math.round(
Math.min(Math.max(sourceWidth, 800), viewportWidth - 40),
);
const tempMeasureDiv = document.createElement("div");
tempMeasureDiv.style.position = "absolute";
tempMeasureDiv.style.left = "-9999px";
tempMeasureDiv.style.width = targetWidth + "px";
tempMeasureDiv.style.visibility = "hidden";
tempMeasureDiv.innerHTML = `
<div class="notice-unified-content notice-modal-state" style="position: relative; width: 100%; padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1);">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge">${notice.label_title || "General"}</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
</div>
`;
document.body.appendChild(tempMeasureDiv);
const measuredHeight =
tempMeasureDiv.firstElementChild!.getBoundingClientRect().height;
document.body.removeChild(tempMeasureDiv);
let targetHeight = Math.round(
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
);
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
const closeModal = () => {
window.removeEventListener("resize", handleResize);
document.removeEventListener("keydown", handleEscape);
if (!settingsState.animations) {
modal.remove();
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
sourceElement.removeAttribute("data-transitioning");
return;
}
animate(
modal,
{
backgroundColor: ["rgba(0, 0, 0, 0.5)", "rgba(0, 0, 0, 0)"],
backdropFilter: ["blur(4px)", "blur(0px)"],
},
{ duration: 0.2 },
);
animate(
transitionContainer,
{ opacity: [1, 0] },
{ duration: 0.2, delay: 0.3 },
);
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
modal.style.pointerEvents = "none";
animate(
transitionContainer,
{
left: [targetLeft + scrollX, sourceLeft + scrollX],
top: [targetTop, sourceTop + scrollY],
width: [targetWidth, sourceWidth],
height: [targetHeight, sourceHeight],
scale: [1, 1],
},
{
duration: 0.35,
type: "spring",
stiffness: 400,
damping: 35,
},
).finished.then(async () => {
modal.remove();
sourceElement.removeAttribute("data-transitioning");
});
};
closeBtn?.addEventListener("click", closeModal);
modal?.addEventListener("click", (e) => {
if (e.target === modal) {
closeModal();
}
});
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeModal();
document.removeEventListener("keydown", handleEscape);
window.removeEventListener("resize", handleResize);
}
};
document.addEventListener("keydown", handleEscape);
const handleResize = () => {
const newSourceRect = sourceElement.getBoundingClientRect();
const newScrollY = Math.round(window.scrollY);
const newScrollX = Math.round(window.scrollX);
const computedStyle = getComputedStyle(sourceElement);
const transform = computedStyle.transform;
let scaleX = 1,
scaleY = 1;
if (transform && transform !== "none") {
const matrix = transform.match(/matrix.*\((.+)\)/);
if (matrix) {
const values = matrix[1].split(", ");
scaleX = parseFloat(values[0]);
scaleY = parseFloat(values[3]);
}
}
const newSourceWidth = newSourceRect.width / scaleX;
const newSourceHeight = newSourceRect.height / scaleY;
const deltaX = (newSourceWidth - newSourceRect.width) / 2;
const deltaY = (newSourceHeight - newSourceRect.height) / 2;
const newSourceLeft = newSourceRect.left - deltaX;
const newSourceTop = newSourceRect.top - deltaY;
const newViewportWidth = window.innerWidth;
const newViewportHeight = window.innerHeight;
const newTargetWidth = Math.round(
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
);
const currentHeight = unifiedContent.getBoundingClientRect().height;
const newTargetHeight = Math.round(
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
);
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
const newTargetTop =
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
transitionContainer.style.left =
Math.round(newTargetLeft + newScrollX) + "px";
transitionContainer.style.top = Math.round(newTargetTop) + "px";
transitionContainer.style.width = Math.round(newTargetWidth) + "px";
transitionContainer.style.height = Math.round(newTargetHeight) + "px";
sourceLeft = newSourceLeft;
sourceTop = newSourceTop;
sourceWidth = newSourceWidth;
sourceHeight = newSourceHeight;
targetLeft = newTargetLeft;
targetTop = newTargetTop;
targetWidth = newTargetWidth;
targetHeight = newTargetHeight;
scrollY = newScrollY;
scrollX = newScrollX;
};
window.addEventListener("resize", handleResize);
if (settingsState.animations) {
animate(modal, { opacity: [0, 1] }, { duration: 0.2 });
animate(
transitionContainer,
{
left: [sourceLeft + scrollX, targetLeft + scrollX],
top: [sourceTop + scrollY, targetTop],
width: [sourceWidth, targetWidth],
height: [sourceHeight, targetHeight],
scale: [1, 1],
},
{
duration: 0.5,
type: "spring",
stiffness: 280,
damping: 24,
},
);
unifiedContent.classList.remove("notice-card-state");
unifiedContent.classList.add("notice-modal-state");
} else {
modal.style.opacity = "1";
transitionContainer.style.left = Math.round(targetLeft + scrollX) + "px";
transitionContainer.style.top = Math.round(targetTop) + "px";
transitionContainer.style.width = Math.round(targetWidth) + "px";
transitionContainer.style.height = Math.round(targetHeight) + "px";
unifiedContent.classList.remove("notice-card-state");
unifiedContent.classList.add("notice-modal-state");
}
}
async function fetchEngageNoticesFromApi(
date: string,
labelTokens: string[],
): Promise<void> {
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
if (noticeContainer) {
noticeContainer.classList.add("loading");
noticeContainer.innerHTML = "";
}
try {
const data = settingsState.mockNotices
? getMockNotices()
: await (
await fetch(`${location.origin}/seqta/parent/load/notices`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({ date }),
})
).json();
processEngageNotices(data, labelTokens);
} catch (e) {
console.warn("[BetterSEQTA+] Engage notices request failed:", e);
processEngageNotices({ payload: [] }, labelTokens);
}
}
function bindEngageNoticesDateInput(
labelTokens: string[],
initialDate: string,
): () => void {
const dateControl = document.getElementById(
ENGAGE_NOTICES_DATE_ID,
) as HTMLInputElement | null;
if (!dateControl) {
return () => {};
}
dateControl.value = initialDate;
const debouncedInputChange = debounce((e: Event) => {
void fetchEngageNoticesFromApi(
(e.target as HTMLInputElement).value,
labelTokens,
);
}, 250);
dateControl.addEventListener("input", debouncedInputChange);
return () => dateControl.removeEventListener("input", debouncedInputChange);
}
async function initEngageNoticesUi(todayFormatted: string): Promise<void> { async function initEngageNoticesUi(todayFormatted: string): Promise<void> {
const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID); const noticeContainer = document.getElementById(ENGAGE_NOTICE_CONTAINER_ID);
if (!noticeContainer) return; if (!noticeContainer) return;
@@ -693,14 +247,13 @@ async function initEngageNoticesUi(todayFormatted: string): Promise<void> {
`${location.origin}/seqta/parent/load/notices`, `${location.origin}/seqta/parent/load/notices`,
); );
const dateControl = document.getElementById(ENGAGE_NOTICES_DATE_ID); const cleanup = setupNoticesSection({
if (dateControl) { containerId: ENGAGE_NOTICE_CONTAINER_ID,
(dateControl as HTMLInputElement).value = todayFormatted; dateInput: `#${ENGAGE_NOTICES_DATE_ID}`,
} noticesUrl: `${location.origin}/seqta/parent/load/notices`,
labelTokens,
await fetchEngageNoticesFromApi(todayFormatted, labelTokens); initialDate: todayFormatted,
});
const cleanup = bindEngageNoticesDateInput(labelTokens, todayFormatted);
engageMergeNoticeCleanup(cleanup); engageMergeNoticeCleanup(cleanup);
} }
+12 -450
View File
@@ -1,5 +1,4 @@
import { animate, stagger } from "motion"; import { animate, stagger } from "motion";
import browser from "webextension-polyfill";
import LogoLight from "@/resources/icons/betterseqta-light-icon.png"; import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
import assessmentsicon from "@/seqta/icons/assessmentsIcon"; import assessmentsicon from "@/seqta/icons/assessmentsIcon";
@@ -12,7 +11,6 @@ import stringToHTML from "../stringToHTML";
import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts"; import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement"; import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments"; import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip"; import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip";
import { verboseInfo } from "@/utils/verboseLog"; import { verboseInfo } from "@/utils/verboseLog";
import { import {
@@ -21,10 +19,9 @@ import {
filterAssessmentsForActiveSubjects, filterAssessmentsForActiveSubjects,
subjectsWithUpcomingAssessments, subjectsWithUpcomingAssessments,
} from "@/plugins/built-in/assessmentsOverview/utils"; } from "@/plugins/built-in/assessmentsOverview/utils";
import { import { resolveNoticeFilterTokens } from "@/seqta/utils/notices/noticeLabelFilters";
noticeMatchesLabelFilter, import { setupNoticesSection } from "@/seqta/utils/notices/noticeHomeUi";
resolveNoticeFilterTokens, import { lessonsSubtitleForViewDate } from "@/seqta/utils/Loaders/timetableSubtitle";
} from "@/seqta/utils/notices/noticeLabelFilters";
let LessonInterval: any; let LessonInterval: any;
let currentSelectedDate = new Date(); let currentSelectedDate = new Date();
@@ -141,15 +138,14 @@ export async function loadHomePage() {
`${location.origin}/seqta/student/load/notices?`, `${location.origin}/seqta/student/load/notices?`,
); );
const noticeContainer = document.getElementById("notice-container"); if (document.getElementById("notice-container")) {
if (noticeContainer) { setupNoticesSection({
const dateControl = document.querySelector( containerId: "notice-container",
'input[type="date"]', dateInput: 'input[type="date"]',
) as HTMLInputElement; noticesUrl: `${location.origin}/seqta/student/load/notices?`,
if (dateControl) { labelTokens,
dateControl.value = TodayFormatted; initialDate: TodayFormatted,
} });
setupNotices(labelTokens, TodayFormatted);
} }
return cleanup; return cleanup;
@@ -283,57 +279,6 @@ async function GetActiveClasses() {
} }
} }
function setupNotices(labelArray: string[], date: string) {
const dateControl = document.querySelector(
'input[type="date"]',
) as HTMLInputElement;
const fetchNotices = async (date: string) => {
const container = document.getElementById("notice-container");
if (container) {
container.classList.add("loading");
container.innerHTML = "";
}
try {
const data = settingsState.mockNotices
? getMockNotices()
: await (
await fetch(`${location.origin}/seqta/student/load/notices?`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({ date }),
})
).json();
processNotices(data, labelArray);
} catch {
processNotices({ payload: [] }, labelArray);
}
};
const debouncedInputChange = debounce((e: Event) => {
fetchNotices((e.target as HTMLInputElement).value);
}, 250);
dateControl?.addEventListener("input", debouncedInputChange);
fetchNotices(date);
return () => dateControl?.removeEventListener("input", debouncedInputChange);
}
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: any;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
function comparedate(obj1: any, obj2: any) { function comparedate(obj1: any, obj2: any) {
const d1 = new Date(obj1.due || obj1.date || 0).getTime(); const d1 = new Date(obj1.due || obj1.date || 0).getTime();
const d2 = new Date(obj2.due || obj2.date || 0).getTime(); const d2 = new Date(obj2.due || obj2.date || 0).getTime();
@@ -343,362 +288,6 @@ function comparedate(obj1: any, obj2: any) {
function startOfDay(date: Date): Date { function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()); return new Date(date.getFullYear(), date.getMonth(), date.getDate());
} }
function processNotices(response: any, labelArray: string[]) {
const NoticeContainer = document.getElementById("notice-container");
if (!NoticeContainer) return;
NoticeContainer.classList.remove("loading");
NoticeContainer.innerHTML = "";
const notices = response?.payload;
if (!Array.isArray(notices)) {
appendNoticeEmptyState(NoticeContainer, "No notices for today.");
return;
}
if (!notices.length) {
appendNoticeEmptyState(NoticeContainer, "No notices for today.");
return;
}
const fragment = document.createDocumentFragment();
notices.forEach((notice: any) => {
const shouldInclude =
settingsState.mockNotices || noticeMatchesLabelFilter(notice, labelArray);
if (shouldInclude) {
const colour = processNoticeColor(notice.colour);
const noticeElement = createNoticeElement(notice, colour);
fragment.appendChild(noticeElement);
}
});
if (fragment.childNodes.length === 0) {
appendNoticeEmptyState(NoticeContainer, "No notices for today.");
return;
}
NoticeContainer.appendChild(fragment);
}
function appendNoticeEmptyState(container: HTMLElement, message: string) {
const emptyState = document.createElement("div");
emptyState.classList.add("day-empty");
const img = document.createElement("img");
img.src = resolveExtensionAssetUrl(LogoLight);
const text = document.createElement("p");
text.innerText = message;
emptyState.append(img, text);
container.append(emptyState);
}
function processNoticeColor(colour: string): string | undefined {
if (typeof colour === "string") {
const rgb = GetThresholdOfColor(colour);
if (rgb < 100 && settingsState.DarkMode) {
return undefined;
}
}
return colour;
}
function createNoticeElement(notice: any, colour: string | undefined): Node {
const textPreview =
notice.contents
.replace(/<[^>]*>/g, "")
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/\s+/g, " ")
.trim()
.substring(0, 150) + (notice.contents.length > 150 ? "..." : "");
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const htmlContent = `
<div class="notice-unified-content notice-card-state" data-notice-id="${noticeId}" style="--colour: ${colour || "#8e8e8e"}; position: relative; background: var(--background-primary); cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
${notice.label_title || "General"}
</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn" style="opacity: 0; pointer-events: none;">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${textPreview}</div>
</div>`;
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
element.addEventListener("click", () =>
openNoticeModal(notice, colour, element),
);
return element;
}
function openNoticeModal(
notice: any,
colour: string | undefined,
sourceElement: HTMLElement,
) {
const cleanContent = notice.contents
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " ");
document.getElementById("notice-modal")?.remove();
const sourceRect = sourceElement.getBoundingClientRect();
let scrollY = Math.round(window.scrollY);
let scrollX = Math.round(window.scrollX);
let sourceLeft = sourceRect.left;
let sourceTop = sourceRect.top;
let sourceWidth = sourceRect.width;
let sourceHeight = sourceRect.height;
const modalHtml = `
<div id="notice-modal" class="notice-modal-overlay" style="opacity: 0;">
<div class="notice-modal-transition" style="
position: fixed;
left: ${sourceLeft + scrollX}px;
top: ${sourceTop + scrollY}px;
width: ${sourceWidth}px;
height: ${sourceHeight}px;
transform-origin: center;
z-index: 10001;
">
<div class="notice-modal-content notice-transitioning">
<div class="notice-unified-content notice-card-state">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
${notice.label_title || "General"}
</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
</div>
</div>
</div>
</div>`;
const modal = stringToHTML(modalHtml).firstChild as HTMLElement;
const transitionContainer = modal.querySelector(
".notice-modal-transition",
) as HTMLElement;
const unifiedContent = modal.querySelector(
".notice-unified-content",
) as HTMLElement;
const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement;
document.body.appendChild(modal);
sourceElement.setAttribute("data-transitioning", "true");
sourceElement.style.opacity = "0";
sourceElement.style.transform = "scale(0.95)";
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let targetWidth = Math.round(
Math.min(Math.max(sourceWidth, 800), viewportWidth - 40),
);
const tempMeasureDiv = document.createElement("div");
tempMeasureDiv.style.position = "absolute";
tempMeasureDiv.style.left = "-9999px";
tempMeasureDiv.style.width = targetWidth + "px";
tempMeasureDiv.style.visibility = "hidden";
tempMeasureDiv.innerHTML = `
<div class="notice-unified-content notice-modal-state" style="position: relative; width: 100%; padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1);">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge">${notice.label_title || "General"}</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
</div>
`;
document.body.appendChild(tempMeasureDiv);
const measuredHeight =
tempMeasureDiv.firstElementChild!.getBoundingClientRect().height;
document.body.removeChild(tempMeasureDiv);
let targetHeight = Math.round(
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
);
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
const closeModal = () => {
window.removeEventListener("resize", handleResize);
document.removeEventListener("keydown", handleEscape);
if (!settingsState.animations) {
modal.remove();
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
sourceElement.removeAttribute("data-transitioning");
return;
}
animate(
modal,
{
backgroundColor: ["rgba(0, 0, 0, 0.5)", "rgba(0, 0, 0, 0)"],
backdropFilter: ["blur(4px)", "blur(0px)"],
},
{ duration: 0.2 },
);
animate(
transitionContainer,
{ opacity: [1, 0] },
{ duration: 0.2, delay: 0.3 },
);
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
modal.style.pointerEvents = "none";
animate(
transitionContainer,
{
left: [targetLeft + scrollX, sourceLeft + scrollX],
top: [targetTop, sourceTop + scrollY],
width: [targetWidth, sourceWidth],
height: [targetHeight, sourceHeight],
scale: [1, 1],
},
{
duration: 0.35,
type: "spring",
stiffness: 400,
damping: 35,
},
).finished.then(async () => {
modal.remove();
sourceElement.removeAttribute("data-transitioning");
});
};
closeBtn?.addEventListener("click", closeModal);
modal?.addEventListener("click", (e) => {
if (e.target === modal) {
closeModal();
}
});
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeModal();
document.removeEventListener("keydown", handleEscape);
window.removeEventListener("resize", handleResize);
}
};
document.addEventListener("keydown", handleEscape);
const handleResize = () => {
const newSourceRect = sourceElement.getBoundingClientRect();
const newScrollY = Math.round(window.scrollY);
const newScrollX = Math.round(window.scrollX);
// Get the current scale applied to the source element and compensate for it
const computedStyle = getComputedStyle(sourceElement);
const transform = computedStyle.transform;
let scaleX = 1,
scaleY = 1;
if (transform && transform !== "none") {
const matrix = transform.match(/matrix.*\((.+)\)/);
if (matrix) {
const values = matrix[1].split(", ");
scaleX = parseFloat(values[0]);
scaleY = parseFloat(values[3]);
}
}
// Apply inverse scale to get true original dimensions and positions
const newSourceWidth = newSourceRect.width / scaleX;
const newSourceHeight = newSourceRect.height / scaleY;
// Calculate position shift due to center-based scaling
const deltaX = (newSourceWidth - newSourceRect.width) / 2;
const deltaY = (newSourceHeight - newSourceRect.height) / 2;
const newSourceLeft = newSourceRect.left - deltaX;
const newSourceTop = newSourceRect.top - deltaY;
const newViewportWidth = window.innerWidth;
const newViewportHeight = window.innerHeight;
const newTargetWidth = Math.round(
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
);
const currentHeight = unifiedContent.getBoundingClientRect().height;
const newTargetHeight = Math.round(
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
);
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
const newTargetTop =
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
transitionContainer.style.left =
Math.round(newTargetLeft + newScrollX) + "px";
transitionContainer.style.top = Math.round(newTargetTop) + "px";
transitionContainer.style.width = Math.round(newTargetWidth) + "px";
transitionContainer.style.height = Math.round(newTargetHeight) + "px";
sourceLeft = newSourceLeft;
sourceTop = newSourceTop;
sourceWidth = newSourceWidth;
sourceHeight = newSourceHeight;
targetLeft = newTargetLeft;
targetTop = newTargetTop;
targetWidth = newTargetWidth;
targetHeight = newTargetHeight;
scrollY = newScrollY;
scrollX = newScrollX;
};
window.addEventListener("resize", handleResize);
if (settingsState.animations) {
animate(modal, { opacity: [0, 1] }, { duration: 0.2 });
animate(
transitionContainer,
{
left: [sourceLeft + scrollX, targetLeft + scrollX],
top: [sourceTop + scrollY, targetTop],
width: [sourceWidth, targetWidth],
height: [sourceHeight, targetHeight],
scale: [1, 1],
},
{
duration: 0.5,
type: "spring",
stiffness: 280,
damping: 24,
},
);
unifiedContent.classList.remove("notice-card-state");
unifiedContent.classList.add("notice-modal-state");
} else {
modal.style.opacity = "1";
transitionContainer.style.left = Math.round(targetLeft + scrollX) + "px";
transitionContainer.style.top = Math.round(targetTop) + "px";
transitionContainer.style.width = Math.round(targetWidth) + "px";
transitionContainer.style.height = Math.round(targetHeight) + "px";
unifiedContent.classList.remove("notice-card-state");
unifiedContent.classList.add("notice-modal-state");
}
}
function callHomeTimetable(date: string, change?: any) { function callHomeTimetable(date: string, change?: any) {
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
@@ -1287,32 +876,5 @@ function CreateSubjectFilter(
function SetTimetableSubtitle() { function SetTimetableSubtitle() {
const homelessonsubtitle = document.getElementById("home-lesson-subtitle"); const homelessonsubtitle = document.getElementById("home-lesson-subtitle");
if (!homelessonsubtitle) return; if (!homelessonsubtitle) return;
homelessonsubtitle.innerText = lessonsSubtitleForViewDate(currentSelectedDate);
const date = new Date();
const isSameMonth =
date.getFullYear() === currentSelectedDate.getFullYear() &&
date.getMonth() === currentSelectedDate.getMonth();
if (isSameMonth) {
const dayDiff = date.getDate() - currentSelectedDate.getDate();
switch (dayDiff) {
case 0:
homelessonsubtitle.innerText = "Today's Lessons";
break;
case 1:
homelessonsubtitle.innerText = "Yesterday's Lessons";
break;
case -1:
homelessonsubtitle.innerText = "Tomorrow's Lessons";
break;
default:
homelessonsubtitle.innerText = formatDateString(currentSelectedDate);
}
} else {
homelessonsubtitle.innerText = formatDateString(currentSelectedDate);
}
}
function formatDateString(date: Date): string {
return `${date.toLocaleString("en-us", { weekday: "short" })} ${date.toLocaleDateString("en-au")}`;
} }
@@ -0,0 +1,27 @@
/** Shared "Today's Lessons" / relative day labels for Learn and Engage home timetables. */
export function formatTimetableDayLabel(date: Date): string {
return `${date.toLocaleString("en-us", { weekday: "short" })} ${date.toLocaleDateString("en-au")}`;
}
export function lessonsSubtitleForViewDate(viewDate: Date): string {
const today = new Date();
const isSameMonth =
today.getFullYear() === viewDate.getFullYear() &&
today.getMonth() === viewDate.getMonth();
if (isSameMonth) {
const dayDiff = today.getDate() - viewDate.getDate();
switch (dayDiff) {
case 0:
return "Today's Lessons";
case 1:
return "Yesterday's Lessons";
case -1:
return "Tomorrow's Lessons";
default:
return formatTimetableDayLabel(viewDate);
}
}
return formatTimetableDayLabel(viewDate);
}
+4 -15
View File
@@ -3,32 +3,21 @@ import { verboseInfo } from "@/utils/verboseLog";
const STYLE_ID = "bsplus-menuitem-visibility"; const STYLE_ID = "bsplus-menuitem-visibility";
function isEditSidebarOpen(): boolean {
return document.querySelector(".editmenuoption-container") != null;
}
function hideRule(menuItem: string): string {
return `li[data-key=${menuItem}],section[data-key=${menuItem}]{display:var(--menuHidden) !important;transition:1s;}`;
}
/** Whether a sidebar key is hidden via Edit Sidebar toggles. */ /** Whether a sidebar key is hidden via Edit Sidebar toggles. */
export function isMenuItemHidden(key: string): boolean { export function isMenuItemHidden(key: string): boolean {
const items = settingsState.menuitems as Record<string, { toggle?: boolean }>; const entry = (settingsState.menuitems as Record<string, { toggle?: boolean }>)?.[key];
const entry = items?.[key];
return entry != null && entry.toggle === false; return entry != null && entry.toggle === false;
} }
/** Apply hide rules from `menuitems` (re-runnable after edit / storage sync). */ /** Apply hide rules from `menuitems` (re-runnable after edit / storage sync). */
export function applyMenuItemVisibility(): void { export function applyMenuItemVisibility(): void {
if (isEditSidebarOpen()) return; if (document.querySelector(".editmenuoption-container")) return;
try { try {
let css = ""; let css = "";
for (const [menuItem, config] of Object.entries( for (const [menuItem, config] of Object.entries(settingsState.menuitems ?? {})) {
settingsState.menuitems ?? {},
)) {
if (config && !config.toggle) { if (config && !config.toggle) {
css += hideRule(menuItem); css += `li[data-key=${menuItem}],section[data-key=${menuItem}]{display:var(--menuHidden) !important;transition:1s;}`;
verboseInfo(`[BetterSEQTA+] Hiding ${menuItem} menu item`); verboseInfo(`[BetterSEQTA+] Hiding ${menuItem} menu item`);
} }
} }
+430
View File
@@ -0,0 +1,430 @@
import { animate } from "motion";
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
import debounce from "@/seqta/utils/debounce";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { noticeMatchesLabelFilter } from "@/seqta/utils/notices/noticeLabelFilters";
import stringToHTML from "@/seqta/utils/stringToHTML";
export function processNoticeColor(colour: unknown): string | undefined {
if (typeof colour !== "string") return undefined;
const rgb = GetThresholdOfColor(colour);
if (rgb < 100 && settingsState.DarkMode) {
return undefined;
}
return colour;
}
export function appendNoticeEmptyState(container: HTMLElement, message: string) {
const emptyState = document.createElement("div");
emptyState.classList.add("day-empty");
const img = document.createElement("img");
img.src = resolveExtensionAssetUrl(LogoLight);
const text = document.createElement("p");
text.innerText = message;
emptyState.append(img, text);
container.append(emptyState);
}
function createNoticeElement(notice: any, colour: string | undefined): Node {
const textPreview =
notice.contents
.replace(/<[^>]*>/g, "")
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/\s+/g, " ")
.trim()
.substring(0, 150) + (notice.contents.length > 150 ? "..." : "");
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const htmlContent = `
<div class="notice-unified-content notice-card-state" data-notice-id="${noticeId}" style="--colour: ${colour || "#8e8e8e"}; position: relative; background: var(--background-primary); cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
${notice.label_title || "General"}
</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn" style="opacity: 0; pointer-events: none;">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${textPreview}</div>
</div>`;
const element = stringToHTML(htmlContent).firstChild as HTMLElement;
element.addEventListener("click", () =>
openNoticeModal(notice, colour, element),
);
return element;
}
export function openNoticeModal(
notice: any,
colour: string | undefined,
sourceElement: HTMLElement,
) {
const cleanContent = notice.contents
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " ");
document.getElementById("notice-modal")?.remove();
const sourceRect = sourceElement.getBoundingClientRect();
let scrollY = Math.round(window.scrollY);
let scrollX = Math.round(window.scrollX);
let sourceLeft = sourceRect.left;
let sourceTop = sourceRect.top;
let sourceWidth = sourceRect.width;
let sourceHeight = sourceRect.height;
const modalHtml = `
<div id="notice-modal" class="notice-modal-overlay" style="opacity: 0;">
<div class="notice-modal-transition" style="
position: fixed;
left: ${sourceLeft + scrollX}px;
top: ${sourceTop + scrollY}px;
width: ${sourceWidth}px;
height: ${sourceHeight}px;
transform-origin: center;
z-index: 10001;
">
<div class="notice-modal-content notice-transitioning">
<div class="notice-unified-content notice-card-state">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge" style="background: linear-gradient(135deg, ${colour || "#8e8e8e"}, ${colour || "#8e8e8e"}dd); color: white;">
${notice.label_title || "General"}
</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
</div>
</div>
</div>
</div>`;
const modal = stringToHTML(modalHtml).firstChild as HTMLElement;
const transitionContainer = modal.querySelector(
".notice-modal-transition",
) as HTMLElement;
const unifiedContent = modal.querySelector(
".notice-unified-content",
) as HTMLElement;
const closeBtn = modal.querySelector(".notice-close-btn") as HTMLElement;
document.body.appendChild(modal);
sourceElement.setAttribute("data-transitioning", "true");
sourceElement.style.opacity = "0";
sourceElement.style.transform = "scale(0.95)";
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let targetWidth = Math.round(
Math.min(Math.max(sourceWidth, 800), viewportWidth - 40),
);
const tempMeasureDiv = document.createElement("div");
tempMeasureDiv.style.position = "absolute";
tempMeasureDiv.style.left = "-9999px";
tempMeasureDiv.style.width = targetWidth + "px";
tempMeasureDiv.style.visibility = "hidden";
tempMeasureDiv.innerHTML = `
<div class="notice-unified-content notice-modal-state" style="position: relative; width: 100%; padding: 16px; border: 1px solid rgba(255, 255, 255, 0.1);">
<div class="notice-header">
<div class="notice-badge-row">
<span class="notice-badge">${notice.label_title || "General"}</span>
<span class="notice-staff">${notice.staff}</span>
</div>
<button class="notice-close-btn">&times;</button>
</div>
<h2 class="notice-content-title">${notice.title}</h2>
<div class="notice-content-body">${cleanContent}</div>
</div>
`;
document.body.appendChild(tempMeasureDiv);
const measuredHeight =
tempMeasureDiv.firstElementChild!.getBoundingClientRect().height;
document.body.removeChild(tempMeasureDiv);
let targetHeight = Math.round(
Math.min(Math.max(measuredHeight + 32, 200), viewportHeight * 0.9),
);
let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
const closeModal = () => {
window.removeEventListener("resize", handleResize);
document.removeEventListener("keydown", handleEscape);
if (!settingsState.animations) {
modal.remove();
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
sourceElement.removeAttribute("data-transitioning");
return;
}
animate(
modal,
{
backgroundColor: ["rgba(0, 0, 0, 0.5)", "rgba(0, 0, 0, 0)"],
backdropFilter: ["blur(4px)", "blur(0px)"],
},
{ duration: 0.2 },
);
animate(
transitionContainer,
{ opacity: [1, 0] },
{ duration: 0.2, delay: 0.3 },
);
sourceElement.style.opacity = "1";
sourceElement.style.transform = "";
modal.style.pointerEvents = "none";
animate(
transitionContainer,
{
left: [targetLeft + scrollX, sourceLeft + scrollX],
top: [targetTop, sourceTop + scrollY],
width: [targetWidth, sourceWidth],
height: [targetHeight, sourceHeight],
scale: [1, 1],
},
{
duration: 0.35,
type: "spring",
stiffness: 400,
damping: 35,
},
).finished.then(async () => {
modal.remove();
sourceElement.removeAttribute("data-transitioning");
});
};
closeBtn?.addEventListener("click", closeModal);
modal?.addEventListener("click", (e) => {
if (e.target === modal) {
closeModal();
}
});
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeModal();
document.removeEventListener("keydown", handleEscape);
window.removeEventListener("resize", handleResize);
}
};
document.addEventListener("keydown", handleEscape);
const handleResize = () => {
const newSourceRect = sourceElement.getBoundingClientRect();
const newScrollY = Math.round(window.scrollY);
const newScrollX = Math.round(window.scrollX);
const computedStyle = getComputedStyle(sourceElement);
const transform = computedStyle.transform;
let scaleX = 1,
scaleY = 1;
if (transform && transform !== "none") {
const matrix = transform.match(/matrix.*\((.+)\)/);
if (matrix) {
const values = matrix[1].split(", ");
scaleX = parseFloat(values[0]);
scaleY = parseFloat(values[3]);
}
}
const newSourceWidth = newSourceRect.width / scaleX;
const newSourceHeight = newSourceRect.height / scaleY;
const deltaX = (newSourceWidth - newSourceRect.width) / 2;
const deltaY = (newSourceHeight - newSourceRect.height) / 2;
const newSourceLeft = newSourceRect.left - deltaX;
const newSourceTop = newSourceRect.top - deltaY;
const newViewportWidth = window.innerWidth;
const newViewportHeight = window.innerHeight;
const newTargetWidth = Math.round(
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
);
const currentHeight = unifiedContent.getBoundingClientRect().height;
const newTargetHeight = Math.round(
Math.min(Math.max(currentHeight + 32, 200), newViewportHeight * 0.9),
);
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
const newTargetTop =
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
transitionContainer.style.left =
Math.round(newTargetLeft + newScrollX) + "px";
transitionContainer.style.top = Math.round(newTargetTop) + "px";
transitionContainer.style.width = Math.round(newTargetWidth) + "px";
transitionContainer.style.height = Math.round(newTargetHeight) + "px";
sourceLeft = newSourceLeft;
sourceTop = newSourceTop;
sourceWidth = newSourceWidth;
sourceHeight = newSourceHeight;
targetLeft = newTargetLeft;
targetTop = newTargetTop;
targetWidth = newTargetWidth;
targetHeight = newTargetHeight;
scrollY = newScrollY;
scrollX = newScrollX;
};
window.addEventListener("resize", handleResize);
if (settingsState.animations) {
animate(modal, { opacity: [0, 1] }, { duration: 0.2 });
animate(
transitionContainer,
{
left: [sourceLeft + scrollX, targetLeft + scrollX],
top: [sourceTop + scrollY, targetTop],
width: [sourceWidth, targetWidth],
height: [sourceHeight, targetHeight],
scale: [1, 1],
},
{
duration: 0.5,
type: "spring",
stiffness: 280,
damping: 24,
},
);
unifiedContent.classList.remove("notice-card-state");
unifiedContent.classList.add("notice-modal-state");
} else {
modal.style.opacity = "1";
transitionContainer.style.left = Math.round(targetLeft + scrollX) + "px";
transitionContainer.style.top = Math.round(targetTop) + "px";
transitionContainer.style.width = Math.round(targetWidth) + "px";
transitionContainer.style.height = Math.round(targetHeight) + "px";
unifiedContent.classList.remove("notice-card-state");
unifiedContent.classList.add("notice-modal-state");
}
}
export function renderNoticesIntoContainer(
containerId: string,
response: { payload?: unknown },
labelTokens: string[],
emptyMessage = "No notices for today.",
): void {
const noticeContainer = document.getElementById(containerId);
if (!noticeContainer) return;
noticeContainer.classList.remove("loading");
noticeContainer.innerHTML = "";
const notices = response?.payload;
if (!Array.isArray(notices) || !notices.length) {
appendNoticeEmptyState(noticeContainer, emptyMessage);
return;
}
const fragment = document.createDocumentFragment();
notices.forEach((notice: any) => {
const shouldInclude =
settingsState.mockNotices || noticeMatchesLabelFilter(notice, labelTokens);
if (shouldInclude) {
const colour = processNoticeColor(notice.colour);
fragment.appendChild(createNoticeElement(notice, colour));
}
});
if (fragment.childNodes.length === 0) {
appendNoticeEmptyState(noticeContainer, emptyMessage);
return;
}
noticeContainer.appendChild(fragment);
}
export async function fetchNoticesForDate(
containerId: string,
date: string,
noticesUrl: string,
labelTokens: string[],
): Promise<void> {
const container = document.getElementById(containerId);
if (container) {
container.classList.add("loading");
container.innerHTML = "";
}
try {
const data = settingsState.mockNotices
? getMockNotices()
: await (
await fetch(noticesUrl, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({ date }),
})
).json();
renderNoticesIntoContainer(containerId, data, labelTokens);
} catch {
renderNoticesIntoContainer(containerId, { payload: [] }, labelTokens);
}
}
export type SetupNoticesSectionOptions = {
containerId: string;
dateInput: HTMLInputElement | string;
noticesUrl: string;
labelTokens: string[];
initialDate: string;
};
/** Wire date picker + initial fetch for a home-page notices block. Returns cleanup. */
export function setupNoticesSection(options: SetupNoticesSectionOptions): () => void {
const dateControl =
typeof options.dateInput === "string"
? (document.querySelector(options.dateInput) as HTMLInputElement | null)
: options.dateInput;
if (dateControl) {
dateControl.value = options.initialDate;
}
const debouncedInputChange = debounce((e: Event) => {
void fetchNoticesForDate(
options.containerId,
(e.target as HTMLInputElement).value,
options.noticesUrl,
options.labelTokens,
);
}, 250);
dateControl?.addEventListener("input", debouncedInputChange);
void fetchNoticesForDate(
options.containerId,
options.initialDate,
options.noticesUrl,
options.labelTokens,
);
return () => dateControl?.removeEventListener("input", debouncedInputChange);
}
+90 -17
View File
@@ -1,29 +1,19 @@
/** /**
* SEQTA Learn bug (vanilla too): MainMenu.updateColours uses * Timetable colour save recovery (#221): broken menu.update.colours in PAGE context
* `.each(function (item) { this.options... }).bind(this)` the bind is on * (injected script) plus Coloris / overlay cleanup in the content script.
* `.each()`'s return value, not the callback. Saving a timetable subject colour
* sends `menu.update.colours` and throws.
*
* Also: ColourChooser (SlidePane + Modaliser) can leave a full-screen
* uiSlidePane / empty modaliser-container that blocks timetable clicks.
*
* Must run in the PAGE JavaScript context inject via web_accessible script URL.
*/ */
import browser from "webextension-polyfill";
import patchScript from "@/seqta/utils/seqtaMenuColourPatch.js?url"; import patchScript from "@/seqta/utils/seqtaMenuColourPatch.js?url";
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
import { verboseInfo } from "@/utils/verboseLog";
const PAGE_PATCH_LOADER_ID = "bsplus-seqta-menu-colour-patch-loader"; const PAGE_PATCH_LOADER_ID = "bsplus-seqta-menu-colour-patch-loader";
/** Remove empty or hidden modaliser shells left after colour dialog teardown. */
export function dismissStaleModaliserContainers(): number { export function dismissStaleModaliserContainers(): number {
let removed = 0; let removed = 0;
for (const container of document.querySelectorAll(".modaliser-container")) { for (const container of document.querySelectorAll(".modaliser-container")) {
const modal = container.querySelector(".modaliser"); const modal = container.querySelector(".modaliser");
const empty = !modal || modal.childElementCount === 0; if (!modal?.childElementCount || !container.classList.contains("visible")) {
const hidden = !container.classList.contains("visible");
if (empty || hidden) {
container.remove(); container.remove();
removed++; removed++;
} }
@@ -31,14 +21,12 @@ export function dismissStaleModaliserContainers(): number {
return removed; return removed;
} }
/** Remove stuck SEQTA colour chooser slide panes that intercept timetable clicks. */
export function dismissStaleColourSlidePanes( export function dismissStaleColourSlidePanes(
forceColourChooser = false, forceColourChooser = false,
): number { ): number {
let removed = 0; let removed = 0;
for (const pane of document.querySelectorAll(".uiSlidePane")) { for (const pane of document.querySelectorAll(".uiSlidePane")) {
const isColourChooser = pane.querySelector(".pane.colourChooser"); if (pane.querySelector(".pane.colourChooser")) {
if (isColourChooser) {
pane.remove(); pane.remove();
removed++; removed++;
continue; continue;
@@ -63,6 +51,91 @@ export function dismissStaleColourDialogs(forceColourChooser = false): {
return { slideRemoved, modalRemoved }; return { slideRemoved, modalRemoved };
} }
function setClrPickerState(reset: boolean): void {
document.body.classList.remove("clr-open");
document.documentElement.classList.remove("clr-open");
for (const picker of document.querySelectorAll(".clr-picker")) {
picker.classList.remove("clr-open");
if (!(picker instanceof HTMLElement)) continue;
if (reset) {
picker.style.removeProperty("display");
picker.style.removeProperty("pointer-events");
picker.style.removeProperty("visibility");
} else {
picker.style.display = "none";
picker.style.pointerEvents = "none";
picker.style.visibility = "hidden";
}
}
}
/** Hide colour-picker / modal layers that intercept clicks after a colour save. */
export function dismissTimetableUiBlockers(): {
slideRemoved: number;
modalRemoved: number;
} {
document.body.style.removeProperty("overflow");
setClrPickerState(false);
return dismissStaleColourDialogs();
}
/** Clear inline styles that can prevent Coloris from reopening. */
export function prepareColorisPickerOpen(): void {
setClrPickerState(true);
}
let colorisRecoveryAttached = false;
let dismissTimer: ReturnType<typeof setTimeout> | null = null;
export function attachTimetableColorisRecovery(): void {
if (colorisRecoveryAttached) return;
colorisRecoveryAttached = true;
const scheduleDismiss = () => {
if (dismissTimer !== null) clearTimeout(dismissTimer);
dismissTimer = setTimeout(() => {
dismissTimer = null;
dismissTimetableUiBlockers();
}, 100);
};
document.addEventListener("coloris:close", scheduleDismiss);
document.addEventListener("coloris:pick", scheduleDismiss);
document.addEventListener(
"click",
(event) => {
const target = event.target as HTMLElement;
if (!target.closest(".timetablepage")) return;
if (target.closest("[title='Choose a colour']")) {
if (dismissTimer !== null) {
clearTimeout(dismissTimer);
dismissTimer = null;
}
prepareColorisPickerOpen();
return;
}
if (!target.closest(".entry")) return;
const pickerOpen =
document.body.classList.contains("clr-open") &&
document.querySelector(".clr-picker.clr-open");
if (!pickerOpen) {
const result = dismissTimetableUiBlockers();
if (result.slideRemoved > 0 || result.modalRemoved > 0) {
verboseInfo(
"[BetterSEQTA+] timetable colour: content-script cleanup",
result,
);
}
}
},
true,
);
}
export function installSeqtaMenuColourPatch(): void { export function installSeqtaMenuColourPatch(): void {
if (document.getElementById(PAGE_PATCH_LOADER_ID)) return; if (document.getElementById(PAGE_PATCH_LOADER_ID)) return;
+6 -34
View File
@@ -46,41 +46,25 @@ function ensureBridgeElements(): void {
} }
} }
function bumpBridgeRevision(): void {
const bridge = document.getElementById(BRIDGE_ID);
if (!bridge) return;
const rev = Number(bridge.getAttribute("data-rev") || "0") + 1;
bridge.setAttribute("data-rev", String(rev));
}
function sendPayload(payload: Record<string, unknown>): void { function sendPayload(payload: Record<string, unknown>): void {
installThemeImagePagePatch(); installThemeImagePagePatch();
ensureBridgeElements(); ensureBridgeElements();
const payloadEl = document.getElementById(PAYLOAD_ID) as HTMLTextAreaElement; const payloadEl = document.getElementById(PAYLOAD_ID) as HTMLTextAreaElement;
payloadEl.value = JSON.stringify(payload); payloadEl.value = JSON.stringify(payload);
bumpBridgeRevision(); const bridge = document.getElementById(BRIDGE_ID)!;
bridge.setAttribute("data-rev", String(Number(bridge.getAttribute("data-rev") || "0") + 1));
} }
export async function syncThemeToPage(input: ThemePageSyncInput): Promise<void> { export async function syncThemeToPage(input: ThemePageSyncInput): Promise<void> {
const payload: Record<string, unknown> = {};
if (input.clear) { if (input.clear) {
sendPayload({ clear: true }); sendPayload({ clear: true });
return; return;
} }
if (input.clearPreview) { const payload: Record<string, unknown> = {};
payload.clearPreview = true; if (input.clearPreview) payload.clearPreview = true;
} if (input.customCss !== undefined) payload.customCss = input.customCss;
if (input.previewCss !== undefined) payload.previewCss = input.previewCss;
if (input.customCss !== undefined) {
payload.customCss = input.customCss;
}
if (input.previewCss !== undefined) {
payload.previewCss = input.previewCss;
}
if (input.images !== undefined) { if (input.images !== undefined) {
payload.images = await Promise.all( payload.images = await Promise.all(
input.images.map(async (image) => ({ input.images.map(async (image) => ({
@@ -98,15 +82,3 @@ export async function syncThemeToPage(input: ThemePageSyncInput): Promise<void>
export function clearThemeInPage(): void { export function clearThemeInPage(): void {
sendPayload({ clear: true }); sendPayload({ clear: true });
} }
/** @deprecated Use clearThemeInPage */
export function clearThemeImagesInPage(): void {
clearThemeInPage();
}
/** @deprecated Use syncThemeToPage */
export async function syncThemeImagesToPage(
images: Array<{ variableName: string; blob: Blob }>,
): Promise<void> {
await syncThemeToPage({ images });
}
+105 -274
View File
@@ -1,121 +1,95 @@
/** /**
* PAGE context only patches SEQTA menu.update.colours and removes stuck colour-dialog * PAGE context patches broken menu.update.colours and removes stuck colour-dialog layers.
* layers (uiSlidePane + modaliser) that block timetable entry clicks after a colour save.
*/ */
(function () { (function () {
if (window.__bsplusMenuColoursPatched) return; if (window.__bsplusMenuColoursPatched) return;
var LOG = "[BetterSEQTA+] timetable colour:";
var MENU_UPDATE_COLOURS = "menu.update.colours"; var MENU_UPDATE_COLOURS = "menu.update.colours";
var SUBJECT_COLOUR_PREF_PREFIX = "timetable.subject.colour."; var SUBJECT_PREFIX = "timetable.subject.colour.";
var TUTOR_COLOUR_PREF_PREFIX = "timetable.tutor."; var TUTOR_PREFIX = "timetable.tutor.";
function log(event, detail) { function isTesStyling() {
if (!document.documentElement.hasAttribute("data-bsplus-verbose-log")) return; var el = document.getElementById("logo-style");
if (detail !== undefined) { return el && el.textContent.indexOf("tesSeqta") !== -1;
console.info(LOG, event, detail); }
} else {
console.info(LOG, event); function dismissModalisers() {
var n = 0;
var containers = document.querySelectorAll(".modaliser-container");
for (var i = 0; i < containers.length; i++) {
var c = containers[i];
var m = c.querySelector(".modaliser");
if (!m || !m.childElementCount || !c.classList.contains("visible")) {
c.remove();
n++;
}
}
return n;
}
function dismissSlidePanes(forceColour) {
var n = 0;
var panes = document.querySelectorAll(".uiSlidePane");
for (var i = 0; i < panes.length; i++) {
var p = panes[i];
if (p.querySelector(".pane.colourChooser")) {
p.remove();
n++;
continue;
}
if (!forceColour && p.classList.contains("shown")) continue;
if (!p.classList.contains("shown")) {
p.remove();
n++;
}
}
return n;
}
function dismissStaleDialogs(forceColour) {
var slide = dismissSlidePanes(forceColour);
var modal = dismissModalisers();
document.body.classList.remove("clr-open");
document.documentElement.classList.remove("clr-open");
return { slideRemoved: slide, modalRemoved: modal };
}
function scheduleCleanup() {
var delays = [0, 100, 300, 600];
for (var i = 0; i < delays.length; i++) {
(function (d) {
setTimeout(function () {
dismissStaleDialogs(true);
}, d);
})(delays[i]);
} }
} }
function isTesStylingEnabled() { function applyMenuColours() {
var logoStyle = document.getElementById("logo-style");
return logoStyle && logoStyle.textContent.indexOf("tesSeqta") !== -1;
}
function countOverlayState() {
return {
slidePanes: document.querySelectorAll(".uiSlidePane").length,
slidePanesShown: document.querySelectorAll(".uiSlidePane.shown").length,
colourChoosers: document.querySelectorAll(
".uiSlidePane .pane.colourChooser",
).length,
modalisers: document.querySelectorAll(".modaliser-container").length,
modalisersVisible: document.querySelectorAll(
".modaliser-container.visible",
).length,
quickbarsVisible: document.querySelectorAll(
".timetablepage .quickbar.visible",
).length,
};
}
function applyMenuSubjectColours() {
if (!window.user) return; if (!window.user) return;
var defaultColour = isTesStylingEnabled() ? "#2b3547" : "#dddddd"; var def = isTesStyling() ? "#2b3547" : "#dddddd";
var items = document.querySelectorAll("#menu li[data-colour]"); var items = document.querySelectorAll("#menu li[data-colour]");
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
var item = items[i]; var item = items[i];
var prefName = item.getAttribute("data-colour"); var prefName = item.getAttribute("data-colour");
if (!prefName) continue; if (!prefName) continue;
var pref = window.user.getPreference(prefName); var pref = window.user.getPreference(prefName);
var colour = (pref && pref.value) || defaultColour; item.style.setProperty("--item-colour", (pref && pref.value) || def);
item.style.setProperty("--item-colour", colour);
} }
} }
function dismissStaleModaliserContainers() { function reconcileQuickbars() {
var removed = 0;
var containers = document.querySelectorAll(".modaliser-container");
for (var i = 0; i < containers.length; i++) {
var container = containers[i];
var modal = container.querySelector(".modaliser");
var empty = !modal || modal.childElementCount === 0;
var hidden = !container.classList.contains("visible");
if (empty || hidden) {
container.remove();
removed++;
}
}
return removed;
}
function dismissStaleColourSlidePanes(forceColourChooser) {
var removed = 0;
var panes = document.querySelectorAll(".uiSlidePane");
for (var i = 0; i < panes.length; i++) {
var pane = panes[i];
var isColourChooser = pane.querySelector(".pane.colourChooser");
if (isColourChooser) {
pane.remove();
removed++;
continue;
}
if (!forceColourChooser && pane.classList.contains("shown")) continue;
if (!pane.classList.contains("shown")) {
pane.remove();
removed++;
}
}
return removed;
}
function dismissStaleColourDialogs(forceColourChooser) {
var slideRemoved = dismissStaleColourSlidePanes(forceColourChooser);
var modalRemoved = dismissStaleModaliserContainers();
document.body.classList.remove("clr-open");
document.documentElement.classList.remove("clr-open");
return {
slideRemoved: slideRemoved,
modalRemoved: modalRemoved,
overlays: countOverlayState(),
};
}
function reconcileStuckQuickbars(reason) {
var fixed = 0; var fixed = 0;
var quickbars = document.querySelectorAll(".timetablepage .quickbar.visible"); var quickbars = document.querySelectorAll(".timetablepage .quickbar.visible");
for (var i = 0; i < quickbars.length; i++) { for (var i = 0; i < quickbars.length; i++) {
var qb = quickbars[i]; var qb = quickbars[i];
var wrapper = qb.querySelector(".wrapper"); var w = qb.querySelector(".wrapper");
if (!wrapper || !wrapper.childElementCount) { if (!w || !w.childElementCount) {
qb.classList.remove("visible"); qb.classList.remove("visible");
fixed++; fixed++;
} }
} }
if (fixed > 0) { if (fixed) {
log("cleared stuck quickbar shell (" + reason + ")", { fixed: fixed });
try { try {
window.msg.send("calendar.quickbar.hide"); window.msg.send("calendar.quickbar.hide");
} catch (err) { } catch (err) {
@@ -125,30 +99,29 @@
return fixed; return fixed;
} }
function findEntryElement(calendarId, code) { function findEntry(calendarId, code) {
if (calendarId) { if (calendarId) {
var byCalendar = document.querySelector( var byId = document.querySelector(
".timetablepage .entry[data-calendarid=\"" + calendarId + "\"]", ".timetablepage .entry[data-calendarid=\"" + calendarId + "\"]",
); );
if (byCalendar) return byCalendar; if (byId) return byId;
} }
if (code) { if (!code) return null;
var entries = document.querySelectorAll(".timetablepage .entry.class"); var entries = document.querySelectorAll(".timetablepage .entry.class");
for (var i = 0; i < entries.length; i++) { for (var i = 0; i < entries.length; i++) {
var entry = entries[i]; var entry = entries[i];
var titleEl = entry.querySelector(".title"); var titleEl = entry.querySelector(".title");
var title = var title =
titleEl && titleEl.textContent ? titleEl.textContent.trim() : ""; titleEl && titleEl.textContent ? titleEl.textContent.trim() : "";
if (title && title.indexOf(code) !== -1) return entry; if (title && title.indexOf(code) !== -1) return entry;
}
} }
return null; return null;
} }
function normalizeQuickbarOpenContext(contents) { function normalizeQuickbarContext(contents) {
if (!contents) return contents; if (!contents) return contents;
reconcileStuckQuickbars("before-open"); reconcileQuickbars();
var element = contents.element; var element = contents.element;
var calendarId = var calendarId =
@@ -162,190 +135,74 @@
document.contains(element); document.contains(element);
if (!connected) { if (!connected) {
var replacement = findEntryElement(calendarId, code); var replacement = findEntry(calendarId, code);
if (replacement) { if (replacement) contents.element = replacement;
contents.element = replacement;
log("replaced detached quickbar entry element", {
calendarId: calendarId,
code: code,
});
} else {
log("quickbar entry element detached, no replacement found", {
calendarId: calendarId,
code: code,
});
}
} }
return contents; return contents;
} }
function logQuickbarOpenResult(phase) { function isColourPref(handle) {
var qb = document.querySelector(".timetablepage .quickbar.visible");
var wrapper = qb && qb.querySelector(".wrapper");
log("quickbar open result (" + phase + ")", {
visible: !!qb,
hasWrapper: !!wrapper,
wrapperChildren: wrapper ? wrapper.childElementCount : 0,
overlays: countOverlayState(),
});
}
function scheduleColourDialogCleanup(reason) {
var delays = [0, 100, 300, 600];
for (var i = 0; i < delays.length; i++) {
(function (delay) {
setTimeout(function () {
var result = dismissStaleColourDialogs(true);
if (
result.slideRemoved > 0 ||
result.modalRemoved > 0 ||
delay === 0
) {
log("cleanup (" + reason + ", +" + delay + "ms)", result);
}
}, delay);
})(delays[i]);
}
}
function isSubjectOrTutorColourPref(handle) {
return ( return (
typeof handle === "string" && typeof handle === "string" &&
(handle.indexOf(SUBJECT_COLOUR_PREF_PREFIX) === 0 || (handle.indexOf(SUBJECT_PREFIX) === 0 || handle.indexOf(TUTOR_PREFIX) === 0)
handle.indexOf(TUTOR_COLOUR_PREF_PREFIX) === 0)
); );
} }
function runMenuColourUpdate() { function onMenuColourUpdate() {
log("menu.update.colours intercepted", countOverlayState());
try { try {
applyMenuSubjectColours(); applyMenuColours();
} catch (err) { } catch (err) {
console.error("[BetterSEQTA+] menu.update.colours failed:", err); console.error("[BetterSEQTA+] menu.update.colours failed:", err);
} }
scheduleColourDialogCleanup("menu.update.colours"); scheduleCleanup();
reconcileStuckQuickbars("after-colour-save"); reconcileQuickbars();
} }
function fixedMenuColourHandler() { function neutralizeBrokenListeners(msg) {
runMenuColourUpdate();
}
function neutralizeBrokenMenuColourListeners(msg) {
var listeners = msg.listeners && msg.listeners[MENU_UPDATE_COLOURS]; var listeners = msg.listeners && msg.listeners[MENU_UPDATE_COLOURS];
if (!listeners) return; if (!listeners) return;
for (var i = 0; i < listeners.length; i++) { for (var i = 0; i < listeners.length; i++) {
if (listeners[i]) { if (listeners[i]) listeners[i].fn = onMenuColourUpdate;
listeners[i].fn = fixedMenuColourHandler;
}
} }
} }
function patchMsg(msg) { function patchMsg(msg) {
if (!msg || msg.__bsplusPatched) return; if (!msg || msg.__bsplusPatched) return;
var originalSend = msg.send.bind(msg); var send = msg.send.bind(msg);
var register = msg.register.bind(msg);
msg.send = function (handle, contents, suppressLogs, noRecord) { msg.send = function (handle, contents, suppressLogs, noRecord) {
if (handle === MENU_UPDATE_COLOURS) { if (handle === MENU_UPDATE_COLOURS) {
runMenuColourUpdate(); onMenuColourUpdate();
return; return;
} }
if (isColourPref(handle)) {
if (isSubjectOrTutorColourPref(handle)) { var result = send(handle, contents, suppressLogs, noRecord);
log("colour pref save detected", { scheduleCleanup();
pref: handle, reconcileQuickbars();
colour: contents, return result;
overlays: countOverlayState(), }
}); if (handle === "calendar.quickbar.class") {
var prefResult = originalSend( return send(
handle, handle,
contents, normalizeQuickbarContext(contents),
suppressLogs, suppressLogs,
noRecord, noRecord,
); );
scheduleColourDialogCleanup("pref:" + handle);
reconcileStuckQuickbars("after-colour-save");
return prefResult;
} }
return send(handle, contents, suppressLogs, noRecord);
if (handle === "calendar.quickbar.class") {
var openContext = normalizeQuickbarOpenContext(contents);
var label =
openContext &&
openContext.data &&
(openContext.data.description || openContext.data.code);
log("quickbar open msg.send", {
subject: label,
elementConnected:
openContext &&
openContext.element &&
openContext.element.isConnected,
overlays: countOverlayState(),
});
var openResult;
try {
openResult = originalSend(
handle,
openContext,
suppressLogs,
noRecord,
);
} catch (err) {
console.error("[BetterSEQTA+] quickbar open failed:", err);
throw err;
}
setTimeout(function () {
logQuickbarOpenResult("+50ms");
}, 50);
setTimeout(function () {
logQuickbarOpenResult("+200ms");
}, 200);
return openResult;
}
if (handle === "calendar.quickbar.hide") {
log("quickbar hide msg.send", countOverlayState());
return originalSend(handle, contents, suppressLogs, noRecord);
}
return originalSend(handle, contents, suppressLogs, noRecord);
}; };
var originalRegister = msg.register.bind(msg);
msg.register = function (handle, callback, clear, ignoreHistory) { msg.register = function (handle, callback, clear, ignoreHistory) {
if (handle === MENU_UPDATE_COLOURS) { if (handle === MENU_UPDATE_COLOURS) {
return originalRegister( return register(handle, onMenuColourUpdate, clear, ignoreHistory);
handle,
fixedMenuColourHandler,
clear,
ignoreHistory,
);
} }
if (handle === "calendar.quickbar.class") { return register(handle, callback, clear, ignoreHistory);
return originalRegister(
handle,
function (context) {
log("quickbar class handler invoked", {
elementConnected:
context &&
context.element &&
context.element.isConnected,
subject:
context &&
context.data &&
(context.data.description || context.data.code),
});
return callback(context);
},
clear,
ignoreHistory,
);
}
return originalRegister(handle, callback, clear, ignoreHistory);
}; };
neutralizeBrokenMenuColourListeners(msg); neutralizeBrokenListeners(msg);
msg.__bsplusPatched = true; msg.__bsplusPatched = true;
} }
@@ -354,41 +211,15 @@
if (!window.msg || !window.msg.send) return false; if (!window.msg || !window.msg.send) return false;
patchMsg(window.msg); patchMsg(window.msg);
window.__bsplusMenuColoursPatched = true; window.__bsplusMenuColoursPatched = true;
log("patch active");
return true; return true;
} }
document.addEventListener(
"click",
function (event) {
var target = event.target;
if (!target || !target.closest) return;
var entry = target.closest(".timetablepage .entry");
if (!entry) return;
var before = countOverlayState();
var cleanup = dismissStaleColourDialogs(false);
var calendarId = entry.getAttribute("data-calendarid");
var instance = entry.getAttribute("data-instance");
var titleEl = entry.querySelector(".title");
var title = titleEl && titleEl.textContent ? titleEl.textContent.trim() : "";
log("entry click (capture)", {
calendarId: calendarId,
instance: instance,
title: title,
before: before,
cleanup: cleanup,
});
},
true,
);
if (!tryPatch()) { if (!tryPatch()) {
var interval = setInterval(function () { var interval = setInterval(function () {
if (tryPatch()) { if (tryPatch()) {
clearInterval(interval); clearInterval(interval);
} else if (window.msg) { } else if (window.msg) {
neutralizeBrokenMenuColourListeners(window.msg); neutralizeBrokenListeners(window.msg);
} }
}, 25); }, 25);
setTimeout(function () { setTimeout(function () {
+6 -16
View File
@@ -45,22 +45,12 @@ export function insertKeyAfterInOrder(
/** Default Analytics immediately below Courses in saved menu order. */ /** Default Analytics immediately below Courses in saved menu order. */
export function ensureAnalyticsMenuOrder(): void { export function ensureAnalyticsMenuOrder(): void {
if (!settingsState.defaultmenuorder.includes("analytics")) { for (const key of ["defaultmenuorder", "menuorder"] as const) {
settingsState.defaultmenuorder = insertKeyAfterInOrder( const order = settingsState[key];
settingsState.defaultmenuorder, if (key === "menuorder" && order.length === 0) continue;
"analytics", if (!order.includes("analytics")) {
"courses", settingsState[key] = insertKeyAfterInOrder(order, "analytics", "courses");
); }
}
if (
settingsState.menuorder.length > 0 &&
!settingsState.menuorder.includes("analytics")
) {
settingsState.menuorder = insertKeyAfterInOrder(
settingsState.menuorder,
"analytics",
"courses",
);
} }
} }
+57 -91
View File
@@ -14,139 +14,110 @@
var THEME_STYLE_ID = "custom-theme"; var THEME_STYLE_ID = "custom-theme";
var PREVIEW_STYLE_ID = "custom-theme-preview"; var PREVIEW_STYLE_ID = "custom-theme-preview";
var urlCache = {}; var urlCache = {};
var state = { var cssState = { custom: "", preview: "" };
customCss: "",
previewCss: "",
};
var headObserver = null; var headObserver = null;
function log(event, detail) { function log(event, detail) {
if (!document.documentElement.hasAttribute("data-bsplus-verbose-log")) return; if (!document.documentElement.hasAttribute("data-bsplus-verbose-log")) return;
if (detail !== undefined) { console.info(LOG, event, detail);
console.info(LOG, event, detail);
} else {
console.info(LOG, event);
}
} }
function base64ToBlob(base64, mime) { function base64ToBlob(base64, mime) {
var byteString = atob(base64); var bytes = atob(base64);
var ab = new ArrayBuffer(byteString.length); var ab = new ArrayBuffer(bytes.length);
var ia = new Uint8Array(ab); var view = new Uint8Array(ab);
for (var i = 0; i < byteString.length; i++) { for (var i = 0; i < bytes.length; i++) view[i] = bytes.charCodeAt(i);
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ab], { type: mime || "image/png" }); return new Blob([ab], { type: mime || "image/png" });
} }
function releaseCachedUrls() { function releaseCachedUrls() {
for (var key in urlCache) { for (var key in urlCache) {
if (!urlCache.hasOwnProperty(key)) continue; if (!Object.prototype.hasOwnProperty.call(urlCache, key)) continue;
try { try {
URL.revokeObjectURL(urlCache[key]); URL.revokeObjectURL(urlCache[key]);
} catch (e) { } catch (e) {}
// ignore
}
} }
urlCache = {}; urlCache = {};
} }
function ensureStyleElement(id) { function styleEl(id) {
var style = document.getElementById(id); var el = document.getElementById(id);
if (!style) { if (!el) {
style = document.createElement("style"); el = document.createElement("style");
style.id = id; el.id = id;
document.head.appendChild(style); document.head.appendChild(el);
} }
return style; return el;
} }
function ensureThemeStyleLast() { function keepThemeStyleLast() {
var style = document.getElementById(THEME_STYLE_ID); var themeStyle = document.getElementById(THEME_STYLE_ID);
if (!style || !document.head.contains(style)) return; if (themeStyle && document.head.contains(themeStyle) && document.head.lastElementChild !== themeStyle) {
if (document.head.lastElementChild === style) return; document.head.appendChild(themeStyle);
document.head.appendChild(style); }
} }
function ensureHeadObserver() { function watchHead() {
if (headObserver) return; if (headObserver) return;
headObserver = new MutationObserver(function () { headObserver = new MutationObserver(keepThemeStyleLast);
ensureThemeStyleLast();
});
headObserver.observe(document.head, { childList: true }); headObserver.observe(document.head, { childList: true });
} }
function setStyleText(id, text, watchThemeOrder) {
if (!text) {
document.getElementById(id)?.remove();
return;
}
styleEl(id).textContent = text;
if (watchThemeOrder) {
watchHead();
keepThemeStyleLast();
}
}
function clearAll() { function clearAll() {
releaseCachedUrls(); releaseCachedUrls();
state.customCss = ""; cssState.custom = "";
state.previewCss = ""; cssState.preview = "";
var imagesStyle = document.getElementById(IMAGES_STYLE_ID); setStyleText(IMAGES_STYLE_ID, "");
if (imagesStyle) imagesStyle.textContent = ""; document.getElementById(THEME_STYLE_ID)?.remove();
var themeStyle = document.getElementById(THEME_STYLE_ID); document.getElementById(PREVIEW_STYLE_ID)?.remove();
if (themeStyle) themeStyle.remove(); headObserver?.disconnect();
var previewStyle = document.getElementById(PREVIEW_STYLE_ID); headObserver = null;
if (previewStyle) previewStyle.remove();
if (headObserver) {
headObserver.disconnect();
headObserver = null;
}
log("cleared"); log("cleared");
} }
function applyThemeImages(images) { function applyThemeImages(images) {
releaseCachedUrls(); releaseCachedUrls();
if (!images || !images.length) { if (!images?.length) {
var emptyStyle = document.getElementById(IMAGES_STYLE_ID); setStyleText(IMAGES_STYLE_ID, "");
if (emptyStyle) emptyStyle.textContent = "";
return; return;
} }
var lines = [":root {"]; var lines = [":root {"];
for (var i = 0; i < images.length; i++) { for (var i = 0; i < images.length; i++) {
var img = images[i]; var img = images[i];
if (!img || !img.variableName || !img.data) continue; if (!img?.variableName || !img.data) continue;
try { try {
var blob = base64ToBlob(img.data, img.mime); var url = URL.createObjectURL(base64ToBlob(img.data, img.mime));
var url = URL.createObjectURL(blob);
urlCache[img.variableName] = url; urlCache[img.variableName] = url;
lines.push(" --" + img.variableName + ": url(\"" + url + "\");"); lines.push(' --' + img.variableName + ': url("' + url + '");');
} catch (e) { } catch (e) {
console.warn(LOG, "skip image", img.variableName, e); console.warn(LOG, "skip image", img.variableName, e);
} }
} }
lines.push("}"); lines.push("}");
ensureStyleElement(IMAGES_STYLE_ID).textContent = lines.join("\n"); styleEl(IMAGES_STYLE_ID).textContent = lines.join("\n");
log("images applied", { count: images.length }); log("images applied", { count: images.length });
} }
function applyCustomCss(css) {
if (!css) {
var existing = document.getElementById(THEME_STYLE_ID);
if (existing) existing.remove();
return;
}
ensureStyleElement(THEME_STYLE_ID).textContent = css;
ensureHeadObserver();
ensureThemeStyleLast();
}
function applyPreviewCss(css) {
if (!css) {
var existing = document.getElementById(PREVIEW_STYLE_ID);
if (existing) existing.remove();
return;
}
ensureStyleElement(PREVIEW_STYLE_ID).textContent = css;
}
function processPayload() { function processPayload() {
var payloadEl = document.getElementById(PAYLOAD_ID); var raw = document.getElementById(PAYLOAD_ID)?.value;
if (!payloadEl) return;
var raw = payloadEl.value;
if (!raw) { if (!raw) {
clearAll(); clearAll();
return; return;
} }
try { try {
var payload = JSON.parse(raw); var payload = JSON.parse(raw);
if (!payload || payload.clear) { if (!payload || payload.clear) {
@@ -154,24 +125,22 @@
return; return;
} }
if (payload.images !== undefined) { if (payload.images !== undefined) applyThemeImages(payload.images);
applyThemeImages(payload.images);
}
if (payload.customCss !== undefined) { if (payload.customCss !== undefined) {
state.customCss = payload.customCss || ""; cssState.custom = payload.customCss || "";
applyCustomCss(state.customCss); setStyleText(THEME_STYLE_ID, cssState.custom, true);
log("custom css applied"); log("custom css applied");
} }
if (payload.previewCss !== undefined) { if (payload.previewCss !== undefined) {
state.previewCss = payload.previewCss || ""; cssState.preview = payload.previewCss || "";
applyPreviewCss(state.previewCss); setStyleText(PREVIEW_STYLE_ID, cssState.preview);
} }
if (payload.clearPreview) { if (payload.clearPreview) {
state.previewCss = ""; cssState.preview = "";
applyPreviewCss(""); setStyleText(PREVIEW_STYLE_ID, "");
} }
} catch (e) { } catch (e) {
console.warn(LOG, "invalid payload", e); console.warn(LOG, "invalid payload", e);
@@ -197,10 +166,7 @@
} }
ensureBridge(); ensureBridge();
var bridge = document.getElementById(BRIDGE_ID); new MutationObserver(processPayload).observe(document.getElementById(BRIDGE_ID), {
new MutationObserver(function () {
processPayload();
}).observe(bridge, {
attributes: true, attributes: true,
attributeFilter: ["data-rev"], attributeFilter: ["data-rev"],
}); });
-96
View File
@@ -1,96 +0,0 @@
/**
* SEQTA timetable colour picker (Coloris) recovery and click-blocker cleanup.
* Subject colour saves trigger SEQTA's broken menu.update.colours handler see
* patchSeqtaMenuUpdateColours.ts.
*/
import { dismissStaleColourDialogs } from "@/seqta/utils/patchSeqtaMenuUpdateColours";
import { verboseInfo } from "@/utils/verboseLog";
let attached = false;
let dismissTimer: ReturnType<typeof setTimeout> | null = null;
const DISMISS_DELAY_MS = 100;
function scheduleDismiss(): void {
if (dismissTimer !== null) clearTimeout(dismissTimer);
dismissTimer = setTimeout(() => {
dismissTimer = null;
dismissTimetableUiBlockers();
}, DISMISS_DELAY_MS);
}
/** Hide colour-picker / modal layers that intercept clicks after a colour save. */
export function dismissTimetableUiBlockers(): {
slideRemoved: number;
modalRemoved: number;
} {
document.body.style.removeProperty("overflow");
for (const picker of document.querySelectorAll(".clr-picker")) {
picker.classList.remove("clr-open");
if (picker instanceof HTMLElement) {
picker.style.display = "none";
picker.style.pointerEvents = "none";
picker.style.visibility = "hidden";
}
}
return dismissStaleColourDialogs();
}
/** Clear inline styles that can prevent Coloris from reopening. */
export function prepareColorisPickerOpen(): void {
document.body.classList.remove("clr-open");
document.documentElement.classList.remove("clr-open");
for (const picker of document.querySelectorAll(".clr-picker")) {
picker.classList.remove("clr-open");
if (picker instanceof HTMLElement) {
picker.style.removeProperty("display");
picker.style.removeProperty("pointer-events");
picker.style.removeProperty("visibility");
}
}
}
export function attachTimetableColorisRecovery(): void {
if (attached) return;
attached = true;
document.addEventListener("coloris:close", scheduleDismiss);
document.addEventListener("coloris:pick", scheduleDismiss);
document.addEventListener(
"click",
(event) => {
const target = event.target as HTMLElement;
if (!target.closest(".timetablepage")) return;
if (target.closest("[title='Choose a colour']")) {
if (dismissTimer !== null) {
clearTimeout(dismissTimer);
dismissTimer = null;
}
prepareColorisPickerOpen();
return;
}
if (!target.closest(".entry")) return;
const pickerOpen =
document.body.classList.contains("clr-open") &&
document.querySelector(".clr-picker.clr-open");
if (!pickerOpen) {
const result = dismissTimetableUiBlockers();
if (result.slideRemoved > 0 || result.modalRemoved > 0) {
verboseInfo(
"[BetterSEQTA+] timetable colour: content-script cleanup",
result,
);
}
}
},
true,
);
}
-3
View File
@@ -67,9 +67,6 @@ export default defineConfig(({ command, mode: viteMode }) => {
const env = loadEnv(viteMode, repoRoot, ""); const env = loadEnv(viteMode, repoRoot, "");
return { return {
// Content scripts run on the host page; absolute `/assets/...` URLs would
// resolve against SEQTA instead of chrome-extension://. Relative base makes
// Vite emit import.meta.url-relative chunk/CSS URLs at runtime.
base: command === "build" ? "./" : "/", base: command === "build" ? "./" : "/",
define: { define: {
__ENABLE_GH_RELEASE_UPDATE_CHECK__: JSON.stringify( __ENABLE_GH_RELEASE_UPDATE_CHECK__: JSON.stringify(