mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
Merge pull request #458 from StroepWafel/various-bugfixes
Various bugfixes
This commit is contained in:
@@ -33,14 +33,8 @@ outputs:
|
|||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Use Node.js 20.x
|
- name: Setup Node and dependencies
|
||||||
uses: actions/setup-node@v4
|
uses: ./.github/actions/setup-node-deps
|
||||||
with:
|
|
||||||
node-version: 20.x
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
shell: bash
|
|
||||||
run: npm install --legacy-peer-deps
|
|
||||||
|
|
||||||
- name: Read version
|
- name: Read version
|
||||||
id: version
|
id: version
|
||||||
@@ -62,14 +56,4 @@ runs:
|
|||||||
env:
|
env:
|
||||||
UPDATE_CHANNEL: ${{ inputs.update_channel }}
|
UPDATE_CHANNEL: ${{ inputs.update_channel }}
|
||||||
BUILD_LABEL: ${{ inputs.build_label }}
|
BUILD_LABEL: ${{ inputs.build_label }}
|
||||||
run: |
|
run: node scripts/package-extension-zips.mjs "${{ steps.version.outputs.version }}"
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
|
||||||
if [ "$UPDATE_CHANNEL" = "nightly" ] && [ -n "$BUILD_LABEL" ]; then
|
|
||||||
BASE="betterseqtaplus-nightly-${BUILD_LABEL}"
|
|
||||||
else
|
|
||||||
BASE="betterseqtaplus-${VERSION}"
|
|
||||||
fi
|
|
||||||
(cd dist/chrome && zip -r "../${BASE}-chrome.zip" .)
|
|
||||||
(cd dist/firefox && zip -r "../${BASE}-firefox.zip" .)
|
|
||||||
echo "chrome_zip=dist/${BASE}-chrome.zip" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "firefox_zip=dist/${BASE}-firefox.zip" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
name: Run lint
|
||||||
|
description: Run ESLint on src.
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Lint
|
||||||
|
shell: bash
|
||||||
|
run: npm run lint
|
||||||
|
env:
|
||||||
|
ESLINT_USE_FLAT_CONFIG: "false"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
name: Run smoke tests
|
||||||
|
description: Verify built extension dist output.
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Smoke tests
|
||||||
|
shell: bash
|
||||||
|
run: npm run test:smoke
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
name: Run unit tests
|
||||||
|
description: Run Jest unit tests.
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Unit tests
|
||||||
|
shell: bash
|
||||||
|
run: npm run test:unit
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
name: Setup Node and dependencies
|
||||||
|
description: Install Node.js 22.x and npm dependencies.
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Use Node.js 22.x
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22.x
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
shell: bash
|
||||||
|
run: npm install --legacy-peer-deps
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
name: NodeJS Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [20.x]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node-version }}
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: |
|
|
||||||
npm install --legacy-peer-deps
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: Zip dist folder
|
|
||||||
run: |
|
|
||||||
zip -r dist.zip dist
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: dist-zip
|
|
||||||
path: dist.zip
|
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
# Nightly release workflow — updates the same "nightly" release with fresh builds from main.
|
# Nightly release workflow — updates the same "nightly" release with fresh builds from main.
|
||||||
# Runs only on BetterSEQTA/BetterSEQTA-Plus. Uses the default GITHUB_TOKEN.
|
# Runs only on BetterSEQTA/BetterSEQTA-Plus. Uses the default GITHUB_TOKEN.
|
||||||
|
#
|
||||||
|
# Scheduled at midnight Australia/Adelaide (ACST/ACDT). GitHub cron is UTC-only, so we
|
||||||
|
# trigger at 13:30 and 14:30 UTC and only proceed when Adelaide local time is 00:00.
|
||||||
|
# Builds on windows-latest (matches local dev; Linux nightly builds were failing on Vite/Svelte).
|
||||||
|
|
||||||
name: Nightly Release
|
name: Nightly Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 3 * * *"
|
# 13:30 UTC = 00:00 Adelaide during daylight saving (ACDT, UTC+10:30)
|
||||||
|
- cron: "30 13 * * *"
|
||||||
|
# 14:30 UTC = 00:00 Adelaide during standard time (ACST, UTC+9:30)
|
||||||
|
- cron: "30 14 * * *"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -13,19 +20,41 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
NIGHTLY_TAG: nightly
|
NIGHTLY_TAG: nightly
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
nightly:
|
nightly:
|
||||||
runs-on: ubuntu-latest
|
runs-on: windows-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
steps:
|
steps:
|
||||||
|
- name: Check Adelaide midnight
|
||||||
|
id: time_check
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
TZ=Australia/Adelaide
|
||||||
|
if [ "$(date +%H%M)" = "0000" ]; then
|
||||||
|
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Skipping: not midnight Australia/Adelaide ($(date +%Y-%m-%d %H:%M %Z))"
|
||||||
|
fi
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
if: steps.time_check.outputs.proceed == 'true'
|
||||||
|
|
||||||
- name: Set build date
|
- name: Set build date
|
||||||
id: build_date
|
id: build_date
|
||||||
run: echo "date=$(date -u +'%Y-%m-%d')" >> "$GITHUB_OUTPUT"
|
if: steps.time_check.outputs.proceed == 'true'
|
||||||
|
run: echo "date=$(TZ=Australia/Adelaide date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build extension
|
- name: Build extension
|
||||||
id: build
|
id: build
|
||||||
|
if: steps.time_check.outputs.proceed == 'true'
|
||||||
uses: ./.github/actions/build-extension
|
uses: ./.github/actions/build-extension
|
||||||
with:
|
with:
|
||||||
gh_release_update_check: "true"
|
gh_release_update_check: "true"
|
||||||
@@ -34,6 +63,7 @@ jobs:
|
|||||||
release_repo: ${{ github.repository }}
|
release_repo: ${{ github.repository }}
|
||||||
|
|
||||||
- name: Ensure nightly release exists
|
- name: Ensure nightly release exists
|
||||||
|
if: steps.time_check.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
TITLE="Nightly (${{ steps.build_date.outputs.date }})"
|
TITLE="Nightly (${{ steps.build_date.outputs.date }})"
|
||||||
if ! gh release view "${{ env.NIGHTLY_TAG }}" 2>/dev/null; then
|
if ! gh release view "${{ env.NIGHTLY_TAG }}" 2>/dev/null; then
|
||||||
@@ -46,6 +76,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload nightly assets
|
- name: Upload nightly assets
|
||||||
|
if: steps.time_check.outputs.proceed == 'true'
|
||||||
run: |
|
run: |
|
||||||
gh release upload "${{ env.NIGHTLY_TAG }}" \
|
gh release upload "${{ env.NIGHTLY_TAG }}" \
|
||||||
--clobber \
|
--clobber \
|
||||||
|
|||||||
+45
-17
@@ -1,35 +1,63 @@
|
|||||||
name: PR CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
# windows-latest: Vite/Svelte build fails on Linux CI for layerchart vendor .svelte (see nightly.yml).
|
||||||
|
runs-on: windows-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Use Node.js 20.x
|
- name: Setup Node and dependencies
|
||||||
uses: actions/setup-node@v4
|
uses: ./.github/actions/setup-node-deps
|
||||||
with:
|
|
||||||
node-version: 20.x
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Run lint
|
||||||
run: npm install --legacy-peer-deps
|
uses: ./.github/actions/run-lint
|
||||||
|
|
||||||
- name: Lint
|
unit-tests:
|
||||||
run: npm run lint
|
runs-on: windows-latest
|
||||||
env:
|
defaults:
|
||||||
ESLINT_USE_FLAT_CONFIG: "false"
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Setup Node and dependencies
|
||||||
run: npm test
|
uses: ./.github/actions/setup-node-deps
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
uses: ./.github/actions/run-unit-tests
|
||||||
|
|
||||||
|
build-and-smoke:
|
||||||
|
needs: [lint, unit-tests]
|
||||||
|
runs-on: windows-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build extension
|
- name: Build extension
|
||||||
|
id: build
|
||||||
uses: ./.github/actions/build-extension
|
uses: ./.github/actions/build-extension
|
||||||
with:
|
with:
|
||||||
gh_release_update_check: "false"
|
gh_release_update_check: "false"
|
||||||
|
|
||||||
- name: Smoke tests
|
- name: Upload extension zips
|
||||||
run: npm run test:smoke
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: extension-zips
|
||||||
|
path: |
|
||||||
|
${{ steps.build.outputs.chrome_zip }}
|
||||||
|
${{ steps.build.outputs.firefox_zip }}
|
||||||
|
|
||||||
|
- name: Run smoke tests
|
||||||
|
uses: ./.github/actions/run-smoke-tests
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ bun.lock
|
|||||||
# PDF.js extension assets (copied by postinstall from pdfjs-dist)
|
# PDF.js extension assets (copied by postinstall from pdfjs-dist)
|
||||||
src/public/resources/pdfjs/pdf.worker.min.mjs
|
src/public/resources/pdfjs/pdf.worker.min.mjs
|
||||||
src/public/resources/pdfjs/pdf.legacy.min.mjs
|
src/public/resources/pdfjs/pdf.legacy.min.mjs
|
||||||
|
# ONNX Runtime WASM assets (copied by postinstall from @huggingface/transformers)
|
||||||
|
src/public/resources/ort/ort-wasm-simd-threaded.jsep.mjs
|
||||||
|
src/public/resources/ort/ort-wasm-simd-threaded.jsep.wasm
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
extension.zip
|
extension.zip
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ export default {
|
|||||||
],
|
],
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.ts$': 'ts-jest',
|
'^.+\\.ts$': 'ts-jest',
|
||||||
|
'^.+\\.js$': ['ts-jest', { tsconfig: { allowJs: true } }],
|
||||||
},
|
},
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'/node_modules/(?!(color|color-string|color-convert|color-name)/)',
|
||||||
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@/(.*)$': '<rootDir>/src/$1',
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
'^color$': '<rootDir>/src/test/mocks/color.ts',
|
||||||
'^webextension-polyfill$': '<rootDir>/src/test/mocks/webextension-polyfill.ts',
|
'^webextension-polyfill$': '<rootDir>/src/test/mocks/webextension-polyfill.ts',
|
||||||
},
|
},
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/src/test/jest.setup.ts'],
|
||||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'src/**/*.ts',
|
'src/**/*.ts',
|
||||||
|
|||||||
+6
-44
@@ -1,58 +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");
|
||||||
process.exit(0); // Exit with status 0 indicating a successful build
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Plugin } from "vite";
|
||||||
|
|
||||||
|
/** Relative chunk/CSS URLs via chrome.runtime.getURL for content-script dynamic imports. */
|
||||||
|
export function extensionChunkUrls(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "extension-chunk-urls",
|
||||||
|
config() {
|
||||||
|
return {
|
||||||
|
base: "./",
|
||||||
|
experimental: {
|
||||||
|
renderBuiltUrl(filename, { hostType, type }) {
|
||||||
|
const path = filename.replace(/^\//, "");
|
||||||
|
if (type === "chunk" && hostType === "js") {
|
||||||
|
return { runtime: `chrome.runtime.getURL(${JSON.stringify(path)})` };
|
||||||
|
}
|
||||||
|
// JS-triggered CSS preloads only — extension HTML pages need static hrefs.
|
||||||
|
if (type === "asset" && hostType === "js" && path.endsWith(".css")) {
|
||||||
|
return { runtime: `chrome.runtime.getURL(${JSON.stringify(path)})` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+20
-58
@@ -1,70 +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], // esbuild uses the file path directly
|
|
||||||
bundle: true,
|
|
||||||
write: false, // We want the output in memory, not written to disk
|
|
||||||
platform: "browser", // Target environment for the worker code
|
|
||||||
format: "iife", // Immediately Invoked Function Expression, suitable for workers
|
|
||||||
target: "esnext", // Transpile to modern JavaScript
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -11,7 +11,7 @@
|
|||||||
* or `node lib/publish.js --b firefox`
|
* or `node lib/publish.js --b firefox`
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const glob = require("glob");
|
const { globSync } = require("glob");
|
||||||
const semver = require("semver");
|
const semver = require("semver");
|
||||||
const { execSync } = require("child_process");
|
const { execSync } = require("child_process");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
@@ -98,7 +98,7 @@ function getLatestFiles(browser) {
|
|||||||
const pattern = `dist/betterseqtaplus@*-*${browser}.zip`;
|
const pattern = `dist/betterseqtaplus@*-*${browser}.zip`;
|
||||||
console.log("Glob pattern:", pattern);
|
console.log("Glob pattern:", pattern);
|
||||||
|
|
||||||
const files = glob.sync(pattern);
|
const files = globSync(pattern);
|
||||||
console.log("Files found for browser", browser, ":", files);
|
console.log("Files found for browser", browser, ":", files);
|
||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
|
|||||||
+18
-5
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "betterseqtaplus",
|
"name": "betterseqtaplus",
|
||||||
"version": "3.7.2",
|
"version": "3.7.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
|
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
|
||||||
"browserslist": "> 0.5%, last 2 versions, not dead",
|
"browserslist": "> 0.5%, last 2 versions, not dead",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "node scripts/copy-pdfjs-assets.mjs",
|
"compile:layerchart": "node scripts/compile-layerchart-vendor.mjs",
|
||||||
|
"postinstall": "node scripts/copy-pdfjs-assets.mjs && node scripts/copy-ort-wasm-assets.mjs && npm run compile:layerchart",
|
||||||
"autoaudit": "npm audit && npm audit fix && npm run build",
|
"autoaudit": "npm audit && npm audit fix && npm run build",
|
||||||
"dev": "cross-env MODE=chrome vite dev",
|
"dev": "cross-env MODE=chrome vite dev",
|
||||||
"dev:firefox": "cross-env MODE=firefox vite build --watch",
|
"dev:firefox": "cross-env MODE=firefox vite build --watch",
|
||||||
"compile": "npm i && npm run build",
|
"compile": "npm i && npm run build",
|
||||||
"build": "cross-env MODE=chrome vite build && cross-env MODE=firefox vite build",
|
"build": "npm run compile:layerchart && cross-env MODE=chrome vite build && cross-env MODE=firefox vite build",
|
||||||
"build:chrome": "cross-env MODE=chrome vite build",
|
"build:chrome": "cross-env MODE=chrome vite build",
|
||||||
"build:firefox": "cross-env MODE=firefox vite build",
|
"build:firefox": "cross-env MODE=firefox vite build",
|
||||||
"build:safari": "cross-env MODE=safari vite build",
|
"build:safari": "cross-env MODE=safari vite build",
|
||||||
@@ -18,8 +19,10 @@
|
|||||||
"convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari",
|
"convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari",
|
||||||
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
|
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
|
||||||
"lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint \"src/**/*.{js,ts}\"",
|
"lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint \"src/**/*.{js,ts}\"",
|
||||||
"test": "jest",
|
"test": "npm run test:unit",
|
||||||
|
"test:unit": "jest",
|
||||||
"test:smoke": "node scripts/smoke-test.mjs",
|
"test:smoke": "node scripts/smoke-test.mjs",
|
||||||
|
"test:ci": "npm run test:unit && npm run build && npm run test:smoke",
|
||||||
"release": "gh release create $npm_package_version --repo BetterSEQTA/BetterSEQTA-Plus ./dist/*.zip --generate-notes",
|
"release": "gh release create $npm_package_version --repo BetterSEQTA/BetterSEQTA-Plus ./dist/*.zip --generate-notes",
|
||||||
"publish": "bun lib/publish.js --b",
|
"publish": "bun lib/publish.js --b",
|
||||||
"zip": "bedframe zip"
|
"zip": "bedframe zip"
|
||||||
@@ -55,8 +58,9 @@
|
|||||||
"dependency-cruiser": "^17.0.1",
|
"dependency-cruiser": "^17.0.1",
|
||||||
"eslint": "^9.33.0",
|
"eslint": "^9.33.0",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"glob": "^11.0.1",
|
"glob": "^13.0.6",
|
||||||
"jest": "^30.4.2",
|
"jest": "^30.4.2",
|
||||||
|
"jest-environment-jsdom": "^30.4.1",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
@@ -97,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",
|
||||||
@@ -124,5 +129,13 @@
|
|||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vite": "^6.2.1",
|
"vite": "^6.2.1",
|
||||||
"webextension-polyfill": "^0.12.0"
|
"webextension-polyfill": "^0.12.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"glob": "^13.0.6"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"glob": "^13.0.6"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Pre-compile layerchart `.svelte` sources to `.js` so Rollup/Vite CI builds succeed.
|
||||||
|
*/
|
||||||
|
import { compile } from "svelte/compiler";
|
||||||
|
import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const layerchartRoot = join(root, "node_modules", "layerchart");
|
||||||
|
const layerchartDist = join(layerchartRoot, "dist");
|
||||||
|
const stampPath = join(layerchartDist, ".bsplus-compiled");
|
||||||
|
const COMPILE_ALGO_VERSION = "2";
|
||||||
|
const importSuffixPattern = /\.svelte(?=['"])/g;
|
||||||
|
|
||||||
|
const exists = (path) => {
|
||||||
|
try {
|
||||||
|
statSync(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!exists(layerchartDist)) {
|
||||||
|
console.log("compile-layerchart-vendor: layerchart not installed, skipping");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerchartVersion = JSON.parse(
|
||||||
|
readFileSync(join(layerchartRoot, "package.json"), "utf8"),
|
||||||
|
).version;
|
||||||
|
const stampContent = `${layerchartVersion}\n${COMPILE_ALGO_VERSION}`;
|
||||||
|
|
||||||
|
if (exists(stampPath) && readFileSync(stampPath, "utf8").trim() === stampContent) {
|
||||||
|
console.log(`compile-layerchart-vendor: layerchart@${layerchartVersion} already compiled, skipping`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const walkFiles = (dir, files = []) => {
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
if (name === "node_modules") continue;
|
||||||
|
const path = join(dir, name);
|
||||||
|
if (statSync(path).isDirectory()) walkFiles(path, files);
|
||||||
|
else files.push(path);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchSvelteImports = (content) => content.replace(importSuffixPattern, ".js");
|
||||||
|
const stripRollupBreakingSyntax = (code) =>
|
||||||
|
code
|
||||||
|
.replace(/(\w+)\?(?=\s*[,)\]])/g, "$1")
|
||||||
|
.replace(/(\w+)\?(?=\s*:)/g, "$1");
|
||||||
|
|
||||||
|
const svelteFiles = walkFiles(layerchartDist).filter((f) => f.endsWith(".svelte"));
|
||||||
|
|
||||||
|
for (const sveltePath of svelteFiles) {
|
||||||
|
const source = readFileSync(sveltePath, "utf8");
|
||||||
|
if (!source.includes("<script")) continue;
|
||||||
|
const compiled = compile(source, {
|
||||||
|
filename: sveltePath,
|
||||||
|
generate: "client",
|
||||||
|
css: "injected",
|
||||||
|
});
|
||||||
|
writeFileSync(
|
||||||
|
sveltePath.replace(/\.svelte$/, ".js"),
|
||||||
|
stripRollupBreakingSyntax(patchSvelteImports(compiled.js.code)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const filePath of walkFiles(layerchartDist).filter((f) => /\.(js|svelte|ts|mjs)$/.test(f))) {
|
||||||
|
const content = readFileSync(filePath, "utf8");
|
||||||
|
if (!content.includes(".svelte")) continue;
|
||||||
|
const patched = patchSvelteImports(content);
|
||||||
|
if (patched !== content) writeFileSync(filePath, patched);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(stampPath, stampContent);
|
||||||
|
console.log(`compile-layerchart-vendor: compiled ${svelteFiles.length} Svelte files`);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
const transformersDist = dirname(require.resolve("@huggingface/transformers"));
|
||||||
|
const outDir = join(root, "src", "public", "resources", "ort");
|
||||||
|
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const ortFiles = [
|
||||||
|
"ort-wasm-simd-threaded.jsep.mjs",
|
||||||
|
"ort-wasm-simd-threaded.jsep.wasm",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of ortFiles) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Package Chrome/Firefox build folders into Windows-friendly zip files.
|
||||||
|
*/
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
|
||||||
|
function zipDirectory(sourceRel, outRel) {
|
||||||
|
const sourceDir = join(root, sourceRel);
|
||||||
|
const outZip = join(root, outRel);
|
||||||
|
if (!existsSync(sourceDir)) throw new Error(`Missing build output: ${sourceRel}`);
|
||||||
|
|
||||||
|
mkdirSync(dirname(outZip), { recursive: true });
|
||||||
|
if (existsSync(outZip)) unlinkSync(outZip);
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const esc = (s) => s.replace(/'/g, "''");
|
||||||
|
execFileSync(
|
||||||
|
"powershell",
|
||||||
|
[
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
[
|
||||||
|
"Add-Type -AssemblyName System.IO.Compression.FileSystem",
|
||||||
|
`[IO.Compression.ZipFile]::CreateFromDirectory('${esc(sourceDir)}', '${esc(outZip)}')`,
|
||||||
|
].join("; "),
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
execFileSync("zip", ["-r", "-q", outZip, "."], { cwd: sourceDir, stdio: "inherit" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||||
|
const version = process.argv[2] || pkg.version;
|
||||||
|
const updateChannel = process.env.UPDATE_CHANNEL || "stable";
|
||||||
|
const buildLabel = process.env.BUILD_LABEL || "";
|
||||||
|
const base =
|
||||||
|
updateChannel === "nightly" && buildLabel
|
||||||
|
? `betterseqtaplus-nightly-${buildLabel}`
|
||||||
|
: `betterseqtaplus-${version}`;
|
||||||
|
|
||||||
|
const chromeZip = `dist/${base}-chrome.zip`;
|
||||||
|
const firefoxZip = `dist/${base}-firefox.zip`;
|
||||||
|
|
||||||
|
zipDirectory("dist/chrome", chromeZip);
|
||||||
|
zipDirectory("dist/firefox", firefoxZip);
|
||||||
|
console.log(`Packaged ${chromeZip}\nPackaged ${firefoxZip}`);
|
||||||
|
|
||||||
|
if (process.env.GITHUB_OUTPUT) {
|
||||||
|
appendFileSync(process.env.GITHUB_OUTPUT, `chrome_zip=${chromeZip}\nfirefox_zip=${firefoxZip}\n`);
|
||||||
|
}
|
||||||
+10
-2
@@ -10,6 +10,9 @@ import { init as Monofile } from "@/plugins/monofile";
|
|||||||
import { main } from "@/seqta/main";
|
import { main } from "@/seqta/main";
|
||||||
import { delay } from "./seqta/utils/delay";
|
import { delay } from "./seqta/utils/delay";
|
||||||
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
||||||
|
import { installSeqtaMenuColourPatch } from "@/seqta/utils/patchSeqtaMenuUpdateColours";
|
||||||
|
import { installThemeImagePagePatch } from "@/seqta/utils/patchThemeImagesPageContext";
|
||||||
|
import { initVerboseLogging, verboseInfo } from "@/utils/verboseLog";
|
||||||
|
|
||||||
function registerFetchSeqtaAppLinkListener() {
|
function registerFetchSeqtaAppLinkListener() {
|
||||||
browser.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
browser.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||||
@@ -46,6 +49,10 @@ if (document.childNodes[1]) {
|
|||||||
document.childNodes[1].textContent?.includes(
|
document.childNodes[1].textContent?.includes(
|
||||||
"Copyright (c) SEQTA Software",
|
"Copyright (c) SEQTA Software",
|
||||||
) ?? false;
|
) ?? false;
|
||||||
|
if (hasSEQTAText) {
|
||||||
|
installSeqtaMenuColourPatch();
|
||||||
|
installThemeImagePagePatch();
|
||||||
|
}
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +64,7 @@ async function init() {
|
|||||||
!IsSEQTAPage
|
!IsSEQTAPage
|
||||||
) {
|
) {
|
||||||
IsSEQTAPage = true;
|
IsSEQTAPage = true;
|
||||||
console.info("[BetterSEQTA+] Verified SEQTA Page");
|
verboseInfo("[BetterSEQTA+] Verified SEQTA Page");
|
||||||
|
|
||||||
if (typeof window !== "undefined" && window === window.top) {
|
if (typeof window !== "undefined" && window === window.top) {
|
||||||
void browser.runtime.sendMessage({ type: "cloudSettingsPoll" }).catch(() => {});
|
void browser.runtime.sendMessage({ type: "cloudSettingsPoll" }).catch(() => {});
|
||||||
@@ -96,6 +103,7 @@ async function init() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await initializeSettingsState();
|
await initializeSettingsState();
|
||||||
|
initVerboseLogging();
|
||||||
|
|
||||||
if (typeof settingsState.onoff === "undefined") {
|
if (typeof settingsState.onoff === "undefined") {
|
||||||
await browser.runtime.sendMessage({ type: "setDefaultStorage" });
|
await browser.runtime.sendMessage({ type: "setDefaultStorage" });
|
||||||
@@ -115,7 +123,7 @@ async function init() {
|
|||||||
initializeHideSensitiveToggle();
|
initializeHideSensitiveToggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info(
|
verboseInfo(
|
||||||
"[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.",
|
"[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.",
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+21
-11
@@ -12,6 +12,7 @@ import {
|
|||||||
runCloudSettingsPoll,
|
runCloudSettingsPoll,
|
||||||
withSuppressedCloudAutoUpload,
|
withSuppressedCloudAutoUpload,
|
||||||
} from "./background/cloudSettingsAutoSync";
|
} from "./background/cloudSettingsAutoSync";
|
||||||
|
import { getBsplusDeviceName } from "@/seqta/utils/bsplusDeviceName";
|
||||||
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
|
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
|
||||||
import { initCalendarBackground } from "./background/calendarBackground";
|
import { initCalendarBackground } from "./background/calendarBackground";
|
||||||
import {
|
import {
|
||||||
@@ -233,25 +234,33 @@ function handleCloudLogin(
|
|||||||
sendResponse({ error: "Unauthorized sender" });
|
sendResponse({ error: "Unauthorized sender" });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { client_id, redirect_uri, login, password } = request;
|
const { client_id, redirect_uri, login, password, device_name } = request;
|
||||||
if (!client_id || !redirect_uri || !login || !password) {
|
if (!client_id || !redirect_uri || !login || !password) {
|
||||||
sendResponse({ error: "Missing client_id, redirect_uri, login, or password" });
|
sendResponse({ error: "Missing client_id, redirect_uri, login, or password" });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
fetch("https://accounts.betterseqta.org/api/bsplus/login", {
|
void (async () => {
|
||||||
method: "POST",
|
const loginBody: Record<string, string> = {
|
||||||
headers: { "Content-Type": "application/json" },
|
client_id,
|
||||||
body: JSON.stringify({ client_id, redirect_uri, login, password }),
|
redirect_uri,
|
||||||
})
|
login,
|
||||||
.then(async (r) => {
|
password,
|
||||||
|
device_name: device_name ?? await getBsplusDeviceName(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const r = await fetch("https://accounts.betterseqta.org/api/bsplus/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(loginBody),
|
||||||
|
});
|
||||||
const data = await parseJsonResponse(r);
|
const data = await parseJsonResponse(r);
|
||||||
if (!r.ok) sendResponse({ error: data?.error ?? "Login failed" });
|
if (!r.ok) sendResponse({ error: data?.error ?? "Login failed" });
|
||||||
else sendResponse(data);
|
else sendResponse(data);
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => {
|
|
||||||
console.error("[Background] cloudLogin error:", err);
|
console.error("[Background] cloudLogin error:", err);
|
||||||
sendResponse({ error: err?.message ?? "Network error" });
|
sendResponse({ error: (err as Error)?.message ?? "Network error" });
|
||||||
});
|
}
|
||||||
|
})();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,6 +726,7 @@ browser.runtime.onInstalled.addListener(function (event) {
|
|||||||
void migrateGlobalSearchDefaultsFor365Upgrade(event.previousVersion);
|
void migrateGlobalSearchDefaultsFor365Upgrade(event.previousVersion);
|
||||||
void resetThemeOfTheMonthDisabledFor366Upgrade(event.previousVersion);
|
void resetThemeOfTheMonthDisabledFor366Upgrade(event.previousVersion);
|
||||||
void resetThemeOfTheMonthDismissalFor370Upgrade(event.previousVersion);
|
void resetThemeOfTheMonthDismissalFor370Upgrade(event.previousVersion);
|
||||||
|
reloadSeqtaPages();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+125
-52
@@ -34,7 +34,8 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.uiButton.timetable-zoom.iconFamily,
|
button.timetable-zoom.iconFamily,
|
||||||
|
button.bsplus-timetable-control.iconFamily,
|
||||||
.iconFamily {
|
.iconFamily {
|
||||||
font-family: "IconFamily" !important;
|
font-family: "IconFamily" !important;
|
||||||
}
|
}
|
||||||
@@ -63,9 +64,13 @@ body {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
border-radius: 16px !important;
|
border-radius: 16px !important;
|
||||||
border: 1px solid color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 78%, transparent) !important;
|
border: 1px solid var(--theme-offset-bg, var(--theme-secondary, var(--background-secondary))) !important;
|
||||||
background: color-mix(in srgb, var(--background-primary) 90%, transparent) !important;
|
background: var(--theme-primary, var(--background-primary)) !important;
|
||||||
color: var(--text-primary) !important;
|
color: var(--text-primary) !important;
|
||||||
|
padding: 0.5rem 1rem !important;
|
||||||
|
min-height: 2.5rem !important;
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
line-height: 1.25 !important;
|
||||||
transition:
|
transition:
|
||||||
background-color 180ms ease,
|
background-color 180ms ease,
|
||||||
border-color 180ms ease,
|
border-color 180ms ease,
|
||||||
@@ -73,14 +78,14 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
select:hover {
|
select:hover {
|
||||||
background: color-mix(in srgb, var(--background-primary) 94%, var(--background-secondary) 6%) !important;
|
background: var(--theme-secondary, var(--background-secondary)) !important;
|
||||||
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 92%, transparent) !important;
|
border-color: var(--theme-offset-bg, var(--theme-secondary, var(--background-secondary))) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
select:focus {
|
select:focus {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
background: color-mix(in srgb, var(--background-primary) 96%, var(--background-secondary) 4%) !important;
|
background: var(--theme-secondary, var(--background-secondary)) !important;
|
||||||
border-color: color-mix(in srgb, var(--text-primary) 18%, var(--theme-offset-bg, var(--background-secondary)) 82%) !important;
|
border-color: color-mix(in srgb, var(--text-primary) 18%, var(--theme-offset-bg, var(--theme-secondary, var(--background-secondary))) 82%) !important;
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent) !important;
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +94,12 @@ 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;
|
||||||
background-size: 1rem !important;
|
background-size: 1rem !important;
|
||||||
padding-right: 2.6rem !important;
|
padding-right: 2.6rem !important;
|
||||||
color-scheme: light;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
select::-ms-expand {
|
select::-ms-expand {
|
||||||
@@ -102,19 +107,19 @@ select::-ms-expand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
select option {
|
select option {
|
||||||
background: var(--background-primary) !important;
|
background-color: #ffffff !important;
|
||||||
color: var(--text-primary) !important;
|
color: #18181b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark select option {
|
||||||
|
background-color: #1a1a1a !important;
|
||||||
|
color: #ffffff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
|
|
||||||
.dark select option {
|
|
||||||
background: var(--background-primary) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
}
|
||||||
#container {
|
#container {
|
||||||
background: var(--auto-background) !important;
|
background: var(--auto-background) !important;
|
||||||
@@ -215,6 +220,13 @@ select option {
|
|||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Colour picker dialog teardown can leave an empty shell that blocks clicks */
|
||||||
|
.modaliser-container:not(:has(.modaliser > *)) {
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.connectedNotificationsWrapper > div > button > svg > g {
|
.connectedNotificationsWrapper > div > button > svg > g {
|
||||||
fill: var(--theme-primary) !important;
|
fill: var(--theme-primary) !important;
|
||||||
}
|
}
|
||||||
@@ -331,12 +343,18 @@ select option {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.timetable-zoom,
|
.timetable-zoom,
|
||||||
.timetable-hide {
|
.timetable-hide,
|
||||||
|
.bsplus-timetable-control {
|
||||||
font-size: 14px !important;
|
font-size: 14px !important;
|
||||||
line-height: 1 !important;
|
line-height: 1 !important;
|
||||||
display: inline-flex !important;
|
display: inline-flex !important;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#main > .dashboard {
|
#main > .dashboard {
|
||||||
@@ -421,6 +439,7 @@ ul.magicDelete > li.deleting {
|
|||||||
.addedButton svg {
|
.addedButton svg {
|
||||||
margin: 6px;
|
margin: 6px;
|
||||||
fill: var(--theme-primary);
|
fill: var(--theme-primary);
|
||||||
|
color: var(--theme-primary);
|
||||||
}
|
}
|
||||||
#menu,
|
#menu,
|
||||||
.sub,
|
.sub,
|
||||||
@@ -508,6 +527,54 @@ ul.magicDelete > li.deleting {
|
|||||||
#menu:has(> ul > li.hasChildren.active) > ul > li:not(.hasChildren.active) {
|
#menu:has(> ul > li.hasChildren.active) > ul > li:not(.hasChildren.active) {
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Edit Sidebar: every row + toggle must stay clickable (drill stack disables siblings). */
|
||||||
|
#menu.bsplus-sidebar-edit-mode li.item,
|
||||||
|
#menu.bsplus-sidebar-edit-mode section.item,
|
||||||
|
#menu.bsplus-sidebar-edit-mode .bsplus-sidebar-offscreen,
|
||||||
|
#menu.bsplus-sidebar-edit-mode .bsplus-sidebar-offscreen * {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
user-select: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu.bsplus-sidebar-edit-mode:has(> ul > li.hasChildren.active)
|
||||||
|
> ul
|
||||||
|
> li:not(.hasChildren.active) {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu.bsplus-sidebar-edit-mode > ul > .bsplus-sidebar-offscreen:not(.hasChildren.active),
|
||||||
|
#menu.bsplus-sidebar-edit-mode .sub .bsplus-sidebar-offscreen:not(.hasChildren.active) {
|
||||||
|
position: relative !important;
|
||||||
|
left: auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
margin: inherit !important;
|
||||||
|
padding: inherit !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
clip: auto !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu.bsplus-sidebar-edit-mode .item.draggable {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu.bsplus-sidebar-edit-mode .item.draggable > label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu.bsplus-sidebar-edit-mode .onoffswitch {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
#menu section > label {
|
#menu section > label {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -796,6 +863,11 @@ ol:has([class*="MessageList__avatar___"] svg) {
|
|||||||
.quickbar .actions [title="Choose a colour"] > svg {
|
.quickbar .actions [title="Choose a colour"] > svg {
|
||||||
scale: 0.9;
|
scale: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quickbar .actions .timetable-edit-quickbar-btn > svg {
|
||||||
|
scale: 0.9;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
.quickbar[data-yiq="light"] .actions {
|
.quickbar[data-yiq="light"] .actions {
|
||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
@@ -1026,7 +1098,13 @@ div > ol:has(.uiFileHandlerWrapper) {
|
|||||||
min-height: 128px !important;
|
min-height: 128px !important;
|
||||||
}
|
}
|
||||||
body.student #menu > ul::before {
|
body.student #menu > ul::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
background-image: var(--betterseqta-logo) !important;
|
background-image: var(--betterseqta-logo) !important;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: auto 48px;
|
||||||
position: -webkit-sticky;
|
position: -webkit-sticky;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -2660,11 +2738,24 @@ body {
|
|||||||
.days {
|
.days {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.modaliser {
|
/* Do not hide .modaliser globally — SEQTA Modaliser relies on transitionend to
|
||||||
display: none;
|
dispose; display:none prevents that and leaves empty shells that block clicks. */
|
||||||
|
.modaliser-container:not(.visible) {
|
||||||
|
display: none !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modaliser-container.visible .modaliser {
|
||||||
background: var(--better-main);
|
background: var(--better-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ColourChooser teardown can leave a full-screen uiSlidePane that blocks entry clicks */
|
||||||
|
.uiSlidePane:not(.shown):has(.pane.colourChooser) {
|
||||||
|
display: none !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
[class*="MessageList__unread___"] {
|
[class*="MessageList__unread___"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--background-secondary, rgb(228 225 225));
|
background: var(--background-secondary, rgb(228 225 225));
|
||||||
@@ -2742,39 +2833,9 @@ body {
|
|||||||
.defaultWelcomeWrapper {
|
.defaultWelcomeWrapper {
|
||||||
background: unset !important;
|
background: unset !important;
|
||||||
}
|
}
|
||||||
.clr-swatches button::after,
|
|
||||||
.clr-dark .clr-preview::after,
|
/* Coloris (timetable subject colours): cosmetic only — do not unset
|
||||||
.clr-field button::after {
|
transforms/animations on ::after (breaks picker reopen). */
|
||||||
opacity: unset;
|
|
||||||
padding-top: unset;
|
|
||||||
-webkit-transform: unset;
|
|
||||||
transform: unset;
|
|
||||||
-webkit-transform-origin: unset;
|
|
||||||
transform-origin: unset;
|
|
||||||
visibility: unset;
|
|
||||||
-webkit-animation-name: unset !important;
|
|
||||||
animation-name: unset !important;
|
|
||||||
background-color: currentColor !important;
|
|
||||||
}
|
|
||||||
.clr-swatches button {
|
|
||||||
align-items: unset;
|
|
||||||
display: block;
|
|
||||||
padding: unset;
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
.clr-clear {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.clr-preview::before,
|
|
||||||
.clr-preview::after {
|
|
||||||
visibility: unset;
|
|
||||||
-webkit-transform-origin: unset;
|
|
||||||
transform-origin: unset;
|
|
||||||
-webkit-transform: unset;
|
|
||||||
transform: unset;
|
|
||||||
padding-top: unset;
|
|
||||||
opacity: unset;
|
|
||||||
}
|
|
||||||
#clr-color-preview {
|
#clr-color-preview {
|
||||||
margin: 15px 0 20px 20px;
|
margin: 15px 0 20px 20px;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -2783,6 +2844,18 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clr-swatches button {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Never let a closed Coloris picker intercept timetable clicks */
|
||||||
|
body:not(.clr-open) .clr-picker,
|
||||||
|
.clr-picker:not(.clr-open) {
|
||||||
|
display: none !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
.dark
|
.dark
|
||||||
[class*="MessageList__MessageList___"]
|
[class*="MessageList__MessageList___"]
|
||||||
> ol
|
> ol
|
||||||
|
|||||||
Vendored
+16
@@ -10,6 +10,22 @@ declare module "*?inlineWorker" {
|
|||||||
export default value;
|
export default value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** CRXJS dynamic content / main-world script path (relative to extension root). */
|
||||||
|
declare module "*?script" {
|
||||||
|
const path: string;
|
||||||
|
export default path;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*?script&iife" {
|
||||||
|
const path: string;
|
||||||
|
export default path;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*?script&module" {
|
||||||
|
const path: string;
|
||||||
|
export default path;
|
||||||
|
}
|
||||||
|
|
||||||
declare module "*.png?base64" {
|
declare module "*.png?base64" {
|
||||||
const value: string;
|
const value: string;
|
||||||
export default value;
|
export default value;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolveCloudPfp } from "@/seqta/utils/cloudPfpCache";
|
import { resolveCloudPfp, defaultAccountsPfpUrl } from "@/seqta/utils/cloudPfpCache";
|
||||||
import type { CloudUser } from "@/seqta/utils/CloudAuth";
|
import type { CloudUser } from "@/seqta/utils/CloudAuth";
|
||||||
|
|
||||||
const { user, class: className = "" } = $props<{
|
const { user, class: className = "" } = $props<{
|
||||||
@@ -18,10 +18,12 @@
|
|||||||
}
|
}
|
||||||
avatarSrc = undefined;
|
avatarSrc = undefined;
|
||||||
|
|
||||||
if (!u?.pfpUrl || !u.id) return;
|
if (!u?.id) return;
|
||||||
|
|
||||||
|
const pfpUrl = u.pfpUrl ?? defaultAccountsPfpUrl(u.id);
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void resolveCloudPfp(u.id, u.pfpUrl).then((resolved) => {
|
void resolveCloudPfp(u.id, pfpUrl).then((resolved) => {
|
||||||
if (cancelled || !resolved) return;
|
if (cancelled || !resolved) return;
|
||||||
if (resolved.fromCache) {
|
if (resolved.fromCache) {
|
||||||
revokeUrl = resolved.src;
|
revokeUrl = resolved.src;
|
||||||
|
|||||||
@@ -1,82 +1,203 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { state, onChange, options } = $props<{
|
let { value, onChange, options } = $props<{
|
||||||
state: string,
|
value: string,
|
||||||
onChange: (newState: string) => void,
|
onChange: (newValue: string) => void,
|
||||||
options: Array<{ value: string, label: string }>
|
options: Array<{ value: string, label: string }>
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let select: HTMLSelectElement;
|
const listboxId = `select-listbox-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
|
let isOpen = $state(false);
|
||||||
|
let activeIndex = $state(0);
|
||||||
|
let trigger = $state<HTMLButtonElement>();
|
||||||
|
|
||||||
|
function openMenu(preferredIndex?: number) {
|
||||||
|
isOpen = true;
|
||||||
|
const selectedIndex = options.findIndex((option) => option.value === value);
|
||||||
|
activeIndex = preferredIndex ?? (selectedIndex >= 0 ? selectedIndex : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu(returnFocus = true) {
|
||||||
|
isOpen = false;
|
||||||
|
if (returnFocus) trigger?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectValue(nextValue: string) {
|
||||||
|
onChange(nextValue);
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent, inListbox = false) {
|
||||||
|
const { key } = event;
|
||||||
|
|
||||||
|
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
const count = options.length;
|
||||||
|
if (!count) return;
|
||||||
|
if (isOpen || inListbox) {
|
||||||
|
activeIndex = (activeIndex + (key === "ArrowDown" ? 1 : -1) + count) % count;
|
||||||
|
} else {
|
||||||
|
openMenu();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "Enter" || key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isOpen) {
|
||||||
|
const option = options[activeIndex];
|
||||||
|
if (option) selectValue(option.value);
|
||||||
|
} else {
|
||||||
|
openMenu();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "Escape" && isOpen) {
|
||||||
|
event.preventDefault();
|
||||||
|
closeMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inListbox) return;
|
||||||
|
|
||||||
|
if (key === "Home") {
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex = 0;
|
||||||
|
} else if (key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex = Math.max(0, options.length - 1);
|
||||||
|
} else if (key === "Tab") {
|
||||||
|
closeMenu(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
queueMicrotask(() => document.getElementById(listboxId)?.focus());
|
||||||
|
|
||||||
|
const wrapper = trigger?.parentElement;
|
||||||
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
|
if (wrapper && event.composedPath().includes(wrapper)) return;
|
||||||
|
closeMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", onPointerDown, true);
|
||||||
|
return () => document.removeEventListener("pointerdown", onPointerDown, true);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="select-wrapper relative w-full overflow-hidden rounded-2xl border shadow-2xl">
|
<div class="select relative w-full">
|
||||||
<select
|
<button
|
||||||
bind:this={select}
|
bind:this={trigger}
|
||||||
value={state}
|
type="button"
|
||||||
onchange={() => onChange(select.value)}
|
class="select-trigger flex w-full items-center justify-between gap-3 rounded-[18px] border px-4 py-2.5 text-sm font-medium leading-tight shadow-2xl transition-[background-color,border-color,box-shadow] duration-200 cursor-pointer"
|
||||||
class="select-input w-full appearance-none border-none bg-transparent px-4 py-2.5 pr-10 text-[0.875rem] font-medium transition-colors"
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-controls={listboxId}
|
||||||
|
onclick={() => (isOpen ? closeMenu() : openMenu())}
|
||||||
|
onkeydown={onKeydown}
|
||||||
>
|
>
|
||||||
{#each options as option}
|
<span class="truncate">
|
||||||
<option value={option.value}>
|
{options.find((option) => option.value === value)?.label ?? value}
|
||||||
{option.label}
|
</span>
|
||||||
</option>
|
<span class="select-icon shrink-0" aria-hidden="true">
|
||||||
{/each}
|
<svg viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
||||||
</select>
|
<path
|
||||||
<span class="select-icon pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3" aria-hidden="true">
|
fill-rule="evenodd"
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4">
|
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"
|
||||||
<path 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"></path>
|
clip-rule="evenodd"
|
||||||
</svg>
|
/>
|
||||||
</span>
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div
|
||||||
|
id={listboxId}
|
||||||
|
class="select-menu absolute inset-x-0 top-[calc(100%+0.35rem)] z-50 flex max-h-72 flex-col gap-0.5 p-2"
|
||||||
|
role="listbox"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-activedescendant={options[activeIndex] ? `${listboxId}-opt-${activeIndex}` : undefined}
|
||||||
|
onkeydown={(event) => onKeydown(event, true)}
|
||||||
|
>
|
||||||
|
{#each options as option, index (option.value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id={`${listboxId}-opt-${index}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={option.value === value}
|
||||||
|
class="select-option block w-full rounded-[10px] border-none px-3.5 py-2.5 text-left text-sm font-medium leading-snug transition-colors duration-150 cursor-pointer"
|
||||||
|
class:is-selected={option.value === value}
|
||||||
|
class:is-active={index === activeIndex}
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={() => selectValue(option.value)}
|
||||||
|
onmouseenter={() => (activeIndex = index)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.select-wrapper {
|
.select {
|
||||||
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
|
--sel-border: var(--theme-offset-bg, var(--theme-secondary, #e5e7eb));
|
||||||
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 72%, transparent);
|
--sel-bg: var(--theme-primary, #ffffff);
|
||||||
border-radius: 18px;
|
--sel-surface: var(--theme-secondary, #e5e7eb);
|
||||||
|
--sel-focus-border: color-mix(in srgb, var(--text-primary) 22%, var(--theme-secondary, #e5e7eb) 78%);
|
||||||
|
--sel-ring: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||||
|
--sel-menu-shadow:
|
||||||
|
0 10px 25px -5px rgb(0 0 0 / 0.25),
|
||||||
|
0 8px 10px -6px rgb(0 0 0 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-trigger {
|
||||||
|
border-color: var(--sel-border);
|
||||||
|
background: var(--sel-bg);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition:
|
|
||||||
background-color 180ms ease,
|
|
||||||
border-color 180ms ease,
|
|
||||||
box-shadow 180ms ease,
|
|
||||||
transform 180ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-wrapper:hover {
|
.select-trigger:hover,
|
||||||
background: color-mix(in srgb, var(--background-primary) 94%, var(--background-secondary) 6%);
|
.select-trigger:focus-visible {
|
||||||
border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 88%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-wrapper:focus-within {
|
|
||||||
background: color-mix(in srgb, var(--background-primary) 96%, var(--background-secondary) 4%);
|
|
||||||
border-color: color-mix(in srgb, var(--text-primary) 22%, var(--theme-offset-bg, var(--background-secondary)) 78%);
|
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-input {
|
|
||||||
color: var(--text-primary);
|
|
||||||
outline: none;
|
outline: none;
|
||||||
text-overflow: ellipsis;
|
background: var(--sel-surface);
|
||||||
|
border-color: var(--sel-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-input:hover,
|
.select-trigger:focus-visible {
|
||||||
.select-input:focus {
|
border-color: var(--sel-focus-border);
|
||||||
background: transparent;
|
box-shadow: var(--sel-ring);
|
||||||
}
|
|
||||||
|
|
||||||
.select-input option {
|
|
||||||
background: var(--background-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-icon {
|
.select-icon {
|
||||||
color: color-mix(in srgb, var(--text-primary) 60%, transparent);
|
color: color-mix(in srgb, var(--text-primary) 60%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-input {
|
.select-menu {
|
||||||
color-scheme: light;
|
border: 1px solid var(--sel-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--sel-bg);
|
||||||
|
box-shadow: var(--sel-menu-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .select-input {
|
.select-menu:focus-visible {
|
||||||
color-scheme: dark;
|
outline: none;
|
||||||
|
box-shadow: var(--sel-menu-shadow), var(--sel-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option:hover,
|
||||||
|
.select-option:focus-visible,
|
||||||
|
.select-option.is-active,
|
||||||
|
.select-option.is-selected {
|
||||||
|
outline: none;
|
||||||
|
background: var(--sel-surface);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { LUCIDE_MOON_PATH } from "@/lib/icons/lucideMoon";
|
||||||
|
|
||||||
|
let { class: className = "w-5 h-5" }: { class?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
class={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d={LUCIDE_MOON_PATH} />
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { LUCIDE_SUN_PATH } from "@/lib/icons/lucideSun";
|
||||||
|
|
||||||
|
let { class: className = "w-5 h-5" }: { class?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
class={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d={LUCIDE_SUN_PATH} />
|
||||||
|
</svg>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader'
|
import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader'
|
||||||
import BackgroundUploader from './BackgroundUploader.svelte';
|
import BackgroundUploader from './BackgroundUploader.svelte';
|
||||||
import BackgroundItem from './BackgroundItem.svelte'
|
import BackgroundItem from './BackgroundItem.svelte'
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
|
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
|
||||||
import { delay } from 'lodash'
|
|
||||||
import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates'
|
import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates'
|
||||||
|
|
||||||
let { isEditMode, selectNoBackground = $bindable(), selectedBackground = $bindable() } = $props<{ isEditMode: boolean, selectNoBackground: () => void, selectedBackground: string | null }>();
|
let { isEditMode, selectNoBackground = $bindable(), selectedBackground = $bindable() } = $props<{ isEditMode: boolean, selectNoBackground: () => void, selectedBackground: string | null }>();
|
||||||
@@ -14,10 +13,9 @@
|
|||||||
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'));
|
||||||
|
|
||||||
let isVisible = $state(false);
|
function setError(e: unknown) {
|
||||||
let element: HTMLElement;
|
error = e instanceof Error ? e.message : 'An unknown error occurred';
|
||||||
let observer: MutationObserver;
|
}
|
||||||
let parentElement: HTMLElement | null = null;
|
|
||||||
|
|
||||||
async function getTheme() {
|
async function getTheme() {
|
||||||
return localStorage.getItem('selectedBackground');
|
return localStorage.getItem('selectedBackground');
|
||||||
@@ -47,34 +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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadBackgroundMetadata(): Promise<void> {
|
|
||||||
try {
|
|
||||||
error = null;
|
|
||||||
|
|
||||||
if (!isIndexedDBSupported()) {
|
|
||||||
throw new Error("Your browser doesn't support IndexedDB. Unable to load backgrounds.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await openDatabase();
|
|
||||||
const data = await readAllData();
|
|
||||||
selectedBackground = await getTheme();
|
|
||||||
|
|
||||||
// Only load metadata (id and type) for placeholders
|
|
||||||
backgrounds = data.map(({ id, type }) => ({ id, type, blob: null }));
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error) {
|
|
||||||
error = e.message;
|
|
||||||
} else {
|
|
||||||
error = 'An unknown error occurred';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +57,9 @@
|
|||||||
throw new Error("Your browser doesn't support IndexedDB. Unable to load backgrounds.");
|
throw new Error("Your browser doesn't support IndexedDB. Unable to load backgrounds.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedBackground = await getTheme();
|
||||||
const dbData = await readAllData();
|
const dbData = await readAllData();
|
||||||
|
|
||||||
// Release existing object URLs to prevent memory leaks
|
// Release existing object URLs to prevent memory leaks
|
||||||
backgrounds.forEach(bg => {
|
backgrounds.forEach(bg => {
|
||||||
if (bg.url) URL.revokeObjectURL(bg.url);
|
if (bg.url) URL.revokeObjectURL(bg.url);
|
||||||
@@ -106,11 +78,7 @@
|
|||||||
selectNoBackground();
|
selectNoBackground();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) {
|
setError(e);
|
||||||
error = e.message;
|
|
||||||
} else {
|
|
||||||
error = 'An unknown error occurred';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +87,7 @@
|
|||||||
selectNoBackground();
|
selectNoBackground();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedBackground = fileId;
|
selectedBackground = fileId;
|
||||||
setTheme(fileId);
|
setTheme(fileId);
|
||||||
}
|
}
|
||||||
@@ -133,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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,40 +121,23 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function checkActiveClass() {
|
|
||||||
if (parentElement?.classList.contains('active')) {
|
|
||||||
delay(() => {
|
|
||||||
isVisible = true;
|
|
||||||
syncBackgrounds();
|
|
||||||
}, 600);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadBackgroundMetadata();
|
syncBackgrounds();
|
||||||
backgroundUpdates.addListener(syncBackgrounds);
|
backgroundUpdates.addListener(syncBackgrounds);
|
||||||
|
|
||||||
parentElement = element.closest('.tab');
|
|
||||||
if (parentElement) {
|
|
||||||
observer = new MutationObserver(checkActiveClass);
|
|
||||||
observer.observe(parentElement, { attributes: true, attributeFilter: ['class'] });
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
observer?.disconnect();
|
|
||||||
backgroundUpdates.removeListener(syncBackgrounds);
|
backgroundUpdates.removeListener(syncBackgrounds);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
observer?.disconnect();
|
|
||||||
backgrounds.forEach((bg) => {
|
backgrounds.forEach((bg) => {
|
||||||
if (bg.url) URL.revokeObjectURL(bg.url);
|
if (bg.url) URL.revokeObjectURL(bg.url);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={element} class="relative px-1 { !( isEditMode && imageBackgrounds.length === 0 && videoBackgrounds.length === 0 ) && 'pt-2' }">
|
<div class="relative px-1 { !( isEditMode && imageBackgrounds.length === 0 && videoBackgrounds.length === 0 ) && 'pt-2' }">
|
||||||
{#if !(imageBackgrounds.length === 0 && isEditMode)}
|
{#if !(imageBackgrounds.length === 0 && isEditMode)}
|
||||||
<h2 class="pb-2 text-lg font-bold">Background Images</h2>
|
<h2 class="pb-2 text-lg font-bold">Background Images</h2>
|
||||||
<div class="flex flex-wrap gap-4 mb-4">
|
<div class="flex flex-wrap gap-4 mb-4">
|
||||||
@@ -198,7 +145,7 @@
|
|||||||
<BackgroundUploader on:fileChange={e => handleFileChange(e.detail)} />
|
<BackgroundUploader on:fileChange={e => handleFileChange(e.detail)} />
|
||||||
{/if}
|
{/if}
|
||||||
{#each imageBackgrounds as bg (bg.id)}
|
{#each imageBackgrounds as bg (bg.id)}
|
||||||
{#if isVisible && bg.blob}
|
{#if bg.url}
|
||||||
<BackgroundItem
|
<BackgroundItem
|
||||||
bg={bg}
|
bg={bg}
|
||||||
isSelected={selectedBackground === bg.id}
|
isSelected={selectedBackground === bg.id}
|
||||||
@@ -219,7 +166,7 @@
|
|||||||
<BackgroundUploader on:fileChange={e => handleFileChange(e.detail)} />
|
<BackgroundUploader on:fileChange={e => handleFileChange(e.detail)} />
|
||||||
{/if}
|
{/if}
|
||||||
{#each videoBackgrounds as bg (bg.id)}
|
{#each videoBackgrounds as bg (bg.id)}
|
||||||
{#if isVisible && bg.blob}
|
{#if bg.url}
|
||||||
<BackgroundItem
|
<BackgroundItem
|
||||||
bg={bg}
|
bg={bg}
|
||||||
isSelected={selectedBackground === bg.id}
|
isSelected={selectedBackground === bg.id}
|
||||||
@@ -233,4 +180,4 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { blobToDataUrl } from '@/plugins/built-in/themes/themeImageUrl'
|
||||||
|
|
||||||
|
let { source, alt = '', class: className = '' } = $props<{
|
||||||
|
source: string | Blob | null | undefined
|
||||||
|
alt?: string
|
||||||
|
class?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
let src = $state('')
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const value = source
|
||||||
|
if (!value) {
|
||||||
|
src = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
src = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
void blobToDataUrl(value).then((url) => {
|
||||||
|
if (!cancelled) src = url
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if src}
|
||||||
|
<img {src} {alt} class={className} />
|
||||||
|
{/if}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
||||||
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
||||||
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte'
|
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte'
|
||||||
|
import ThemeBlobImage from '@/interface/components/themes/ThemeBlobImage.svelte'
|
||||||
|
|
||||||
const themeManager = ThemeManager.getInstance();
|
const themeManager = ThemeManager.getInstance();
|
||||||
|
|
||||||
@@ -242,8 +243,8 @@
|
|||||||
|
|
||||||
<div class="relative top-0 z-10 flex justify-center w-full h-full overflow-hidden transition dark:text-white rounded-xl group place-items-center bg-zinc-100 dark:bg-zinc-900 { isEditMode ? 'animate-shake brightness-90' : ''}">
|
<div class="relative top-0 z-10 flex justify-center w-full h-full overflow-hidden transition dark:text-white rounded-xl group place-items-center bg-zinc-100 dark:bg-zinc-900 { isEditMode ? 'animate-shake brightness-90' : ''}">
|
||||||
{#if theme.coverImage}
|
{#if theme.coverImage}
|
||||||
<img
|
<ThemeBlobImage
|
||||||
src={typeof theme.coverImage === 'string' ? theme.coverImage : URL.createObjectURL(theme.coverImage)}
|
source={theme.coverImage}
|
||||||
alt={theme.name}
|
alt={theme.name}
|
||||||
class="object-cover absolute inset-0 z-0 w-full h-full pointer-events-none"
|
class="object-cover absolute inset-0 z-0 w-full h-full pointer-events-none"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
button {
|
||||||
|
@apply cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
&:focus {
|
||||||
|
box-shadow: unset !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar {
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
}
|
||||||
@@ -5,9 +5,10 @@ import browser from "webextension-polyfill";
|
|||||||
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
|
||||||
import renderSvelte from "./main";
|
import renderSvelte from "./main";
|
||||||
import { initializeSettingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { initializeSettingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
import { initVerboseLogging, verboseInfo } from "@/utils/verboseLog";
|
||||||
|
|
||||||
function InjectCustomIcons() {
|
function InjectCustomIcons() {
|
||||||
console.info("[BetterSEQTA+] Injecting Icons");
|
verboseInfo("[BetterSEQTA+] Injecting Icons");
|
||||||
|
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.setAttribute("type", "text/css");
|
style.setAttribute("type", "text/css");
|
||||||
@@ -31,5 +32,6 @@ InjectCustomIcons();
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await initializeSettingsState();
|
await initializeSettingsState();
|
||||||
|
initVerboseLogging();
|
||||||
renderSvelte(Settings, mountPoint, { standalone: true });
|
renderSvelte(Settings, mountPoint, { standalone: true });
|
||||||
})();
|
})();
|
||||||
|
|||||||
Vendored
-2
@@ -1,5 +1,3 @@
|
|||||||
import "./index.css";
|
|
||||||
|
|
||||||
declare module "*.png";
|
declare module "*.png";
|
||||||
declare module "*.svg";
|
declare module "*.svg";
|
||||||
declare module "*.jpeg";
|
declare module "*.jpeg";
|
||||||
|
|||||||
@@ -106,10 +106,15 @@
|
|||||||
showCloudPanel = true;
|
showCloudPanel = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const showDisclaimer = (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => {
|
const showDisclaimer = (
|
||||||
|
onConfirm: () => void,
|
||||||
|
onCancel: () => void,
|
||||||
|
title = "Confirm",
|
||||||
|
message = "",
|
||||||
|
) => {
|
||||||
disclaimerCallbacks = { onConfirm, onCancel };
|
disclaimerCallbacks = { onConfirm, onCancel };
|
||||||
disclaimerTitle = title ?? "Confirm";
|
disclaimerTitle = title;
|
||||||
disclaimerMessage = message ?? "";
|
disclaimerMessage = message;
|
||||||
showDisclaimerModal = true;
|
showDisclaimerModal = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -250,7 +250,7 @@
|
|||||||
id: 10,
|
id: 10,
|
||||||
Component: Select,
|
Component: Select,
|
||||||
props: {
|
props: {
|
||||||
state: $settingsState.defaultPage ?? "home",
|
value: $settingsState.defaultPage ?? "home",
|
||||||
onChange: (value: string) => (settingsState.defaultPage = value),
|
onChange: (value: string) => (settingsState.defaultPage = value),
|
||||||
options: [
|
options: [
|
||||||
{ value: "home", label: "Home" },
|
{ value: "home", label: "Home" },
|
||||||
@@ -269,7 +269,7 @@
|
|||||||
id: 11,
|
id: 11,
|
||||||
Component: Select,
|
Component: Select,
|
||||||
props: {
|
props: {
|
||||||
state: $settingsState.newsSource,
|
value: $settingsState.newsSource,
|
||||||
onChange: (value: string) => settingsState.newsSource = value,
|
onChange: (value: string) => settingsState.newsSource = value,
|
||||||
options: [
|
options: [
|
||||||
{ value: "australia", label: "Australia" },
|
{ value: "australia", label: "Australia" },
|
||||||
@@ -290,6 +290,65 @@
|
|||||||
{@render Setting(option)}
|
{@render Setting(option)}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<div class="border-none">
|
||||||
|
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
|
||||||
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Home Page Assessments</h2>
|
||||||
|
<p class="text-xs">Limit upcoming assessments shown on the home page by subject</p>
|
||||||
|
</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="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Include Past Assessments</h2>
|
||||||
|
<p class="text-xs">Show past-due assessments from the upcoming list, matching the Assessments page</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Switch
|
||||||
|
state={$settingsState.homeUpcomingIncludePast ?? true}
|
||||||
|
onChange={(isOn: boolean) => (settingsState.homeUpcomingIncludePast = isOn)}
|
||||||
|
/>
|
||||||
|
</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="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Maximum Subjects</h2>
|
||||||
|
<p class="text-xs">Number of subjects to include, ordered by soonest due date</p>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={String($settingsState.homeUpcomingSubjectsMax ?? 5)}
|
||||||
|
onChange={(value: string) => (settingsState.homeUpcomingSubjectsMax = Number(value))}
|
||||||
|
options={[
|
||||||
|
{ value: "0", label: "All" },
|
||||||
|
{ value: "3", label: "3" },
|
||||||
|
{ value: "5", label: "5" },
|
||||||
|
{ value: "7", label: "7" },
|
||||||
|
{ value: "10", label: "10" },
|
||||||
|
{ value: "15", label: "15" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</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="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Maximum Assessments per Subject</h2>
|
||||||
|
<p class="text-xs">Assessments shown for each included subject</p>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={String($settingsState.homeUpcomingAssessmentsPerSubjectMax ?? 0)}
|
||||||
|
onChange={(value: string) => (settingsState.homeUpcomingAssessmentsPerSubjectMax = Number(value))}
|
||||||
|
options={[
|
||||||
|
{ value: "0", label: "All" },
|
||||||
|
{ value: "1", label: "1" },
|
||||||
|
{ value: "2", label: "2" },
|
||||||
|
{ value: "3", label: "3" },
|
||||||
|
{ value: "5", label: "5" },
|
||||||
|
{ value: "10", label: "10" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="border-none">
|
<div class="border-none">
|
||||||
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
|
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
|
||||||
<div class="flex justify-between items-center px-4 py-3">
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
@@ -406,7 +465,7 @@
|
|||||||
/>
|
/>
|
||||||
{:else if setting.type === 'select'}
|
{:else if setting.type === 'select'}
|
||||||
<Select
|
<Select
|
||||||
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
|
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
|
||||||
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
||||||
options={(setting.options as string[]).map(opt => ({
|
options={(setting.options as string[]).map(opt => ({
|
||||||
value: opt,
|
value: opt,
|
||||||
@@ -493,6 +552,18 @@
|
|||||||
<Switch state={$settingsState.devMode} onChange={(isOn: boolean) => settingsState.devMode = isOn} />
|
<Switch state={$settingsState.devMode} onChange={(isOn: boolean) => settingsState.devMode = isOn} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Verbose logging</h2>
|
||||||
|
<p class="text-xs">Show diagnostic console output (indexer, theme manager, timetable colour patch, etc.)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Switch
|
||||||
|
state={$settingsState.verboseLogging ?? false}
|
||||||
|
onChange={(isOn: boolean) => settingsState.verboseLogging = isOn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex justify-between items-center px-4 py-3">
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
<div class="pr-4">
|
<div class="pr-4">
|
||||||
<h2 class="text-sm font-bold">Sensitive Hider</h2>
|
<h2 class="text-sm font-bold">Sensitive Hider</h2>
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
|
||||||
import { themeUpdates } from '../hooks/ThemeUpdates'
|
import { themeUpdates } from '../hooks/ThemeUpdates'
|
||||||
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
|
||||||
|
import ThemeBlobImage from '@/interface/components/themes/ThemeBlobImage.svelte'
|
||||||
|
import LucideMoon from '@/interface/components/icons/LucideMoon.svelte'
|
||||||
|
import LucideSun from '@/interface/components/icons/LucideSun.svelte'
|
||||||
|
|
||||||
const { themeID } = $props<{ themeID: string }>()
|
const { themeID } = $props<{ themeID: string }>()
|
||||||
const themeManager = ThemeManager.getInstance();
|
const themeManager = ThemeManager.getInstance();
|
||||||
@@ -73,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
|
||||||
}
|
}
|
||||||
@@ -230,7 +224,7 @@
|
|||||||
{#each theme.CustomImages as image (image.id)}
|
{#each theme.CustomImages as image (image.id)}
|
||||||
<div class="flex gap-2 items-center px-2 py-2 mb-4 h-16 bg-white rounded-lg shadow-lg dark:bg-zinc-700">
|
<div class="flex gap-2 items-center px-2 py-2 mb-4 h-16 bg-white rounded-lg shadow-lg dark:bg-zinc-700">
|
||||||
<div class="h-full">
|
<div class="h-full">
|
||||||
<img src={URL.createObjectURL(image.blob)} alt={image.variableName} class="object-contain h-full rounded" />
|
<ThemeBlobImage source={image.blob} alt={image.variableName} class="object-contain h-full rounded" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -252,19 +246,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if item.type === 'lightDarkToggle'}
|
{:else if item.type === 'lightDarkToggle'}
|
||||||
<button
|
<button
|
||||||
class="overflow-hidden relative px-4 py-1 text-xl font-medium rounded-lg transition bg-zinc-200 dark:bg-zinc-700 hover:bg-zinc-300 dark:hover:bg-zinc-600 font-IconFamily"
|
class="overflow-hidden relative flex justify-center items-center px-4 py-1 text-xl font-medium rounded-lg transition bg-zinc-200 dark:bg-zinc-700 hover:bg-zinc-300 dark:hover:bg-zinc-600"
|
||||||
onclick={() => (item.props as LightDarkToggleProps).onChange(!(item.props as LightDarkToggleProps).state)}
|
onclick={() => (item.props as LightDarkToggleProps).onChange(!(item.props as LightDarkToggleProps).state)}
|
||||||
>
|
>
|
||||||
{#key (item.props as LightDarkToggleProps).state}
|
{#key (item.props as LightDarkToggleProps).state}
|
||||||
<span
|
<span
|
||||||
class="absolute"
|
class="absolute flex items-center justify-center"
|
||||||
in:fade={{ duration: 150 }}
|
in:fade={{ duration: 150 }}
|
||||||
out:fade={{ duration: 150 }}
|
out:fade={{ duration: 150 }}
|
||||||
>
|
>
|
||||||
{(item.props as LightDarkToggleProps).state ? '\uec12' : '\uecfe'}
|
{#if (item.props as LightDarkToggleProps).state}
|
||||||
|
<LucideMoon class="w-5 h-5" />
|
||||||
|
{:else}
|
||||||
|
<LucideSun class="w-5 h-5" />
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/key}
|
{/key}
|
||||||
<span class='opacity-0'>{'\uec12'}</span>
|
<span class="opacity-0 inline-flex"><LucideMoon class="w-5 h-5" /></span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -330,7 +328,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if theme.coverImage}
|
{#if theme.coverImage}
|
||||||
<div class="absolute z-20 w-full h-full opacity-0 transition-opacity pointer-events-none group-hover:opacity-100 bg-black/20"></div>
|
<div class="absolute z-20 w-full h-full opacity-0 transition-opacity pointer-events-none group-hover:opacity-100 bg-black/20"></div>
|
||||||
<img src="{typeof theme.coverImage === 'string' ? theme.coverImage : URL.createObjectURL(theme.coverImage)}" alt='Cover' class="object-cover absolute z-0 w-full h-full rounded" />
|
<ThemeBlobImage source={theme.coverImage} alt="Cover" class="object-cover absolute z-0 w-full h-full rounded" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { mount } from "svelte";
|
||||||
|
import type { SvelteComponent } from "svelte";
|
||||||
|
import style from "./contentShadow.css?inline";
|
||||||
|
|
||||||
|
/** Mount Svelte UI inside a shadow root from content scripts (decoupled from settings popup CSS). */
|
||||||
|
export default function renderInShadow(
|
||||||
|
Component: SvelteComponent | any,
|
||||||
|
mountPoint: ShadowRoot | HTMLElement,
|
||||||
|
props: Record<string, any> = {},
|
||||||
|
) {
|
||||||
|
const app = mount(Component, {
|
||||||
|
target: mountPoint,
|
||||||
|
props: {
|
||||||
|
standalone: false,
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mountPoint instanceof ShadowRoot) {
|
||||||
|
const styleElement = document.createElement("style");
|
||||||
|
styleElement.textContent = style;
|
||||||
|
mountPoint.appendChild(styleElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ const THEME_CSS_VARS = [
|
|||||||
"--text-color",
|
"--text-color",
|
||||||
"--background-primary",
|
"--background-primary",
|
||||||
"--background-secondary",
|
"--background-secondary",
|
||||||
|
"--theme-primary",
|
||||||
|
"--theme-secondary",
|
||||||
"--text-primary",
|
"--text-primary",
|
||||||
"--theme-offset-bg",
|
"--theme-offset-bg",
|
||||||
"--better-sub",
|
"--better-sub",
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export function resolveExtensionAssetUrl(url: string): string {
|
|||||||
if (/^(?:chrome|moz)-extension:\/\/|https?:|data:/.test(url)) return url;
|
if (/^(?:chrome|moz)-extension:\/\/|https?:|data:/.test(url)) return url;
|
||||||
return browser.runtime.getURL(url.replace(/^\/+/, ""));
|
return browser.runtime.getURL(url.replace(/^\/+/, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it, jest } from "@jest/globals";
|
||||||
|
|
||||||
|
jest.mock("webextension-polyfill", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
runtime: {
|
||||||
|
getURL: (path: string) => `chrome-extension://testid/${path}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { extensionPageScriptUrl } from "./extensionPageScriptUrl";
|
||||||
|
|
||||||
|
describe("extensionPageScriptUrl", () => {
|
||||||
|
it("prefixes chrome.runtime.getURL and strips a leading slash", () => {
|
||||||
|
expect(extensionPageScriptUrl("assets/pageState.js")).toBe(
|
||||||
|
"chrome-extension://testid/assets/pageState.js",
|
||||||
|
);
|
||||||
|
expect(extensionPageScriptUrl("/assets/pageState.js")).toBe(
|
||||||
|
"chrome-extension://testid/assets/pageState.js",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a CRXJS `?script` / `?script&iife` import to a web-accessible
|
||||||
|
* chrome-extension:// URL. Works in both `vite build` and `vite dev`
|
||||||
|
* (plain `?url` imports 404 in CRXJS serve because they are not packaged).
|
||||||
|
*/
|
||||||
|
export function extensionPageScriptUrl(scriptPath: string): string {
|
||||||
|
return browser.runtime.getURL(scriptPath.replace(/^\/+/, ""));
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** 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="${LUCIDE_MOON_PATH}"/>`;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** 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="${LUCIDE_SUN_PATH}"/>`;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
|
const ORT_RESOURCE_DIR = "resources/ort/";
|
||||||
|
|
||||||
|
let configured = false;
|
||||||
|
|
||||||
|
function extensionAssetUrl(relativePath: string): string {
|
||||||
|
return browser.runtime.getURL(relativePath.replace(/^\/+/, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point HuggingFace transformers / onnxruntime at extension-local WASM files
|
||||||
|
* instead of CDN (required on SEQTA pages where page CSP blocks jsdelivr).
|
||||||
|
* Safe to call multiple times; must run before embeddia `initializeModel()`.
|
||||||
|
*/
|
||||||
|
export async function ensureTransformersEnv(
|
||||||
|
ortWasmBase?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (configured) return;
|
||||||
|
|
||||||
|
const { env } = await import("@huggingface/transformers");
|
||||||
|
const base = ortWasmBase ?? extensionAssetUrl(ORT_RESOURCE_DIR);
|
||||||
|
|
||||||
|
env.backends.onnx.wasm = env.backends.onnx.wasm ?? {};
|
||||||
|
env.backends.onnx.wasm.wasmPaths = base.endsWith("/") ? base : `${base}/`;
|
||||||
|
|
||||||
|
configured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrtWasmBaseUrl(): string {
|
||||||
|
const base = extensionAssetUrl(ORT_RESOURCE_DIR);
|
||||||
|
return base.endsWith("/") ? base : `${base}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For page-origin blob workers that cannot call `browser.runtime.getURL`. */
|
||||||
|
export async function configureTransformersEnvForBase(
|
||||||
|
ortWasmBase: string,
|
||||||
|
): Promise<void> {
|
||||||
|
configured = false;
|
||||||
|
await ensureTransformersEnv(ortWasmBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ORT_RESOURCE_DIR };
|
||||||
@@ -47,6 +47,8 @@
|
|||||||
"resources/update-image.webp",
|
"resources/update-image.webp",
|
||||||
"resources/pdfjs/pdf.worker.min.mjs",
|
"resources/pdfjs/pdf.worker.min.mjs",
|
||||||
"resources/pdfjs/pdf.legacy.min.mjs",
|
"resources/pdfjs/pdf.legacy.min.mjs",
|
||||||
|
"resources/ort/*",
|
||||||
|
"assets/*.css",
|
||||||
"assets/*"
|
"assets/*"
|
||||||
],
|
],
|
||||||
"matches": ["*://*/*"]
|
"matches": ["*://*/*"]
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { PluginAPI } from "@/plugins/core/types";
|
||||||
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
|
||||||
|
export const ANIMATED_BG_MARKER = "bsplus-animated-bg";
|
||||||
|
|
||||||
|
const LAYER_CLASSES = [
|
||||||
|
["bg", ANIMATED_BG_MARKER],
|
||||||
|
["bg", "bg2", ANIMATED_BG_MARKER],
|
||||||
|
["bg", "bg3", ANIMATED_BG_MARKER],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const bgSel = `.bg.${ANIMATED_BG_MARKER}`;
|
||||||
|
const scopeSel = `:scope > div${bgSel}`;
|
||||||
|
const BASE_SPEEDS = [3, 4, 5] as const;
|
||||||
|
|
||||||
|
export function updateAnimationSpeed(speed: number) {
|
||||||
|
document.querySelectorAll(bgSel).forEach((element, index) => {
|
||||||
|
const base = BASE_SPEEDS[index] ?? BASE_SPEEDS[2];
|
||||||
|
(element as HTMLElement).style.animationDuration = `${base / speed}s`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureAnimatedBackgroundLayers(
|
||||||
|
container: HTMLElement,
|
||||||
|
menu: HTMLElement,
|
||||||
|
speed: number,
|
||||||
|
): void {
|
||||||
|
if (container.querySelectorAll(scopeSel).length >= 3) {
|
||||||
|
updateAnimationSpeed(speed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.querySelectorAll(scopeSel).forEach((el) => el.remove());
|
||||||
|
|
||||||
|
for (const classes of LAYER_CLASSES) {
|
||||||
|
const bk = document.createElement("div");
|
||||||
|
classes.forEach((cls) => bk.classList.add(cls));
|
||||||
|
container.insertBefore(bk, menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnimationSpeed(speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeAnimatedBackgroundLayers(): void {
|
||||||
|
document.querySelectorAll(`div${bgSel}`).forEach((el) => el.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncAnimatedBackground(
|
||||||
|
api: PluginAPI<{ speed: number }>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [container, menu] = await Promise.all([
|
||||||
|
waitForElm("#container", true),
|
||||||
|
waitForElm("#menu", true),
|
||||||
|
]);
|
||||||
|
ensureAnimatedBackgroundLayers(
|
||||||
|
container as HTMLElement,
|
||||||
|
menu as HTMLElement,
|
||||||
|
api.settings.speed,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// #container / #menu not ready yet
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,11 @@ import {
|
|||||||
Setting,
|
Setting,
|
||||||
} from "@/plugins/core/settingsHelpers";
|
} from "@/plugins/core/settingsHelpers";
|
||||||
import styles from "./styles.css?inline";
|
import styles from "./styles.css?inline";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import {
|
||||||
|
removeAnimatedBackgroundLayers,
|
||||||
|
syncAnimatedBackground,
|
||||||
|
updateAnimationSpeed,
|
||||||
|
} from "./backgroundLayers";
|
||||||
|
|
||||||
const settings = defineSettings({
|
const settings = defineSettings({
|
||||||
speed: numberSetting({
|
speed: numberSetting({
|
||||||
@@ -36,48 +40,25 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
|
|||||||
settings: instance.settings,
|
settings: instance.settings,
|
||||||
|
|
||||||
run: async (api) => {
|
run: async (api) => {
|
||||||
const [container, menu] = await Promise.all([
|
await syncAnimatedBackground(api);
|
||||||
waitForElm("#container", true),
|
const resync = () => void syncAnimatedBackground(api);
|
||||||
waitForElm("#menu", true),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const backgrounds = [
|
const speedUnregister = api.settings.onChange("speed", updateAnimationSpeed);
|
||||||
{ classes: ["bg"] },
|
const pageChangeUnregister = api.seqta.onPageChange(resync);
|
||||||
{ classes: ["bg", "bg2"] },
|
window.addEventListener("pageshow", resync);
|
||||||
{ classes: ["bg", "bg3"] },
|
|
||||||
];
|
|
||||||
|
|
||||||
backgrounds.forEach(({ classes }) => {
|
const containerObserver = new MutationObserver(resync);
|
||||||
const bk = document.createElement("div");
|
const container = document.getElementById("container");
|
||||||
classes.forEach((cls) => bk.classList.add(cls));
|
if (container) containerObserver.observe(container, { childList: true });
|
||||||
container.insertBefore(bk, menu);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set initial speed
|
|
||||||
updateAnimationSpeed(api.settings.speed);
|
|
||||||
|
|
||||||
// Listen for speed changes
|
|
||||||
const speedUnregister = api.settings.onChange(
|
|
||||||
"speed",
|
|
||||||
updateAnimationSpeed,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Return cleanup function
|
|
||||||
return () => {
|
return () => {
|
||||||
speedUnregister.unregister();
|
speedUnregister.unregister();
|
||||||
// Remove background elements
|
pageChangeUnregister.unregister();
|
||||||
const backgrounds = document.getElementsByClassName("bg");
|
window.removeEventListener("pageshow", resync);
|
||||||
Array.from(backgrounds).forEach((element) => element.remove());
|
containerObserver.disconnect();
|
||||||
|
removeAnimatedBackgroundLayers();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateAnimationSpeed(speed: number) {
|
|
||||||
const bgElements = document.getElementsByClassName("bg");
|
|
||||||
Array.from(bgElements).forEach((element, index) => {
|
|
||||||
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
|
|
||||||
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default animatedBackgroundPlugin;
|
export default animatedBackgroundPlugin;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { extractWeightFromCoversheetText } from "./extractWeightFromCoversheetText";
|
||||||
|
|
||||||
|
describe("extractWeightFromCoversheetText", () => {
|
||||||
|
it("matches Weight: N", () => {
|
||||||
|
expect(extractWeightFromCoversheetText("Due date ... Weight: 20 Subject")).toBe(
|
||||||
|
"20",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches Weighting: N%", () => {
|
||||||
|
expect(
|
||||||
|
extractWeightFromCoversheetText("Assessment Weighting: 12.5% of semester"),
|
||||||
|
).toBe("12.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches Assessment weight: N", () => {
|
||||||
|
expect(
|
||||||
|
extractWeightFromCoversheetText("Assessment weight: 15\nCriteria"),
|
||||||
|
).toBe("15");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches Weight of N%", () => {
|
||||||
|
expect(extractWeightFromCoversheetText("This task has a weight of 10%")).toBe(
|
||||||
|
"10",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no weighting is present", () => {
|
||||||
|
expect(extractWeightFromCoversheetText("No marks available yet")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** Pull a numeric weighting from coversheet / report PDF text. */
|
||||||
|
export function extractWeightFromCoversheetText(text: string): string | null {
|
||||||
|
const patterns = [
|
||||||
|
/weightings?\s*:\s*(\d+(?:\.\d+)?)\s*%?/i,
|
||||||
|
/weight\s*:\s*(\d+(?:\.\d+)?)\s*%?/i,
|
||||||
|
/assessment\s+weight(?:ing)?\s*:\s*(\d+(?:\.\d+)?)\s*%?/i,
|
||||||
|
/weight(?:ing)?\s+(?:of\s+)?(\d+(?:\.\d+)?)\s*%/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (match?.[1]) return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
getPdfjsPageContextUrls,
|
getPdfjsPageContextUrls,
|
||||||
} from "@/lib/pdfjsExtension.ts";
|
} from "@/lib/pdfjsExtension.ts";
|
||||||
import * as pdfjs from "pdfjs-dist";
|
import * as pdfjs from "pdfjs-dist";
|
||||||
|
import { extractWeightFromCoversheetText } from "./extractWeightFromCoversheetText";
|
||||||
|
|
||||||
|
export { extractWeightFromCoversheetText };
|
||||||
|
|
||||||
ensurePdfjsWorker();
|
ensurePdfjsWorker();
|
||||||
|
|
||||||
@@ -552,135 +555,67 @@ export async function extractPDFText(url: string): Promise<string> {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
|
script.type = "module";
|
||||||
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
|
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
|
||||||
|
|
||||||
const escapedUrl = escJsSingleQuoted(url);
|
const escapedUrl = escJsSingleQuoted(url);
|
||||||
|
|
||||||
|
// Import the legacy build in page context so it can set
|
||||||
|
// globalThis.pdfjsLib, then parse the coversheet PDF.
|
||||||
script.textContent = `
|
script.textContent = `
|
||||||
(function() {
|
const requestId = '${requestId}';
|
||||||
const requestId = '${requestId}';
|
const pageOrigin = '${escapedOrigin}';
|
||||||
const pageOrigin = '${escapedOrigin}';
|
const url = '${escapedUrl}';
|
||||||
const url = '${escapedUrl}';
|
const pdfWorkerSrc = '${pdfWorkerInj}';
|
||||||
const pdfLibSrc = '${pdfLibInj}';
|
|
||||||
const pdfWorkerSrc = '${pdfWorkerInj}';
|
function postResult(payload) {
|
||||||
|
window.postMessage({ type: requestId, ...payload }, pageOrigin);
|
||||||
if (window.pdfjsLib) {
|
}
|
||||||
extractPDF();
|
|
||||||
|
try {
|
||||||
|
await import('${pdfLibInj}');
|
||||||
|
const pdfjsLib = globalThis.pdfjsLib;
|
||||||
|
if (!pdfjsLib?.getDocument) {
|
||||||
|
postResult({ success: false, error: 'pdfjsLib missing after import' });
|
||||||
} else {
|
} else {
|
||||||
const pdfjsScript = document.createElement('script');
|
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
|
||||||
pdfjsScript.src = pdfLibSrc;
|
|
||||||
pdfjsScript.type = 'module';
|
const response = await fetch(url, {
|
||||||
|
credentials: 'include',
|
||||||
pdfjsScript.onload = function() {
|
redirect: 'follow',
|
||||||
extractPDF();
|
});
|
||||||
};
|
if (!response.ok) {
|
||||||
pdfjsScript.onerror = function() {
|
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
|
||||||
window.postMessage({
|
|
||||||
type: requestId,
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to load pdfjs library'
|
|
||||||
}, pageOrigin);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.head.appendChild(pdfjsScript);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractPDF() {
|
|
||||||
try {
|
|
||||||
window.pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
|
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('GET', url, true);
|
|
||||||
xhr.responseType = 'arraybuffer';
|
|
||||||
xhr.withCredentials = true;
|
|
||||||
|
|
||||||
xhr.onload = function() {
|
|
||||||
if (xhr.status !== 200) {
|
|
||||||
window.postMessage({
|
|
||||||
type: requestId,
|
|
||||||
success: false,
|
|
||||||
error: 'HTTP ' + xhr.status + ': ' + xhr.statusText
|
|
||||||
}, pageOrigin);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const arrayBuffer = xhr.response;
|
|
||||||
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
|
|
||||||
throw new Error('PDF response is empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.pdfjsLib.getDocument({
|
|
||||||
data: arrayBuffer,
|
|
||||||
useSystemFonts: true,
|
|
||||||
verbosity: 0,
|
|
||||||
useWorkerFetch: false,
|
|
||||||
isEvalSupported: false
|
|
||||||
}).promise
|
|
||||||
.then(pdf => {
|
|
||||||
const pagePromises = [];
|
|
||||||
for (let i = 1; i <= pdf.numPages; i++) {
|
|
||||||
pagePromises.push(
|
|
||||||
pdf.getPage(i).then(page => {
|
|
||||||
return page.getTextContent().then(content => {
|
|
||||||
return content.items.map(item => item.str).join(' ');
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Promise.all(pagePromises);
|
|
||||||
})
|
|
||||||
.then(pages => {
|
|
||||||
const text = pages.join('\\n');
|
|
||||||
window.postMessage({
|
|
||||||
type: requestId,
|
|
||||||
success: true,
|
|
||||||
text: text
|
|
||||||
}, pageOrigin);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
window.postMessage({
|
|
||||||
type: requestId,
|
|
||||||
success: false,
|
|
||||||
error: 'PDF parsing error: ' + (error.message || String(error))
|
|
||||||
}, pageOrigin);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
window.postMessage({
|
|
||||||
type: requestId,
|
|
||||||
success: false,
|
|
||||||
error: 'ArrayBuffer error: ' + (error.message || String(error))
|
|
||||||
}, pageOrigin);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onerror = function() {
|
|
||||||
window.postMessage({
|
|
||||||
type: requestId,
|
|
||||||
success: false,
|
|
||||||
error: 'Network error fetching PDF'
|
|
||||||
}, pageOrigin);
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.ontimeout = function() {
|
|
||||||
window.postMessage({
|
|
||||||
type: requestId,
|
|
||||||
success: false,
|
|
||||||
error: 'Timeout fetching PDF'
|
|
||||||
}, pageOrigin);
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.timeout = 30000;
|
|
||||||
xhr.send();
|
|
||||||
} catch (error) {
|
|
||||||
window.postMessage({
|
|
||||||
type: requestId,
|
|
||||||
success: false,
|
|
||||||
error: 'Setup error: ' + (error.message || String(error))
|
|
||||||
}, pageOrigin);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
|
||||||
|
throw new Error('PDF response is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdf = await pdfjsLib.getDocument({
|
||||||
|
data: arrayBuffer,
|
||||||
|
useSystemFonts: true,
|
||||||
|
verbosity: 0,
|
||||||
|
useWorkerFetch: false,
|
||||||
|
isEvalSupported: false,
|
||||||
|
}).promise;
|
||||||
|
|
||||||
|
const pages = [];
|
||||||
|
for (let i = 1; i <= pdf.numPages; i++) {
|
||||||
|
const page = await pdf.getPage(i);
|
||||||
|
const content = await page.getTextContent();
|
||||||
|
pages.push(content.items.map((item) => item.str).join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
postResult({ success: true, text: pages.join('\\n') });
|
||||||
}
|
}
|
||||||
})();
|
} catch (error) {
|
||||||
|
postResult({
|
||||||
|
success: false,
|
||||||
|
error: 'PDF extraction error: ' + (error?.message || String(error)),
|
||||||
|
});
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const messageHandler = (event: MessageEvent) => {
|
const messageHandler = (event: MessageEvent) => {
|
||||||
@@ -723,6 +658,9 @@ export async function extractPDFText(url: string): Promise<string> {
|
|||||||
const pdf = await pdfjs.getDocument({
|
const pdf = await pdfjs.getDocument({
|
||||||
data: arrayBuffer,
|
data: arrayBuffer,
|
||||||
useSystemFonts: true,
|
useSystemFonts: true,
|
||||||
|
verbosity: 0,
|
||||||
|
useWorkerFetch: false,
|
||||||
|
isEvalSupported: false,
|
||||||
}).promise;
|
}).promise;
|
||||||
|
|
||||||
let text = "";
|
let text = "";
|
||||||
@@ -740,6 +678,85 @@ export async function extractPDFText(url: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function randomStudentPdfFileName(): string {
|
||||||
|
// Matches SEQTA Learn coversheet tokens, e.g. "mrr158ct.pdf".
|
||||||
|
return `${Math.random().toString(36).slice(2, 10)}.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestStudentAssessmentPdf(params: {
|
||||||
|
assessmentID: string | number;
|
||||||
|
metaclassID: string | number;
|
||||||
|
studentID: string | number;
|
||||||
|
}): Promise<string> {
|
||||||
|
const fileName = randomStudentPdfFileName();
|
||||||
|
|
||||||
|
const printResponse = await fetch(
|
||||||
|
`${location.origin}/seqta/student/print/assessment`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: Number(params.assessmentID),
|
||||||
|
metaclass: Number(params.metaclassID),
|
||||||
|
student: Number(params.studentID),
|
||||||
|
fileName,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!printResponse.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await printResponse.json()) as {
|
||||||
|
payload?: { file?: string };
|
||||||
|
status?: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = data.payload?.file;
|
||||||
|
if (!resolved) {
|
||||||
|
throw new Error(
|
||||||
|
`Print assessment response missing payload.file (status=${String(data.status)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStudentAssessmentReportUrl(fileName: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
type: "generated_report",
|
||||||
|
file: fileName,
|
||||||
|
});
|
||||||
|
return `${location.origin}/seqta/student/load/file?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractPDFTextWithRetry(
|
||||||
|
url: string,
|
||||||
|
attempts = 3,
|
||||||
|
delayMs = 1500,
|
||||||
|
): Promise<string> {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return await extractPDFText(url);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const retryable =
|
||||||
|
message.includes("404") ||
|
||||||
|
message.includes("empty") ||
|
||||||
|
message.includes("Failed to fetch PDF");
|
||||||
|
if (!retryable || attempt === attempts - 1) throw error;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleWeightings(mark: any, api: any) {
|
async function handleWeightings(mark: any, api: any) {
|
||||||
const assessmentID = assessmentIdKey(mark);
|
const assessmentID = assessmentIdKey(mark);
|
||||||
const metaclassID = mark.metaclassID;
|
const metaclassID = mark.metaclassID;
|
||||||
@@ -749,13 +766,25 @@ async function handleWeightings(mark: any, api: any) {
|
|||||||
| WeightingEntry
|
| WeightingEntry
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
const isFresh =
|
// Skip only when we already have a real numeric weight for this fingerprint.
|
||||||
|
// "N/A" / "processing" / in-flight refreshing must not permanently block retries.
|
||||||
|
const hasNumericWeight =
|
||||||
existing &&
|
existing &&
|
||||||
existing.weight !== "processing" &&
|
existing.weight !== "processing" &&
|
||||||
existing.fingerprint === fingerprint &&
|
existing.weight !== "N/A" &&
|
||||||
existing.pluginVersion === WEIGHTING_SCHEMA_VERSION;
|
!Number.isNaN(parseFloat(existing.weight));
|
||||||
|
|
||||||
if (isFresh) return;
|
const inFlightSameFingerprint =
|
||||||
|
existing &&
|
||||||
|
existing.fingerprint === fingerprint &&
|
||||||
|
(existing.weight === "processing" || existing.refreshing);
|
||||||
|
|
||||||
|
const isFresh =
|
||||||
|
Boolean(hasNumericWeight) &&
|
||||||
|
existing?.fingerprint === fingerprint &&
|
||||||
|
existing?.pluginVersion === WEIGHTING_SCHEMA_VERSION;
|
||||||
|
|
||||||
|
if (isFresh || inFlightSameFingerprint) return;
|
||||||
|
|
||||||
// If we have a previous usable value, keep showing it while we refetch
|
// If we have a previous usable value, keep showing it while we refetch
|
||||||
// by marking the entry as refreshing instead of wiping it. We claim the
|
// by marking the entry as refreshing instead of wiping it. We claim the
|
||||||
@@ -763,7 +792,7 @@ async function handleWeightings(mark: any, api: any) {
|
|||||||
// pass (e.g. a fast re-mount of the wrapper) doesn't kick off a duplicate
|
// pass (e.g. a fast re-mount of the wrapper) doesn't kick off a duplicate
|
||||||
// refetch for the same id while this one is still in flight.
|
// refetch for the same id while this one is still in flight.
|
||||||
const placeholder: WeightingEntry =
|
const placeholder: WeightingEntry =
|
||||||
existing && existing.weight !== "processing"
|
existing && hasNumericWeight
|
||||||
? {
|
? {
|
||||||
...existing,
|
...existing,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
@@ -807,34 +836,13 @@ async function handleWeightings(mark: any, api: any) {
|
|||||||
const userInfo = await getUserInfo();
|
const userInfo = await getUserInfo();
|
||||||
const userID = userInfo.id;
|
const userID = userInfo.id;
|
||||||
|
|
||||||
const filename =
|
const reportFile = await requestStudentAssessmentPdf({
|
||||||
"BetterSEQTA-" +
|
assessmentID,
|
||||||
String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
|
metaclassID,
|
||||||
|
studentID: userID,
|
||||||
const printResponse = await fetch(
|
});
|
||||||
`${location.origin}/seqta/student/print/assessment`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({
|
|
||||||
fileName: filename,
|
|
||||||
id: assessmentID,
|
|
||||||
metaclass: metaclassID,
|
|
||||||
student: userID,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!printResponse.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
pdfUrl = getStudentAssessmentReportUrl(reportFile);
|
||||||
pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pdfUrl.startsWith("blob:")) {
|
if (pdfUrl.startsWith("blob:")) {
|
||||||
@@ -843,32 +851,37 @@ async function handleWeightings(mark: any, api: any) {
|
|||||||
|
|
||||||
let text: string;
|
let text: string;
|
||||||
try {
|
try {
|
||||||
text = await extractPDFText(pdfUrl);
|
text = await extractPDFTextWithRetry(pdfUrl);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (
|
if (
|
||||||
isFirefox &&
|
isFirefox &&
|
||||||
(error?.message?.includes("blob") ||
|
(error?.message?.includes("blob") ||
|
||||||
error?.message?.includes("Security") ||
|
error?.message?.includes("Security") ||
|
||||||
error?.message?.includes("CSP"))
|
error?.message?.includes("CSP") ||
|
||||||
|
error?.message?.includes("empty"))
|
||||||
) {
|
) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
text = await extractPDFText(pdfUrl);
|
text = await extractPDFTextWithRetry(pdfUrl, 2, 2000);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`PDF extraction failed: ${error.message}`);
|
throw new Error(`PDF extraction failed: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = text.match(/weight:\s*(\d+\.?\d*)/i);
|
const weight = extractWeightFromCoversheetText(text);
|
||||||
|
|
||||||
api.storage.weightings = {
|
api.storage.weightings = {
|
||||||
...api.storage.weightings,
|
...api.storage.weightings,
|
||||||
[assessmentID]: {
|
[assessmentID]: {
|
||||||
weight: match ? match[1] : "N/A",
|
weight: weight ?? "N/A",
|
||||||
fingerprint,
|
fingerprint,
|
||||||
pluginVersion: WEIGHTING_SCHEMA_VERSION,
|
pluginVersion: WEIGHTING_SCHEMA_VERSION,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`[BetterSEQTA+] Weighting fetch failed for assessment ${assessmentID}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
api.storage.weightings = {
|
api.storage.weightings = {
|
||||||
...api.storage.weightings,
|
...api.storage.weightings,
|
||||||
[assessmentID]: {
|
[assessmentID]: {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { determineStatus, formatDate, getGradeValue } from "./utils";
|
import {
|
||||||
|
assessmentHasGradeDisplay,
|
||||||
|
determineStatus,
|
||||||
|
formatDate,
|
||||||
|
getDisplayGrade,
|
||||||
|
getThermoscorePercent,
|
||||||
|
} from "./utils";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
import { buildEngageAssessmentPagePath } from "@/seqta/utils/engageAssessmentStudent";
|
import { buildEngageAssessmentPagePath } from "@/seqta/utils/engageAssessmentStudent";
|
||||||
@@ -21,28 +27,12 @@
|
|||||||
|
|
||||||
const HIDDEN_ASSESSMENTS_KEY = "betterseqta-hidden-assessments";
|
const HIDDEN_ASSESSMENTS_KEY = "betterseqta-hidden-assessments";
|
||||||
|
|
||||||
function percentageToLetter(percentage: number): string {
|
function isLetterGradeMode(): boolean {
|
||||||
const letterMap: Record<number, string> = {
|
const allSettings = settingsState.getAll() as unknown as Record<
|
||||||
100: "A+",
|
string,
|
||||||
95: "A",
|
{ lettergrade?: boolean } | undefined
|
||||||
90: "A-",
|
>;
|
||||||
85: "B+",
|
return allSettings["plugin.assessments-average.settings"]?.lettergrade ?? false;
|
||||||
80: "B",
|
|
||||||
75: "B-",
|
|
||||||
70: "C+",
|
|
||||||
65: "C",
|
|
||||||
60: "C-",
|
|
||||||
55: "D+",
|
|
||||||
50: "D",
|
|
||||||
45: "D-",
|
|
||||||
40: "E+",
|
|
||||||
35: "E",
|
|
||||||
30: "E-",
|
|
||||||
0: "F",
|
|
||||||
};
|
|
||||||
|
|
||||||
const rounded = Math.ceil(percentage / 5) * 5;
|
|
||||||
return letterMap[rounded] || "F";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentFilters: FilterOptions = {
|
let currentFilters: FilterOptions = {
|
||||||
@@ -73,9 +63,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAssessmentGrade(a: any): string {
|
function getAssessmentGrade(a: any): string {
|
||||||
const val = getGradeValue(a);
|
return getDisplayGrade(a, isLetterGradeMode());
|
||||||
if (val === null) return "No grade";
|
|
||||||
return percentageToLetter(val);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGroupKey(assessment: any): string {
|
function getGroupKey(assessment: any): string {
|
||||||
@@ -522,7 +510,7 @@
|
|||||||
{#if assessment.submitted}
|
{#if assessment.submitted}
|
||||||
<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>
|
<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if isCompleted && status === "MARKS_RELEASED" && !assessment.results}
|
{#if isCompleted && status === "MARKS_RELEASED" && !assessmentHasGradeDisplay(assessment)}
|
||||||
<span class="card-label label-completed" style="background: #059669; color: white;">Completed</span>
|
<span class="card-label label-completed" style="background: #059669; color: white;">Completed</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -559,7 +547,7 @@
|
|||||||
|
|
||||||
<h3 class="assessment-title">{assessment.title}</h3>
|
<h3 class="assessment-title">{assessment.title}</h3>
|
||||||
|
|
||||||
{#if !assessment.results && !isCompleted}
|
{#if !assessmentHasGradeDisplay(assessment) && !isCompleted}
|
||||||
<div class="assessment-meta">
|
<div class="assessment-meta">
|
||||||
<div class="due-date {dueDateClass}">
|
<div class="due-date {dueDateClass}">
|
||||||
<OverviewIcon name="calendar-days" size={14} />
|
<OverviewIcon name="calendar-days" size={14} />
|
||||||
@@ -568,18 +556,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if assessment.results}
|
{#if assessmentHasGradeDisplay(assessment)}
|
||||||
|
{@const gradeLabel = getDisplayGrade(assessment, isLetterGradeMode())}
|
||||||
|
{@const barPercent = getThermoscorePercent(assessment) ?? 0}
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<div class="Thermoscore__Thermoscore___WFpL3" style="--fill-colour: {color}">
|
<div class="Thermoscore__Thermoscore___WFpL3" style="--fill-colour: {color}">
|
||||||
<div style="width: {assessment.results.percentage}%" class="Thermoscore__fill___ojxDI">
|
<div style="width: {barPercent}%" class="Thermoscore__fill___ojxDI">
|
||||||
<div title="{assessment.results.percentage}%" class="Thermoscore__text___XSR_M">
|
<div title={gradeLabel} class="Thermoscore__text___XSR_M">
|
||||||
{(() => {
|
{gradeLabel}
|
||||||
const allSettings = settingsState.getAll() as unknown as any;
|
|
||||||
const letterGradeSetting = allSettings["plugin.assessments-average.settings"]?.lettergrade;
|
|
||||||
return letterGradeSetting
|
|
||||||
? percentageToLetter(assessment.results.percentage)
|
|
||||||
: `${assessment.results.percentage}%`;
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
approximatePercentFromLetterGrade,
|
||||||
|
extractLetterGradeStringFromPayload,
|
||||||
|
} from "../gradeAnalytics/letterGradeScale";
|
||||||
|
|
||||||
export interface OverviewSubject {
|
export interface OverviewSubject {
|
||||||
code: string;
|
code: string;
|
||||||
programme: number;
|
programme: number;
|
||||||
@@ -101,6 +106,34 @@ export function assessmentBelongsToActiveSubjects(
|
|||||||
return activeSubjects.some((subject) => subject.code === code);
|
return activeSubjects.some((subject) => subject.code === code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function activeSubjectForAssessment(
|
||||||
|
assessment: Record<string, unknown>,
|
||||||
|
activeSubjects: OverviewSubject[],
|
||||||
|
): OverviewSubject | undefined {
|
||||||
|
const programme = Number(
|
||||||
|
assessment.programmeID ?? assessment.programme,
|
||||||
|
);
|
||||||
|
const metaclass = Number(
|
||||||
|
assessment.metaclassID ?? assessment.metaclass,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
programme &&
|
||||||
|
metaclass &&
|
||||||
|
!Number.isNaN(programme) &&
|
||||||
|
!Number.isNaN(metaclass)
|
||||||
|
) {
|
||||||
|
const subject = activeSubjects.find(
|
||||||
|
(s) => s.programme === programme && s.metaclass === metaclass,
|
||||||
|
);
|
||||||
|
if (subject) return subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = String(assessment.code ?? assessment.subject ?? "").trim();
|
||||||
|
if (!code) return undefined;
|
||||||
|
return activeSubjects.find((s) => s.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
export function filterAssessmentsForActiveSubjects<T extends Record<string, unknown>>(
|
export function filterAssessmentsForActiveSubjects<T extends Record<string, unknown>>(
|
||||||
assessments: T[],
|
assessments: T[],
|
||||||
activeSubjects: OverviewSubject[],
|
activeSubjects: OverviewSubject[],
|
||||||
@@ -110,6 +143,24 @@ export function filterAssessmentsForActiveSubjects<T extends Record<string, unkn
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Active subjects that have at least one assessment in the given list. */
|
||||||
|
export function subjectsWithUpcomingAssessments(
|
||||||
|
assessments: Record<string, unknown>[],
|
||||||
|
activeSubjects: OverviewSubject[],
|
||||||
|
): OverviewSubject[] {
|
||||||
|
const result: OverviewSubject[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const assessment of assessments) {
|
||||||
|
const subject = activeSubjectForAssessment(assessment, activeSubjects);
|
||||||
|
if (!subject || seen.has(subject.code)) continue;
|
||||||
|
seen.add(subject.code);
|
||||||
|
result.push(subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDate(dateStr: string, submitted?: boolean): string {
|
export function formatDate(dateStr: string, submitted?: boolean): string {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -218,3 +269,73 @@ export function getGradeValue(assessment: any): number | null {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractAssessmentLetterGrade(assessment: any): string | undefined {
|
||||||
|
if (assessment?.grade != null && String(assessment.grade).trim() !== "") {
|
||||||
|
return String(assessment.grade).trim();
|
||||||
|
}
|
||||||
|
return extractLetterGradeStringFromPayload(assessment);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function percentageToLetterGrade(percentage: number): string {
|
||||||
|
const letterMap: Record<number, string> = {
|
||||||
|
100: "A+",
|
||||||
|
95: "A",
|
||||||
|
90: "A-",
|
||||||
|
85: "B+",
|
||||||
|
80: "B",
|
||||||
|
75: "B-",
|
||||||
|
70: "C+",
|
||||||
|
65: "C",
|
||||||
|
60: "C-",
|
||||||
|
55: "D+",
|
||||||
|
50: "D",
|
||||||
|
45: "D-",
|
||||||
|
40: "E+",
|
||||||
|
35: "E",
|
||||||
|
30: "E-",
|
||||||
|
0: "F",
|
||||||
|
};
|
||||||
|
|
||||||
|
const rounded = Math.ceil(percentage / 5) * 5;
|
||||||
|
return letterMap[rounded] || "F";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThermoscorePercent(assessment: any): number | null {
|
||||||
|
const numeric = getGradeValue(assessment);
|
||||||
|
if (numeric !== null) return numeric;
|
||||||
|
|
||||||
|
const letter = extractAssessmentLetterGrade(assessment);
|
||||||
|
if (letter) {
|
||||||
|
const approx = approximatePercentFromLetterGrade(letter);
|
||||||
|
if (approx !== undefined) return approx;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayGrade(assessment: any, letterGradeMode: boolean): string {
|
||||||
|
if (letterGradeMode) {
|
||||||
|
const letter = extractAssessmentLetterGrade(assessment);
|
||||||
|
if (letter) return letter;
|
||||||
|
|
||||||
|
const val = getGradeValue(assessment);
|
||||||
|
if (val !== null) return percentageToLetterGrade(val);
|
||||||
|
|
||||||
|
return "No grade";
|
||||||
|
}
|
||||||
|
|
||||||
|
const val = getGradeValue(assessment);
|
||||||
|
if (val !== null) return `${val}%`;
|
||||||
|
|
||||||
|
const letter = extractAssessmentLetterGrade(assessment);
|
||||||
|
if (letter) return letter;
|
||||||
|
|
||||||
|
return "No grade";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assessmentHasGradeDisplay(assessment: any): boolean {
|
||||||
|
if (extractAssessmentLetterGrade(assessment)) return true;
|
||||||
|
if (getGradeValue(assessment) !== null) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { Plugin } from "@/plugins/core/types";
|
import type { Plugin } from "@/plugins/core/types";
|
||||||
import { booleanSetting, componentSetting, defineSettings, numberSetting } from "@/plugins/core/settingsHelpers";
|
import {
|
||||||
|
booleanSetting,
|
||||||
|
componentSetting,
|
||||||
|
defineSettings,
|
||||||
|
numberSetting,
|
||||||
|
} from "@/plugins/core/settingsHelpers";
|
||||||
import styles from "./styles.css?inline";
|
import styles from "./styles.css?inline";
|
||||||
import BackgroundMusicSetting from "./BackgroundMusicSetting.svelte";
|
import BackgroundMusicSetting from "./BackgroundMusicSetting.svelte";
|
||||||
import localforage from "localforage";
|
import localforage from "localforage";
|
||||||
@@ -20,7 +25,8 @@ const settings = defineSettings({
|
|||||||
}),
|
}),
|
||||||
pauseOnHidden: booleanSetting({
|
pauseOnHidden: booleanSetting({
|
||||||
title: "Pause when tab hidden",
|
title: "Pause when tab hidden",
|
||||||
description: "Pause music when switching to another tab or minimizing the browser",
|
description:
|
||||||
|
"Pause music when switching to another tab or minimizing the browser",
|
||||||
default: true,
|
default: true,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -30,73 +36,144 @@ const store = localforage.createInstance({
|
|||||||
storeName: "music",
|
storeName: "music",
|
||||||
});
|
});
|
||||||
|
|
||||||
let currentAudio: HTMLAudioElement | null = null;
|
const GESTURE_EVENTS = ["pointerdown", "keydown", "touchstart"] as const;
|
||||||
let currentObjectUrl: string | null = null;
|
const gestureOpts: AddEventListenerOptions = { capture: true, passive: true };
|
||||||
let cleanupRegistered = false;
|
|
||||||
let pendingGestureCancel: (() => void) | null = null;
|
|
||||||
let visibilityResumeTimeout: number | null = null;
|
|
||||||
|
|
||||||
async function loadAudioBlob(): Promise<Blob | null> {
|
let audio: HTMLAudioElement | null = null;
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
let gestureCleanup: (() => void) | null = null;
|
||||||
|
let resumeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let hintEl: HTMLElement | null = null;
|
||||||
|
let gesturePending = false;
|
||||||
|
let ensureInFlight: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const clamp = (v: number) => Math.max(0, Math.min(1, v));
|
||||||
|
|
||||||
|
async function waitForBody(): Promise<HTMLElement> {
|
||||||
|
if (document.body) return document.body;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (document.body) {
|
||||||
|
observer.disconnect();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { childList: true });
|
||||||
|
});
|
||||||
|
return document.body!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHint(): void {
|
||||||
|
hintEl?.remove();
|
||||||
|
hintEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disarmGesture(): void {
|
||||||
|
gestureCleanup?.();
|
||||||
|
gestureCleanup = null;
|
||||||
|
gesturePending = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAudio(): void {
|
||||||
|
audio?.pause();
|
||||||
|
audio?.remove();
|
||||||
|
audio = null;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
objectUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prepare <audio> so play() can run synchronously inside a user-gesture handler. */
|
||||||
|
async function prepareAudio(vol: number): Promise<boolean> {
|
||||||
const blob = await store.getItem<Blob>("audio-blob");
|
const blob = await store.getItem<Blob>("audio-blob");
|
||||||
return blob && blob instanceof Blob ? blob : null;
|
if (!(blob instanceof Blob)) {
|
||||||
|
stopAudio();
|
||||||
|
clearHint();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await waitForBody();
|
||||||
|
|
||||||
|
if (!audio) {
|
||||||
|
stopAudio();
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
audio = new Audio(objectUrl);
|
||||||
|
audio.loop = true;
|
||||||
|
audio.preload = "auto";
|
||||||
|
audio.style.display = "none";
|
||||||
|
body.append(audio);
|
||||||
|
}
|
||||||
|
audio.volume = clamp(vol);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopAndCleanupAudio(): void {
|
function attemptPlay(vol: number): Promise<boolean> {
|
||||||
if (currentAudio) {
|
if (!audio) return Promise.resolve(false);
|
||||||
currentAudio.pause();
|
audio.volume = clamp(vol);
|
||||||
currentAudio.src = "";
|
return audio
|
||||||
currentAudio.remove();
|
.play()
|
||||||
currentAudio = null;
|
.then(() => {
|
||||||
}
|
disarmGesture();
|
||||||
if (currentObjectUrl) {
|
clearHint();
|
||||||
URL.revokeObjectURL(currentObjectUrl);
|
return true;
|
||||||
currentObjectUrl = null;
|
})
|
||||||
}
|
.catch(() => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureGestureStart(handler: () => void): () => void {
|
/** Must stay synchronous — any await before play() drops user activation. */
|
||||||
const eventTypes = ["pointerdown", "keydown", "touchstart"]; // broad user gesture coverage
|
function playFromUserGesture(vol: number): void {
|
||||||
const listener = () => {
|
if (!audio) {
|
||||||
handler();
|
gesturePending = true;
|
||||||
for (const type of eventTypes) {
|
|
||||||
window.removeEventListener(type, listener);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for (const type of eventTypes) {
|
|
||||||
window.addEventListener(type, listener, { once: true, passive: true });
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
for (const type of eventTypes) {
|
|
||||||
window.removeEventListener(type, listener);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startPlayback(volume: number): Promise<void> {
|
|
||||||
const blob = await loadAudioBlob();
|
|
||||||
if (!blob) {
|
|
||||||
stopAndCleanupAudio();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
audio.volume = clamp(vol);
|
||||||
|
void audio.play().then(
|
||||||
|
() => {
|
||||||
|
disarmGesture();
|
||||||
|
clearHint();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Keep listeners armed; show hint if somehow missing.
|
||||||
|
if (!hintEl) showHint(() => playFromUserGesture(vol));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
stopAndCleanupAudio();
|
function showHint(onActivate: () => void): void {
|
||||||
|
clearHint();
|
||||||
|
if (!document.body) return;
|
||||||
|
const hint = document.createElement("button");
|
||||||
|
hint.id = "bsplus-bg-music-hint";
|
||||||
|
hint.type = "button";
|
||||||
|
hint.className = "bsplus-bg-music-hint";
|
||||||
|
hint.textContent = "Tap to start background music";
|
||||||
|
hint.addEventListener("pointerdown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onActivate();
|
||||||
|
});
|
||||||
|
document.body.append(hint);
|
||||||
|
hintEl = hint;
|
||||||
|
}
|
||||||
|
|
||||||
currentObjectUrl = URL.createObjectURL(blob);
|
function armGesture(onGesture: () => void): void {
|
||||||
const audio = new Audio(currentObjectUrl);
|
disarmGesture();
|
||||||
audio.loop = true;
|
const listener = (event: Event) => {
|
||||||
audio.volume = Math.max(0, Math.min(1, volume));
|
if (event.type === "keydown") {
|
||||||
audio.preload = "auto";
|
const key = (event as KeyboardEvent).key;
|
||||||
audio.crossOrigin = "anonymous";
|
if (key !== "Enter" && key !== " ") return;
|
||||||
audio.style.display = "none";
|
}
|
||||||
document.body.appendChild(audio);
|
onGesture();
|
||||||
currentAudio = audio;
|
};
|
||||||
|
for (const type of GESTURE_EVENTS) {
|
||||||
try {
|
window.addEventListener(type, listener, gestureOpts);
|
||||||
// Attempt immediate play; may be blocked until gesture
|
document.addEventListener(type, listener, gestureOpts);
|
||||||
await audio.play();
|
|
||||||
} catch {
|
|
||||||
// Ignore; will be started after gesture if enabled
|
|
||||||
}
|
}
|
||||||
|
gestureCleanup = () => {
|
||||||
|
for (const type of GESTURE_EVENTS) {
|
||||||
|
window.removeEventListener(type, listener, gestureOpts);
|
||||||
|
document.removeEventListener(type, listener, gestureOpts);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
showHint(onGesture);
|
||||||
}
|
}
|
||||||
|
|
||||||
const backgroundMusicPlugin: Plugin<typeof settings> = {
|
const backgroundMusicPlugin: Plugin<typeof settings> = {
|
||||||
@@ -112,79 +189,117 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
|
|||||||
run: async (api) => {
|
run: async (api) => {
|
||||||
await api.storage.loaded;
|
await api.storage.loaded;
|
||||||
|
|
||||||
// react to specific setting changes
|
type BgSettings = { volume?: number; pauseOnHidden?: boolean };
|
||||||
api.settings.onChange("volume" as any, (value: any) => {
|
const vol = () => (api.settings as BgSettings).volume ?? 0.5;
|
||||||
const vol = (typeof value === "number" ? value : 0.5) as number;
|
const pauseOnHidden = () =>
|
||||||
if (currentAudio) currentAudio.volume = Math.max(0, Math.min(1, vol));
|
(api.settings as BgSettings).pauseOnHidden ?? true;
|
||||||
|
|
||||||
|
const runEnsurePlayback = async () => {
|
||||||
|
if (!(await prepareAudio(vol()))) return;
|
||||||
|
|
||||||
|
if (audio && !audio.paused) {
|
||||||
|
disarmGesture();
|
||||||
|
clearHint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arm unlock before autoplay so the next click/key can call play()
|
||||||
|
// synchronously (async gaps drop user activation).
|
||||||
|
if (!gestureCleanup) {
|
||||||
|
armGesture(() => playFromUserGesture(vol()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesturePending) {
|
||||||
|
gesturePending = false;
|
||||||
|
playFromUserGesture(vol());
|
||||||
|
if (audio && !audio.paused) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await attemptPlay(vol())) return;
|
||||||
|
|
||||||
|
// Retry after load — some browsers allow autoplay once the page settles
|
||||||
|
// or Media Engagement Index applies from prior visits.
|
||||||
|
for (const delayMs of [500, 1500, 3000]) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
if (!audio || !audio.paused) return;
|
||||||
|
if (await attemptPlay(vol())) return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensurePlayback = () => {
|
||||||
|
if (!ensureInFlight) {
|
||||||
|
ensureInFlight = runEnsurePlayback().finally(() => {
|
||||||
|
ensureInFlight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ensureInFlight;
|
||||||
|
};
|
||||||
|
|
||||||
|
api.settings.onChange("volume" as never, (value: unknown) => {
|
||||||
|
if (typeof value === "number" && audio) audio.volume = clamp(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
api.settings.onChange("pauseOnHidden" as any, (value: any) => {
|
api.settings.onChange("pauseOnHidden" as never, (value: unknown) => {
|
||||||
const pauseOnHidden = (typeof value === "boolean" ? value : true) as boolean;
|
if (
|
||||||
// If the setting is disabled and audio is currently paused due to tab being hidden, resume it
|
value === false &&
|
||||||
if (!pauseOnHidden && currentAudio && currentAudio.paused && document.visibilityState === "hidden") {
|
audio?.paused &&
|
||||||
currentAudio.play().catch(() => {});
|
document.visibilityState === "visible"
|
||||||
|
) {
|
||||||
|
void ensurePlayback();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: Stop button dispatches betterseqta-background-music-stop on remove
|
const onVisibility = () => {
|
||||||
|
|
||||||
// Start if we have audio and autoplay is enabled
|
|
||||||
const tryStart = async () => {
|
|
||||||
const vol = (api.settings as any).volume ?? 0.5;
|
|
||||||
await startPlayback(vol);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Always arm gesture start and attempt immediate start
|
|
||||||
const cancel = ensureGestureStart(() => { tryStart(); });
|
|
||||||
cleanupRegistered = true;
|
|
||||||
(window as any).__betterseqta_bg_music_cancel__ = cancel;
|
|
||||||
tryStart();
|
|
||||||
|
|
||||||
// Pause on tab hide, resume on show with a small delay (if enabled)
|
|
||||||
const visHandler = () => {
|
|
||||||
if (!currentAudio) return;
|
|
||||||
const pauseOnHidden = (api.settings as any).pauseOnHidden ?? true;
|
|
||||||
if (!pauseOnHidden) return;
|
|
||||||
if (document.visibilityState === "hidden") {
|
if (document.visibilityState === "hidden") {
|
||||||
if (visibilityResumeTimeout !== null) {
|
if (!pauseOnHidden() || !audio) return;
|
||||||
clearTimeout(visibilityResumeTimeout);
|
if (resumeTimer) clearTimeout(resumeTimer);
|
||||||
visibilityResumeTimeout = null;
|
resumeTimer = null;
|
||||||
}
|
audio.pause();
|
||||||
currentAudio.pause();
|
return;
|
||||||
} else if (document.visibilityState === "visible") {
|
|
||||||
if (visibilityResumeTimeout !== null) {
|
|
||||||
clearTimeout(visibilityResumeTimeout);
|
|
||||||
}
|
|
||||||
visibilityResumeTimeout = window.setTimeout(() => {
|
|
||||||
visibilityResumeTimeout = null;
|
|
||||||
currentAudio?.play().catch(() => {});
|
|
||||||
}, 200);
|
|
||||||
}
|
}
|
||||||
|
if (!audio) {
|
||||||
|
void ensurePlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pauseOnHidden()) return;
|
||||||
|
if (resumeTimer) clearTimeout(resumeTimer);
|
||||||
|
resumeTimer = setTimeout(() => {
|
||||||
|
resumeTimer = null;
|
||||||
|
void ensurePlayback();
|
||||||
|
}, 200);
|
||||||
};
|
};
|
||||||
document.addEventListener("visibilitychange", visHandler);
|
|
||||||
|
|
||||||
// Allow uploads to trigger refresh; stop event clears playback on remove
|
const onUpdated = () => void ensurePlayback();
|
||||||
const uploadedHandler = () => {
|
const onStop = () => {
|
||||||
const vol = (api.settings as any).volume ?? 0.5;
|
disarmGesture();
|
||||||
startPlayback(vol);
|
clearHint();
|
||||||
|
stopAudio();
|
||||||
};
|
};
|
||||||
const stopHandler = () => {
|
|
||||||
stopAndCleanupAudio();
|
const pageChange = api.seqta.onPageChange(() => {
|
||||||
};
|
void ensurePlayback();
|
||||||
window.addEventListener("betterseqta-background-music-updated", uploadedHandler);
|
});
|
||||||
window.addEventListener("betterseqta-background-music-stop", stopHandler);
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
window.addEventListener("pageshow", onUpdated);
|
||||||
|
window.addEventListener("betterseqta-background-music-updated", onUpdated);
|
||||||
|
window.addEventListener("betterseqta-background-music-stop", onStop);
|
||||||
|
|
||||||
|
void ensurePlayback();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("visibilitychange", visHandler);
|
pageChange.unregister();
|
||||||
window.removeEventListener("betterseqta-background-music-updated", uploadedHandler);
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
window.removeEventListener("betterseqta-background-music-stop", stopHandler);
|
window.removeEventListener("pageshow", onUpdated);
|
||||||
if (cleanupRegistered && (window as any).__betterseqta_bg_music_cancel__) {
|
window.removeEventListener(
|
||||||
(window as any).__betterseqta_bg_music_cancel__();
|
"betterseqta-background-music-updated",
|
||||||
(window as any).__betterseqta_bg_music_cancel__ = undefined;
|
onUpdated,
|
||||||
}
|
);
|
||||||
if (pendingGestureCancel) { pendingGestureCancel(); pendingGestureCancel = null; }
|
window.removeEventListener("betterseqta-background-music-stop", onStop);
|
||||||
if (visibilityResumeTimeout !== null) { clearTimeout(visibilityResumeTimeout); visibilityResumeTimeout = null; }
|
if (resumeTimer) clearTimeout(resumeTimer);
|
||||||
stopAndCleanupAudio();
|
disarmGesture();
|
||||||
|
clearHint();
|
||||||
|
stopAudio();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,2 +1,14 @@
|
|||||||
.background-music-hidden{display:none}
|
.bsplus-bg-music-hint {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
z-index: 2147483000;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--better-main, #22c55e) 55%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--theme-primary, #1a1a1a) 92%, black 8%);
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
font: 600 0.8125rem/1.25 system-ui, sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,17 +7,15 @@ import {
|
|||||||
} from "../../core/settingsHelpers";
|
} from "../../core/settingsHelpers";
|
||||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
import styles from "./src/core/styles.css?inline";
|
import styles from "./src/core/styles.css?inline";
|
||||||
import { resetSearchIndexes } from "./src/indexing/resetIndexes";
|
import {
|
||||||
|
resetSearchIndexes,
|
||||||
// Platform-aware default hotkey
|
notifyOpenTabsResetSearchIndex,
|
||||||
const getDefaultHotkey = () => {
|
} from "./src/indexing/resetIndexes";
|
||||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
import { getDefaultSearchHotkey } from "./src/utils/hotkeyUtils";
|
||||||
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",
|
||||||
}),
|
}),
|
||||||
@@ -52,9 +50,7 @@ const settings = defineSettings({
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// `resetSearchIndexes` is a tiny statically-imported helper: no
|
await notifyOpenTabsResetSearchIndex();
|
||||||
// dynamic chunks to chase, so the button keeps working even when
|
|
||||||
// the settings page has been open across an extension update.
|
|
||||||
await resetSearchIndexes();
|
await resetSearchIndexes();
|
||||||
alert(
|
alert(
|
||||||
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
|
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
|
||||||
|
|||||||
@@ -32,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);
|
||||||
@@ -118,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;
|
||||||
|
|
||||||
@@ -167,7 +150,6 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('indexing-progress', progressHandler as EventListener);
|
|
||||||
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -183,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));
|
||||||
|
|
||||||
console.debug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const performSearch = async () => {
|
const performSearch = async () => {
|
||||||
|
|||||||
@@ -105,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();
|
||||||
console.log(`Navigated to exact lesson: ${lessonDate} ${lessonPeriod}`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
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 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 } from "../indexing/indexer";
|
import { runIndexing, ensureSchemaCurrent } from "../indexing/indexer";
|
||||||
|
import { installResetIndexMessageListener } from "../indexing/resetIndexes";
|
||||||
|
import { isIndexingPaused } from "../indexing/indexingPause";
|
||||||
import { initVectorSearch } from "../search/vector/vectorSearch";
|
import { initVectorSearch } from "../search/vector/vectorSearch";
|
||||||
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
|
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
|
||||||
import { IndexedDbManager } from "embeddia";
|
import { IndexedDbManager } from "embeddia";
|
||||||
@@ -20,183 +15,50 @@ 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();
|
|
||||||
console.log("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 = () => {
|
|
||||||
console.log(`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 = () => {
|
|
||||||
console.log(`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 };
|
||||||
|
|
||||||
// Run the version check BEFORE we open any IndexedDB connections.
|
installResetIndexMessageListener();
|
||||||
// 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) {
|
||||||
console.log(
|
verboseLog(
|
||||||
"[Global Search] Extension updated — search index reset; the next indexing pass will repopulate.",
|
"[Global Search] Extension updated — search index reset; the next indexing pass will repopulate.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} 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")
|
||||||
) {
|
) {
|
||||||
console.debug(
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSchemaCurrent();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Global Search] Schema check failed:", error);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
|
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
|
||||||
primaryKey: "id",
|
primaryKey: "id",
|
||||||
@@ -204,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 {
|
|
||||||
console.debug("[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 () =>
|
||||||
console.log("Vector worker reset via debug helper");
|
(await import("../indexing/selfTests")).runGlobalSearchSelfTests(),
|
||||||
},
|
|
||||||
checkWorkerStatus: () => {
|
|
||||||
const workerManager = VectorWorkerManager.getInstance();
|
|
||||||
console.log("Streaming active:", workerManager.isStreamingActive());
|
|
||||||
},
|
|
||||||
passiveItems: async () => {
|
|
||||||
const items = await getStoredPassiveItems();
|
|
||||||
console.log(`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();
|
|
||||||
console.log("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 = () => {
|
|
||||||
console.log("embeddiaDB item count:", countRequest.result);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error checking storage:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (api.settings.passiveIndexing) {
|
if (api.settings.passiveIndexing) {
|
||||||
@@ -277,24 +95,20 @@ const globalSearchPlugin: Plugin<typeof settings> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (api.settings.runIndexingOnLoad) {
|
if (api.settings.runIndexingOnLoad && !isIndexingPaused()) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await runIndexing();
|
if (!isIndexingPaused()) 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();
|
||||||
@@ -280,9 +258,9 @@ export async function mountSearchBar(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { default: renderSvelte } = await import("@/interface/main");
|
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,6 +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 { verboseLog } from '@/utils/verboseLog';
|
||||||
interface MessageMetadata {
|
interface MessageMetadata {
|
||||||
messageId: number;
|
messageId: number;
|
||||||
author: string;
|
author: string;
|
||||||
@@ -171,7 +172,7 @@ export const actionMap: Record<string, ActionHandler<any>> = {
|
|||||||
if ((assessmentId === undefined || assessmentId === null) && itemClone.id && itemClone.id.startsWith('assignment-')) {
|
if ((assessmentId === undefined || assessmentId === null) && itemClone.id && itemClone.id.startsWith('assignment-')) {
|
||||||
const extractedId = itemClone.id.replace('assignment-', '');
|
const extractedId = itemClone.id.replace('assignment-', '');
|
||||||
assessmentId = Number(extractedId) || extractedId;
|
assessmentId = Number(extractedId) || extractedId;
|
||||||
console.log("[Assessment Action] Extracted assessmentId from item ID:", assessmentId);
|
verboseLog("[Assessment Action] Extracted assessmentId from item ID:", assessmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to numbers, but preserve 0 as valid
|
// Convert to numbers, but preserve 0 as valid
|
||||||
@@ -198,7 +199,7 @@ export const actionMap: Record<string, ActionHandler<any>> = {
|
|||||||
|
|
||||||
if (hasProgrammeId && hasMetaclassId && hasAssessmentId) {
|
if (hasProgrammeId && hasMetaclassId && hasAssessmentId) {
|
||||||
const url = `#?page=/assessments/${programmeId}:${metaclassId}&item=${assessmentId}`;
|
const url = `#?page=/assessments/${programmeId}:${metaclassId}&item=${assessmentId}`;
|
||||||
console.log("[Assessment Action] ✅ Navigating to:", url);
|
verboseLog("[Assessment Action] ✅ Navigating to:", url);
|
||||||
window.location.hash = url;
|
window.location.hash = url;
|
||||||
} else {
|
} else {
|
||||||
// Fallback: try to navigate to assessments page if metadata is incomplete
|
// Fallback: try to navigate to assessments page if metadata is incomplete
|
||||||
@@ -352,9 +353,28 @@ export const actionMap: Record<string, ActionHandler<any>> = {
|
|||||||
const forumId = num("forumId") ?? num("forum");
|
const forumId = num("forumId") ?? num("forum");
|
||||||
const year = num("year");
|
const year = num("year");
|
||||||
const assessmentId =
|
const assessmentId =
|
||||||
num("assessmentId") ?? num("assessmentID") ?? num("id");
|
num("assessmentId") ??
|
||||||
|
num("assessmentID") ??
|
||||||
|
num("entityId") ??
|
||||||
|
num("id");
|
||||||
const messageId = num("messageId");
|
const messageId = num("messageId");
|
||||||
|
|
||||||
|
const navigateToAssessment = (): void => {
|
||||||
|
if (programme !== undefined && metaclass !== undefined) {
|
||||||
|
const itemSuffix =
|
||||||
|
assessmentId !== undefined ? `&item=${assessmentId}` : "";
|
||||||
|
navigateToHashRoute(
|
||||||
|
`/assessments/${programme}:${metaclass}${itemSuffix}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (assessmentId !== undefined) {
|
||||||
|
navigateToHashRoute(`/assessments/upcoming&item=${assessmentId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateToHashRoute("/assessments/upcoming");
|
||||||
|
};
|
||||||
|
|
||||||
if (sourcePage === "/messages") {
|
if (sourcePage === "/messages") {
|
||||||
navigateInCurrentSeqtaApp("/messages");
|
navigateInCurrentSeqtaApp("/messages");
|
||||||
return;
|
return;
|
||||||
@@ -368,19 +388,8 @@ export const actionMap: Record<string, ActionHandler<any>> = {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "assessments":
|
case "assessments":
|
||||||
if (programme !== undefined && metaclass !== undefined) {
|
case "assessment":
|
||||||
const itemSuffix =
|
navigateToAssessment();
|
||||||
assessmentId !== undefined ? `&item=${assessmentId}` : "";
|
|
||||||
navigateToHashRoute(
|
|
||||||
`/assessments/${programme}:${metaclass}${itemSuffix}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (assessmentId !== undefined) {
|
|
||||||
navigateToHashRoute(`/assessments/upcoming&item=${assessmentId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigateToHashRoute("/assessments/upcoming");
|
|
||||||
return;
|
return;
|
||||||
case "forums":
|
case "forums":
|
||||||
case "forum":
|
case "forum":
|
||||||
|
|||||||
@@ -14,138 +14,214 @@ function updateVersion(version: number) {
|
|||||||
localStorage.setItem(VERSION_KEY, version.toString());
|
localStorage.setItem(VERSION_KEY, version.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function invalidateConnection(): void {
|
||||||
|
if (cachedDb) {
|
||||||
|
cachedDb.close();
|
||||||
|
cachedDb = null;
|
||||||
|
}
|
||||||
|
dbPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachConnection(db: IDBDatabase): void {
|
||||||
|
if (cachedDb && cachedDb !== db) {
|
||||||
|
cachedDb.close();
|
||||||
|
}
|
||||||
|
cachedDb = db;
|
||||||
|
cachedDb.onclose = () => {
|
||||||
|
cachedDb = null;
|
||||||
|
dbPromise = null;
|
||||||
|
};
|
||||||
|
updateVersion(db.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupUpgradeHandler(
|
||||||
|
request: IDBOpenDBRequest,
|
||||||
|
extraStore?: string,
|
||||||
|
): void {
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = request.result;
|
||||||
|
|
||||||
|
if (!Array.from(db.objectStoreNames).includes(META_STORE)) {
|
||||||
|
db.createObjectStore(META_STORE);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraStore && !db.objectStoreNames.contains(extraStore)) {
|
||||||
|
db.createObjectStore(extraStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.newVersion != null) {
|
||||||
|
updateVersion(event.newVersion);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDatabase(version?: number, extraStore?: string): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let request: IDBOpenDBRequest;
|
||||||
|
try {
|
||||||
|
request =
|
||||||
|
version != null
|
||||||
|
? indexedDB.open(DB_NAME, version)
|
||||||
|
: indexedDB.open(DB_NAME);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupUpgradeHandler(request, extraStore);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
attachConnection(request.result);
|
||||||
|
resolve(request.result);
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wipeDatabase(): Promise<void> {
|
||||||
|
invalidateConnection();
|
||||||
|
localStorage.removeItem(VERSION_KEY);
|
||||||
|
return deleteDatabaseWithRetries(DB_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDatabaseWithRetries(
|
||||||
|
name: string,
|
||||||
|
maxAttempts = 6,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const attemptDelete = (attempt: number) => {
|
||||||
|
let req: IDBOpenDBRequest;
|
||||||
|
try {
|
||||||
|
req = indexedDB.deleteDatabase(name);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[DB] Could not start delete of ${name}:`, error);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
|
||||||
|
req.onerror = () => {
|
||||||
|
console.warn(`[DB] Error deleting ${name}:`, req.error);
|
||||||
|
if (attempt + 1 < maxAttempts) {
|
||||||
|
setTimeout(() => attemptDelete(attempt + 1), 150 * (attempt + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
req.onblocked = () => {
|
||||||
|
console.warn(
|
||||||
|
`[DB] Delete of ${name} blocked (attempt ${attempt + 1}/${maxAttempts}); waiting for connections to close`,
|
||||||
|
);
|
||||||
|
if (attempt + 1 < maxAttempts) {
|
||||||
|
setTimeout(() => attemptDelete(attempt + 1), 200 * (attempt + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
attemptDelete(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSearchDatabase(): void {
|
||||||
|
invalidateConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("betterseqta-reset-search-index", () => {
|
||||||
|
closeSearchDatabase();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDBInternal(): Promise<IDBDatabase> {
|
||||||
|
const storedVersion = getCurrentVersion();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await openDatabase(storedVersion);
|
||||||
|
} catch (error) {
|
||||||
|
const domError = error as DOMException | undefined;
|
||||||
|
|
||||||
|
if (domError?.name === "VersionError") {
|
||||||
|
console.warn(
|
||||||
|
"[DB] localStorage version out of sync with IndexedDB; opening current version",
|
||||||
|
);
|
||||||
|
invalidateConnection();
|
||||||
|
try {
|
||||||
|
return await openDatabase();
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.warn("[DB] Fallback open failed, recreating database:", fallbackError);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("Error opening database:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await wipeDatabase();
|
||||||
|
return openDatabase(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openDB(): Promise<IDBDatabase> {
|
function openDB(): Promise<IDBDatabase> {
|
||||||
if (cachedDb && cachedDb.version >= getCurrentVersion()) {
|
if (cachedDb) {
|
||||||
return Promise.resolve(cachedDb);
|
return Promise.resolve(cachedDb);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dbPromise) return dbPromise;
|
if (dbPromise) return dbPromise;
|
||||||
|
|
||||||
const currentVersion = getCurrentVersion();
|
dbPromise = openDBInternal();
|
||||||
|
|
||||||
dbPromise = new Promise((resolve, reject) => {
|
|
||||||
let request: IDBOpenDBRequest;
|
|
||||||
|
|
||||||
try {
|
|
||||||
request = indexedDB.open(DB_NAME, currentVersion);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Database version conflict, recreating database...");
|
|
||||||
if (cachedDb) {
|
|
||||||
cachedDb.close();
|
|
||||||
cachedDb = null;
|
|
||||||
}
|
|
||||||
indexedDB.deleteDatabase(DB_NAME);
|
|
||||||
localStorage.removeItem(VERSION_KEY);
|
|
||||||
request = indexedDB.open(DB_NAME, 1);
|
|
||||||
updateVersion(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onupgradeneeded = (event) => {
|
|
||||||
const db = request.result;
|
|
||||||
const existingStores = Array.from(db.objectStoreNames);
|
|
||||||
|
|
||||||
if (!existingStores.includes(META_STORE)) {
|
|
||||||
db.createObjectStore(META_STORE);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateVersion(event.newVersion || 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
if (cachedDb && cachedDb !== request.result) {
|
|
||||||
cachedDb.close();
|
|
||||||
}
|
|
||||||
cachedDb = request.result;
|
|
||||||
|
|
||||||
cachedDb.onclose = () => {
|
|
||||||
cachedDb = null;
|
|
||||||
dbPromise = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
resolve(request.result);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onerror = () => {
|
|
||||||
console.error("Error opening database:", request.error);
|
|
||||||
|
|
||||||
if (cachedDb) {
|
|
||||||
cachedDb.close();
|
|
||||||
cachedDb = null;
|
|
||||||
}
|
|
||||||
indexedDB.deleteDatabase(DB_NAME);
|
|
||||||
localStorage.removeItem(VERSION_KEY);
|
|
||||||
dbPromise = null;
|
|
||||||
reject(request.error);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function upgradeDB(newStore: string): Promise<void> {
|
async function upgradeDB(newStore: string): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
invalidateConnection();
|
||||||
const currentVersion = getCurrentVersion();
|
|
||||||
const newVersion = currentVersion + 1;
|
|
||||||
|
|
||||||
if (cachedDb) {
|
let baseVersion = 0;
|
||||||
cachedDb.close();
|
|
||||||
cachedDb = null;
|
try {
|
||||||
}
|
const db = await openDatabase();
|
||||||
|
baseVersion = db.version;
|
||||||
|
db.close();
|
||||||
|
cachedDb = null;
|
||||||
dbPromise = null;
|
dbPromise = null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[DB] Could not probe database version before upgrade:", error);
|
||||||
|
}
|
||||||
|
|
||||||
const request = indexedDB.open(DB_NAME, newVersion);
|
try {
|
||||||
|
await openDatabase(baseVersion + 1, newStore);
|
||||||
request.onupgradeneeded = (event) => {
|
} catch (error) {
|
||||||
const db = request.result;
|
console.error("Error upgrading database:", error);
|
||||||
if (!db.objectStoreNames.contains(newStore)) {
|
throw error;
|
||||||
db.createObjectStore(newStore);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
updateVersion(event.newVersion || newVersion);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
cachedDb = request.result;
|
|
||||||
|
|
||||||
cachedDb.onclose = () => {
|
|
||||||
cachedDb = null;
|
|
||||||
dbPromise = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
dbPromise = Promise.resolve(request.result);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onerror = () => {
|
|
||||||
console.error("Error upgrading database:", request.error);
|
|
||||||
reject(request.error);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 [];
|
||||||
@@ -154,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;
|
||||||
@@ -172,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 }>,
|
||||||
@@ -195,15 +260,11 @@ export async function applyStoreDiff(
|
|||||||
if (puts.length === 0 && removeKeys.length === 0) return;
|
if (puts.length === 0 && removeKeys.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = await openDB();
|
let db = await openDB();
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(store)) {
|
if (!db.objectStoreNames.contains(store)) {
|
||||||
await upgradeDB(store);
|
await upgradeDB(store);
|
||||||
const upgradedDb = await openDB();
|
db = await openDB();
|
||||||
await runStoreDiffTransaction(upgradedDb, store, puts, removeKeys);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await runStoreDiffTransaction(db, 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);
|
||||||
@@ -219,13 +280,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();
|
||||||
@@ -236,12 +297,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;
|
||||||
@@ -250,12 +307,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;
|
||||||
@@ -263,54 +316,23 @@ export async function clear(store: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function resetDatabase(): Promise<void> {
|
export async function resetDatabase(): Promise<void> {
|
||||||
// Close cached database connection
|
|
||||||
if (cachedDb) {
|
|
||||||
try {
|
|
||||||
cachedDb.close();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[DB] Error closing cached database:", e);
|
|
||||||
}
|
|
||||||
cachedDb = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close pending database promise
|
|
||||||
if (dbPromise) {
|
if (dbPromise) {
|
||||||
try {
|
try {
|
||||||
const db = await dbPromise;
|
const db = await dbPromise;
|
||||||
db.close();
|
db.close();
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Database might not be open yet, that's okay
|
// Database might not be open yet
|
||||||
}
|
}
|
||||||
dbPromise = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait a bit for connections to fully close
|
invalidateConnection();
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
if (typeof window !== "undefined") {
|
||||||
const req = indexedDB.deleteDatabase(DB_NAME);
|
window.dispatchEvent(new CustomEvent("betterseqta-reset-search-index"));
|
||||||
req.onsuccess = () => {
|
}
|
||||||
localStorage.removeItem(VERSION_KEY);
|
|
||||||
resolve();
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
};
|
|
||||||
req.onerror = () => {
|
localStorage.removeItem(VERSION_KEY);
|
||||||
console.error("[DB] Error deleting database:", req.error);
|
await deleteDatabaseWithRetries(DB_NAME);
|
||||||
reject(req.error);
|
|
||||||
};
|
|
||||||
req.onblocked = () => {
|
|
||||||
console.warn("[DB] Database deletion blocked - waiting for connections to close");
|
|
||||||
// Wait a bit longer and try again
|
|
||||||
setTimeout(() => {
|
|
||||||
const retryReq = indexedDB.deleteDatabase(DB_NAME);
|
|
||||||
retryReq.onsuccess = () => {
|
|
||||||
localStorage.removeItem(VERSION_KEY);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
retryReq.onerror = () => reject(retryReq.error);
|
|
||||||
retryReq.onblocked = () => {
|
|
||||||
reject(new Error(`Database is still open. Please close other tabs/windows and try again.`));
|
|
||||||
};
|
|
||||||
}, 500);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { applyStoreDiff, get, getAll, put, remove, resetDatabase } from "./db";
|
import { applyStoreDiff, get, getAll, put, remove } from "./db";
|
||||||
import { jobs } from "./jobs";
|
import { jobs } from "./jobs";
|
||||||
import { decorateIndexItems } from "./renderComponents";
|
import { decorateIndexItems } from "./renderComponents";
|
||||||
import type { IndexItem, Job, JobContext } from "./types";
|
import type { IndexItem, Job, JobContext } from "./types";
|
||||||
@@ -6,7 +6,10 @@ import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
|||||||
import { loadDynamicItems } from "../utils/dynamicItems";
|
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||||
import { getVectorizedItemIds, pruneOrphanVectorEmbeddings } from "./utils";
|
import { getVectorizedItemIds, pruneOrphanVectorEmbeddings } from "./utils";
|
||||||
import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion";
|
import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion";
|
||||||
|
import { resetSearchIndexes } from "./resetIndexes";
|
||||||
|
import { isIndexingPaused } from "./indexingPause";
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -32,20 +35,9 @@ async function ensureSchemaCurrent(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resetDatabase();
|
await resetSearchIndexes();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[Indexer] Failed to reset structured database:", e);
|
console.warn("[Indexer] Failed to reset search indexes:", e);
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const req = indexedDB.deleteDatabase("embeddiaDB");
|
|
||||||
req.onsuccess = () => resolve();
|
|
||||||
req.onerror = () => resolve();
|
|
||||||
req.onblocked = () => resolve();
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[Indexer] Failed to reset embeddiaDB:", e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -57,7 +49,8 @@ async function ensureSchemaCurrent(): Promise<void> {
|
|||||||
return schemaCheckPromise;
|
return schemaCheckPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─────────── Progress‑meta helpers ─────────── */
|
export { ensureSchemaCurrent };
|
||||||
|
|
||||||
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;
|
||||||
@@ -66,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;
|
||||||
@@ -151,46 +143,46 @@ 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) {
|
||||||
console.debug("[Indexer] Already indexing in this tab");
|
verboseDebug("[Indexer] Already indexing in this tab");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,17 +244,59 @@ export async function loadAllStoredItems(): Promise<IndexItem[]> {
|
|||||||
console.error(`Error loading items for job store ${jobId}:`, error);
|
console.error(`Error loading items for job store ${jobId}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`[Indexer] Loaded ${all.length} items from all primary stores.`,
|
`[Indexer] Loaded ${all.length} items from all primary stores.`,
|
||||||
);
|
);
|
||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispatchVectorProgress(
|
||||||
|
progress: {
|
||||||
|
status?: string;
|
||||||
|
total?: number;
|
||||||
|
processed?: number;
|
||||||
|
message?: string;
|
||||||
|
},
|
||||||
|
completedJobs: number,
|
||||||
|
totalSteps: number,
|
||||||
|
): number {
|
||||||
|
const { status, total, processed, message = "" } = progress;
|
||||||
|
let detail = message;
|
||||||
|
let completed = completedJobs;
|
||||||
|
|
||||||
|
if (status === "processing" && total != null && processed != null) {
|
||||||
|
detail = `Vectorizing: ${processed} / ${total}`;
|
||||||
|
} else if (status === "started") {
|
||||||
|
detail = `Vectorization started for ${total} items`;
|
||||||
|
} else if (status === "complete") {
|
||||||
|
dispatchProgress(++completed, totalSteps, false, "Indexing finished", "Vectorization complete");
|
||||||
|
return completed;
|
||||||
|
} else if (status === "error") {
|
||||||
|
dispatchProgress(completed, totalSteps, false, "Vectorization failed", `Vectorization error: ${message}`);
|
||||||
|
return completed;
|
||||||
|
} else if (status === "cancelled") {
|
||||||
|
dispatchProgress(completed, totalSteps, false, "Vectorization cancelled", `Vectorization cancelled: ${message}`);
|
||||||
|
return completed;
|
||||||
|
} else {
|
||||||
|
dispatchProgress(completed, totalSteps, true, "Vectorization in progress", detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return completed;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runIndexing(): Promise<void> {
|
export async function runIndexing(): Promise<void> {
|
||||||
|
if (isIndexingPaused()) {
|
||||||
|
verboseDebug(
|
||||||
|
"[Indexer] Skipping indexing — index was reset; reload the page to rebuild.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await ensureSchemaCurrent();
|
await ensureSchemaCurrent();
|
||||||
|
if (isIndexingPaused()) return;
|
||||||
|
|
||||||
if (!(await acquireLock())) {
|
if (!(await acquireLock())) {
|
||||||
console.debug(
|
verboseDebug(
|
||||||
"%c[Indexer] Could not acquire lock - another tab is indexing or this tab is already indexing",
|
"%c[Indexer] Could not acquire lock - another tab is indexing or this tab is already indexing",
|
||||||
"color: gray",
|
"color: gray",
|
||||||
);
|
);
|
||||||
@@ -270,7 +304,7 @@ export async function runIndexing(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startHeartbeat();
|
startHeartbeat();
|
||||||
console.debug("%c[Indexer] Starting indexing...", "color: green");
|
verboseDebug("%c[Indexer] Starting indexing...", "color: green");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jobIds = Object.keys(jobs);
|
const jobIds = Object.keys(jobs);
|
||||||
@@ -279,6 +313,19 @@ export async function runIndexing(): Promise<void> {
|
|||||||
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs");
|
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs");
|
||||||
|
|
||||||
for (const jobId of jobIds) {
|
for (const jobId of jobIds) {
|
||||||
|
if (isIndexingPaused()) {
|
||||||
|
verboseDebug(
|
||||||
|
"[Indexer] Indexing stopped — index was reset; reload the page to rebuild.",
|
||||||
|
);
|
||||||
|
dispatchProgress(
|
||||||
|
completedJobs,
|
||||||
|
totalSteps,
|
||||||
|
false,
|
||||||
|
"Indexing paused — reload to rebuild",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
dispatchProgress(
|
dispatchProgress(
|
||||||
completedJobs,
|
completedJobs,
|
||||||
totalSteps,
|
totalSteps,
|
||||||
@@ -289,7 +336,7 @@ export async function runIndexing(): Promise<void> {
|
|||||||
const lastRun = await getLastRunMeta(jobId);
|
const lastRun = await getLastRunMeta(jobId);
|
||||||
|
|
||||||
if (!shouldRun(job, lastRun)) {
|
if (!shouldRun(job, lastRun)) {
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`%c[Indexer] Skipping job "${jobId}" (not due)`,
|
`%c[Indexer] Skipping job "${jobId}" (not due)`,
|
||||||
"color: gray",
|
"color: gray",
|
||||||
);
|
);
|
||||||
@@ -334,7 +381,7 @@ export async function runIndexing(): Promise<void> {
|
|||||||
setProgress: (p) => saveProgress(jobId, p),
|
setProgress: (p) => saveProgress(jobId, p),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.debug(`%c[Indexer] Running job "${jobId}"...`, "color: #4ea1ff");
|
verboseDebug(`%c[Indexer] Running job "${jobId}"...`, "color: #4ea1ff");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newItemsRaw = await job.run(ctx);
|
const newItemsRaw = await job.run(ctx);
|
||||||
@@ -346,12 +393,12 @@ export async function runIndexing(): Promise<void> {
|
|||||||
await setStoredItems(merged);
|
await setStoredItems(merged);
|
||||||
await updateLastRunMeta(jobId);
|
await updateLastRunMeta(jobId);
|
||||||
|
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`%c[Indexer] ${job.label}: ${newItemsRaw.length} new items reported by run, ${merged.length} total items now in '${jobId}' store.`,
|
`%c[Indexer] ${job.label}: ${newItemsRaw.length} new items reported by run, ${merged.length} total items now in '${jobId}' store.`,
|
||||||
"color: #00c46f",
|
"color: #00c46f",
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.debug(`%c[Indexer] Job ${job.label} failed:`, "color: red");
|
verboseDebug(`%c[Indexer] Job ${job.label} failed:`, "color: red");
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +425,7 @@ export async function runIndexing(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (allItemsInPrimaryStores.length > 0) {
|
if (allItemsInPrimaryStores.length > 0) {
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`%c[Indexer] Checking ${allItemsInPrimaryStores.length} items for vectorization...`,
|
`%c[Indexer] Checking ${allItemsInPrimaryStores.length} items for vectorization...`,
|
||||||
"color: #4ea1ff",
|
"color: #4ea1ff",
|
||||||
);
|
);
|
||||||
@@ -388,7 +435,7 @@ export async function runIndexing(): Promise<void> {
|
|||||||
const newItemsToVectorize = allItemsInPrimaryStores.filter(item => !vectorizedItemIds.has(item.id));
|
const newItemsToVectorize = allItemsInPrimaryStores.filter(item => !vectorizedItemIds.has(item.id));
|
||||||
|
|
||||||
if (newItemsToVectorize.length > 0) {
|
if (newItemsToVectorize.length > 0) {
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`%c[Indexer] Sending ${newItemsToVectorize.length} new items to worker for vectorization (${allItemsInPrimaryStores.length - newItemsToVectorize.length} already vectorized)`,
|
`%c[Indexer] Sending ${newItemsToVectorize.length} new items to worker for vectorization (${allItemsInPrimaryStores.length - newItemsToVectorize.length} already vectorized)`,
|
||||||
"color: #4ea1ff",
|
"color: #4ea1ff",
|
||||||
);
|
);
|
||||||
@@ -397,56 +444,9 @@ 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" &&
|
verboseDebug(
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.debug(
|
|
||||||
"%c[Indexer] Vectorization task for stored items sent to worker.",
|
"%c[Indexer] Vectorization task for stored items sent to worker.",
|
||||||
"color: green",
|
"color: green",
|
||||||
);
|
);
|
||||||
@@ -465,7 +465,7 @@ export async function runIndexing(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`%c[Indexer] All ${allItemsInPrimaryStores.length} items are already vectorized, skipping worker initialization.`,
|
`%c[Indexer] All ${allItemsInPrimaryStores.length} items are already vectorized, skipping worker initialization.`,
|
||||||
"color: gray",
|
"color: gray",
|
||||||
);
|
);
|
||||||
@@ -478,7 +478,7 @@ export async function runIndexing(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.debug(
|
verboseDebug(
|
||||||
"%c[Indexer] No items found in primary stores to send for vectorization.",
|
"%c[Indexer] No items found in primary stores to send for vectorization.",
|
||||||
"color: gray",
|
"color: gray",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** In-memory gate: after a manual reset, skip indexing until the tab reloads. */
|
||||||
|
let pausedUntilReload = false;
|
||||||
|
|
||||||
|
export function pauseIndexingUntilReload(): void {
|
||||||
|
pausedUntilReload = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIndexingPaused(): boolean {
|
||||||
|
return pausedUntilReload;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IndexItem, Job } from "../types";
|
import type { IndexItem, Job } from "../types";
|
||||||
|
|
||||||
|
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",
|
||||||
@@ -128,7 +129,7 @@ export const assignmentsJob: Job = {
|
|||||||
|
|
||||||
const student = 69; // TODO: Get from context if available
|
const student = 69; // TODO: Get from context if available
|
||||||
|
|
||||||
console.debug("[Assignments job] Starting indexing - fetching all assessments (upcoming and past)...");
|
verboseDebug("[Assignments job] Starting indexing - fetching all assessments (upcoming and past)...");
|
||||||
|
|
||||||
// Fetch data in parallel
|
// Fetch data in parallel
|
||||||
const [upcoming, subjects] = await Promise.all([
|
const [upcoming, subjects] = await Promise.all([
|
||||||
@@ -136,12 +137,12 @@ export const assignmentsJob: Job = {
|
|||||||
fetchSubjects(),
|
fetchSubjects(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.debug(`[Assignments job] Fetched ${upcoming.length} upcoming assessments and ${subjects.length} subjects`);
|
verboseDebug(`[Assignments job] Fetched ${upcoming.length} upcoming assessments and ${subjects.length} subjects`);
|
||||||
|
|
||||||
// Fetch past assessments for ALL subjects to ensure we get all historical assignments
|
// Fetch past assessments for ALL subjects to ensure we get all historical assignments
|
||||||
const past = await fetchPastAssessments(student, subjects);
|
const past = await fetchPastAssessments(student, subjects);
|
||||||
|
|
||||||
console.debug(`[Assignments job] Fetched ${past.length} past assessments`);
|
verboseDebug(`[Assignments job] Fetched ${past.length} past assessments`);
|
||||||
|
|
||||||
// Create a lookup map from subject code to programme/metaclass
|
// Create a lookup map from subject code to programme/metaclass
|
||||||
const subjectLookup = new Map<string, { programme: number; metaclass: number }>();
|
const subjectLookup = new Map<string, { programme: number; metaclass: number }>();
|
||||||
@@ -220,7 +221,7 @@ export const assignmentsJob: Job = {
|
|||||||
const assessmentArray = Array.from(allAssessments.values());
|
const assessmentArray = Array.from(allAssessments.values());
|
||||||
const pastCount = assessmentArray.filter(a => !a.isUpcoming).length;
|
const pastCount = assessmentArray.filter(a => !a.isUpcoming).length;
|
||||||
const upcomingCount = assessmentArray.filter(a => a.isUpcoming).length;
|
const upcomingCount = assessmentArray.filter(a => a.isUpcoming).length;
|
||||||
console.debug(`[Assignments job] Processing ${assessmentArray.length} total assessments (${upcomingCount} upcoming, ${pastCount} past)`);
|
verboseDebug(`[Assignments job] Processing ${assessmentArray.length} total assessments (${upcomingCount} upcoming, ${pastCount} past)`);
|
||||||
const batchSize = 15; // Increased batch size for better performance
|
const batchSize = 15; // Increased batch size for better performance
|
||||||
|
|
||||||
// Skip fetching assessment details - the API endpoint doesn't exist or returns 404
|
// Skip fetching assessment details - the API endpoint doesn't exist or returns 404
|
||||||
@@ -321,7 +322,7 @@ export const assignmentsJob: Job = {
|
|||||||
renderComponentId: "assessment",
|
renderComponentId: "assessment",
|
||||||
};
|
};
|
||||||
|
|
||||||
console.debug(`[Assignments job] ✅ Created item for assignment ${assessment.id}:`, {
|
verboseDebug(`[Assignments job] ✅ Created item for assignment ${assessment.id}:`, {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
programmeId: item.metadata.programmeId,
|
programmeId: item.metadata.programmeId,
|
||||||
programmeID: item.metadata.programmeID,
|
programmeID: item.metadata.programmeID,
|
||||||
@@ -350,7 +351,7 @@ export const assignmentsJob: Job = {
|
|||||||
|
|
||||||
const newItemsCount = items.filter(item => !existingIds.has(item.id)).length;
|
const newItemsCount = items.filter(item => !existingIds.has(item.id)).length;
|
||||||
const updatedItemsCount = items.length - newItemsCount;
|
const updatedItemsCount = items.length - newItemsCount;
|
||||||
console.debug(`[Assignments job] Indexed ${items.length} assignment items (${newItemsCount} new, ${updatedItemsCount} updated)`);
|
verboseDebug(`[Assignments job] Indexed ${items.length} assignment items (${newItemsCount} new, ${updatedItemsCount} updated)`);
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { seqtaFetchPayload } from "../api";
|
|||||||
import { buildIndexItem } from "../extract";
|
import { buildIndexItem } from "../extract";
|
||||||
import { htmlToPlainText } from "../utils";
|
import { htmlToPlainText } from "../utils";
|
||||||
|
|
||||||
|
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`.
|
||||||
*
|
*
|
||||||
@@ -106,7 +107,7 @@ export const coursesJob: Job = {
|
|||||||
run: async (_ctx) => {
|
run: async (_ctx) => {
|
||||||
const subjects = await fetchActiveSubjects();
|
const subjects = await fetchActiveSubjects();
|
||||||
if (subjects.length === 0) {
|
if (subjects.length === 0) {
|
||||||
console.debug("[Courses job] No active subjects discovered.");
|
verboseDebug("[Courses job] No active subjects discovered.");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +170,7 @@ export const coursesJob: Job = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`[Courses job] Indexed ${items.length} courses across ${subjects.length} subjects.`,
|
`[Courses job] Indexed ${items.length} courses across ${subjects.length} subjects.`,
|
||||||
);
|
);
|
||||||
return items;
|
return items;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { IndexItem, Job } from "../types";
|
import type { IndexItem, Job } from "../types";
|
||||||
import { seqtaFetchPayload } from "../api";
|
import { seqtaFetchPayload } from "../api";
|
||||||
|
|
||||||
|
import { verboseDebug } from '@/utils/verboseLog';
|
||||||
/**
|
/**
|
||||||
* Indexes file metadata from `/seqta/student/load/documents`.
|
* Indexes file metadata from `/seqta/student/load/documents`.
|
||||||
*
|
*
|
||||||
@@ -131,7 +132,7 @@ export const documentsJob: Job = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug(`[Documents job] Indexed ${items.length} document entries.`);
|
verboseDebug(`[Documents job] Indexed ${items.length} document entries.`);
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +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 } from '@/utils/verboseLog';
|
||||||
/**
|
/**
|
||||||
* Indexes student folio entries from `/seqta/student/folio`.
|
* Indexes student folio entries from `/seqta/student/folio`.
|
||||||
*
|
*
|
||||||
@@ -126,7 +127,7 @@ export const folioJob: Job = {
|
|||||||
await delay(PER_ITEM_DELAY_MS);
|
await delay(PER_ITEM_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug(`[Folio job] Indexed ${items.length} folio entries.`);
|
verboseDebug(`[Folio job] Indexed ${items.length} folio entries.`);
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +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 } from '@/utils/verboseLog';
|
||||||
/**
|
/**
|
||||||
* Indexes student goals from `/seqta/student/load/goals`.
|
* Indexes student goals from `/seqta/student/load/goals`.
|
||||||
*
|
*
|
||||||
@@ -42,7 +43,7 @@ export const goalsJob: Job = {
|
|||||||
{ mode: "years" },
|
{ mode: "years" },
|
||||||
);
|
);
|
||||||
if (!Array.isArray(years) || years.length === 0) {
|
if (!Array.isArray(years) || years.length === 0) {
|
||||||
console.debug("[Goals job] No goal years available; skipping.");
|
verboseDebug("[Goals job] No goal years available; skipping.");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ export const goalsJob: Job = {
|
|||||||
await delay(PER_YEAR_DELAY_MS);
|
await delay(PER_YEAR_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug(`[Goals job] Indexed ${items.length} goal entries.`);
|
verboseDebug(`[Goals job] Indexed ${items.length} goal entries.`);
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ 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';
|
||||||
const RATE_LIMIT_CONFIG = {
|
const RATE_LIMIT_CONFIG = {
|
||||||
minDelay: 30,
|
minDelay: 30,
|
||||||
maxDelay: 3000,
|
maxDelay: 3000,
|
||||||
@@ -208,7 +207,7 @@ function checkCircuitBreaker(progress: MessagesProgress): boolean {
|
|||||||
) {
|
) {
|
||||||
progress.circuitBreakerOpen = false;
|
progress.circuitBreakerOpen = false;
|
||||||
progress.consecutiveFailures = 0;
|
progress.consecutiveFailures = 0;
|
||||||
console.info(
|
verboseInfo(
|
||||||
`[Messages job] Circuit breaker closed after ${RATE_LIMIT_CONFIG.circuitBreakerResetTime}ms`,
|
`[Messages job] Circuit breaker closed after ${RATE_LIMIT_CONFIG.circuitBreakerResetTime}ms`,
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
@@ -352,7 +351,7 @@ async function processMessagesInParallel(
|
|||||||
batchResponseTime,
|
batchResponseTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Messages job] Processed parallel batch: ${batchSuccesses} successes, ${batchFailures} failures, ${batchResponseTime}ms total time`,
|
`[Messages job] Processed parallel batch: ${batchSuccesses} successes, ${batchFailures} failures, ${batchResponseTime}ms total time`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -394,20 +393,21 @@ export const messagesJob: Job = {
|
|||||||
progress.totalEstimated = await estimateMessageCount();
|
progress.totalEstimated = await estimateMessageCount();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await vectorWorker.startStreamingSession(
|
progress.streamingStarted = await vectorWorker.startStreamingSession(
|
||||||
progress.totalEstimated,
|
progress.totalEstimated,
|
||||||
(progressData) => {
|
(progressData) => {
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Messages job] Vector streaming progress: ${progressData.processed}/${progressData.total} (${progressData.status})`,
|
`[Messages job] Vector streaming progress: ${progressData.processed}/${progressData.total} (${progressData.status})`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
RATE_LIMIT_CONFIG.vectorBatchSize,
|
RATE_LIMIT_CONFIG.vectorBatchSize,
|
||||||
"messages",
|
"messages",
|
||||||
);
|
);
|
||||||
progress.streamingStarted = true;
|
if (progress.streamingStarted) {
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Messages job] Started streaming vectorization session for ~${progress.totalEstimated} items`,
|
`[Messages job] Started streaming vectorization session for ~${progress.totalEstimated} items`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[Messages job] Failed to start streaming session:",
|
"[Messages job] Failed to start streaming session:",
|
||||||
@@ -422,7 +422,7 @@ export const messagesJob: Job = {
|
|||||||
let itemsStreamedToVector = 0;
|
let itemsStreamedToVector = 0;
|
||||||
|
|
||||||
if (progress.retryQueue.length > 0) {
|
if (progress.retryQueue.length > 0) {
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Messages job] Processing ${Math.min(progress.retryQueue.length, 10)} items from retry queue`,
|
`[Messages job] Processing ${Math.min(progress.retryQueue.length, 10)} items from retry queue`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -505,7 +505,7 @@ export const messagesJob: Job = {
|
|||||||
batchResponseTime,
|
batchResponseTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Messages job] Processed retry batch: ${retrySuccesses} successes, ${retryFailures} failures`,
|
`[Messages job] Processed retry batch: ${retrySuccesses} successes, ${retryFailures} failures`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -590,7 +590,7 @@ export const messagesJob: Job = {
|
|||||||
try {
|
try {
|
||||||
await vectorWorker.streamItems(itemsToStream);
|
await vectorWorker.streamItems(itemsToStream);
|
||||||
itemsStreamedToVector += itemsToStream.length;
|
itemsStreamedToVector += itemsToStream.length;
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Messages job] Streamed ${itemsToStream.length} items to vector worker (total: ${itemsStreamedToVector})`,
|
`[Messages job] Streamed ${itemsToStream.length} items to vector worker (total: ${itemsStreamedToVector})`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -603,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(
|
||||||
@@ -659,7 +625,7 @@ export const messagesJob: Job = {
|
|||||||
await ctx.setProgress(progress);
|
await ctx.setProgress(progress);
|
||||||
progressUpdateCounter = 0;
|
progressUpdateCounter = 0;
|
||||||
|
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Messages job] Progress: offset=${progress.offset}, batchSize=${progress.currentBatchSize}, delay=${progress.currentDelay}ms, failures=${progress.failedRequests}, retryQueue=${progress.retryQueue.length}, vectorStreamed=${itemsStreamedToVector}, parallelRequests=${RATE_LIMIT_CONFIG.parallelRequests}`,
|
`[Messages job] Progress: offset=${progress.offset}, batchSize=${progress.currentBatchSize}, delay=${progress.currentDelay}ms, failures=${progress.failedRequests}, retryQueue=${progress.retryQueue.length}, vectorStreamed=${itemsStreamedToVector}, parallelRequests=${RATE_LIMIT_CONFIG.parallelRequests}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -673,7 +639,7 @@ export const messagesJob: Job = {
|
|||||||
if (progress.streamingStarted) {
|
if (progress.streamingStarted) {
|
||||||
try {
|
try {
|
||||||
await vectorWorker.endStreamingSession();
|
await vectorWorker.endStreamingSession();
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Messages job] Ended streaming session. Total items streamed: ${itemsStreamedToVector}`,
|
`[Messages job] Ended streaming session. Total items streamed: ${itemsStreamedToVector}`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,6 +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 } from '@/utils/verboseLog';
|
||||||
/**
|
/**
|
||||||
* Indexes daily notices from `/seqta/student/load/notices`.
|
* Indexes daily notices from `/seqta/student/load/notices`.
|
||||||
*
|
*
|
||||||
@@ -205,7 +206,7 @@ export const noticesJob: Job = {
|
|||||||
await ctx.setProgress(progress);
|
await ctx.setProgress(progress);
|
||||||
|
|
||||||
const newCount = items.filter((i) => !existingIds.has(i.id)).length;
|
const newCount = items.filter((i) => !existingIds.has(i.id)).length;
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`[Notices job] Indexed ${items.length} notices across ${dates.length} dates (${newCount} new).`,
|
`[Notices job] Indexed ${items.length} notices across ${dates.length} dates (${newCount} new).`,
|
||||||
);
|
);
|
||||||
return items;
|
return items;
|
||||||
|
|||||||
@@ -3,11 +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 { verboseLog } from '@/utils/verboseLog';
|
||||||
const NOTIFICATIONS_RATE_LIMIT = {
|
const NOTIFICATIONS_RATE_LIMIT = {
|
||||||
baseDelay: 150,
|
baseDelay: 150,
|
||||||
maxDelay: 3000,
|
maxDelay: 3000,
|
||||||
@@ -198,20 +197,21 @@ export const notificationsJob: Job = {
|
|||||||
const estimatedTotal = Math.min(notifications.length * 1.2, 100);
|
const estimatedTotal = Math.min(notifications.length * 1.2, 100);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await vectorWorker.startStreamingSession(
|
progress.streamingStarted = await vectorWorker.startStreamingSession(
|
||||||
estimatedTotal,
|
estimatedTotal,
|
||||||
(progressData) => {
|
(progressData) => {
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Notifications job] Vector streaming progress: ${progressData.processed}/${progressData.total} (${progressData.status})`,
|
`[Notifications job] Vector streaming progress: ${progressData.processed}/${progressData.total} (${progressData.status})`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
NOTIFICATIONS_RATE_LIMIT.vectorBatchSize,
|
NOTIFICATIONS_RATE_LIMIT.vectorBatchSize,
|
||||||
"notifications",
|
"notifications",
|
||||||
);
|
);
|
||||||
progress.streamingStarted = true;
|
if (progress.streamingStarted) {
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Notifications job] Started streaming vectorization session for ~${estimatedTotal} items`,
|
`[Notifications job] Started streaming vectorization session for ~${estimatedTotal} items`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[Notifications job] Failed to start streaming session:",
|
"[Notifications job] Failed to start streaming session:",
|
||||||
@@ -247,7 +247,7 @@ export const notificationsJob: Job = {
|
|||||||
let itemsStreamedToVector = 0;
|
let itemsStreamedToVector = 0;
|
||||||
|
|
||||||
if (progress.retryQueue.length > 0) {
|
if (progress.retryQueue.length > 0) {
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Notifications job] Processing ${Math.min(progress.retryQueue.length, 3)} items from retry queue`,
|
`[Notifications job] Processing ${Math.min(progress.retryQueue.length, 3)} items from retry queue`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ export const notificationsJob: Job = {
|
|||||||
try {
|
try {
|
||||||
await vectorWorker.streamItems([...itemsToStream]);
|
await vectorWorker.streamItems([...itemsToStream]);
|
||||||
itemsStreamedToVector += itemsToStream.length;
|
itemsStreamedToVector += itemsToStream.length;
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Notifications job] Streamed ${itemsToStream.length} items to vector worker (total: ${itemsStreamedToVector})`,
|
`[Notifications job] Streamed ${itemsToStream.length} items to vector worker (total: ${itemsStreamedToVector})`,
|
||||||
);
|
);
|
||||||
itemsToStream.length = 0;
|
itemsToStream.length = 0;
|
||||||
@@ -371,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(
|
||||||
@@ -424,7 +390,7 @@ export const notificationsJob: Job = {
|
|||||||
try {
|
try {
|
||||||
await vectorWorker.streamItems([...itemsToStream]);
|
await vectorWorker.streamItems([...itemsToStream]);
|
||||||
itemsStreamedToVector += itemsToStream.length;
|
itemsStreamedToVector += itemsToStream.length;
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Notifications job] Streamed final ${itemsToStream.length} items to vector worker (total: ${itemsStreamedToVector})`,
|
`[Notifications job] Streamed final ${itemsToStream.length} items to vector worker (total: ${itemsStreamedToVector})`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -438,7 +404,7 @@ export const notificationsJob: Job = {
|
|||||||
if (progress.streamingStarted) {
|
if (progress.streamingStarted) {
|
||||||
try {
|
try {
|
||||||
await vectorWorker.endStreamingSession();
|
await vectorWorker.endStreamingSession();
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Notifications job] Ended streaming session. Total items streamed: ${itemsStreamedToVector}`,
|
`[Notifications job] Ended streaming session. Total items streamed: ${itemsStreamedToVector}`,
|
||||||
);
|
);
|
||||||
progress.streamingStarted = false;
|
progress.streamingStarted = false;
|
||||||
@@ -459,7 +425,7 @@ export const notificationsJob: Job = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ctx.setProgress(progress);
|
await ctx.setProgress(progress);
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Notifications job] Processed ${processedCount} notifications, ${progress.retryQueue.length} in retry queue, ${progress.failedRequests} failures, ${itemsStreamedToVector} items streamed to vector worker`,
|
`[Notifications job] Processed ${processedCount} notifications, ${progress.retryQueue.length} in retry queue, ${progress.failedRequests} failures, ${itemsStreamedToVector} items streamed to vector worker`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { IndexItem, Job } from "../types";
|
import type { IndexItem, Job } from "../types";
|
||||||
import { seqtaFetchPayload } from "../api";
|
import { seqtaFetchPayload } from "../api";
|
||||||
|
|
||||||
|
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`.
|
||||||
*
|
*
|
||||||
@@ -82,7 +83,7 @@ export const portalsJob: Job = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug(`[Portals job] Indexed ${items.length} portal entries.`);
|
verboseDebug(`[Portals job] Indexed ${items.length} portal entries.`);
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { IndexItem, Job } from "../types";
|
import type { IndexItem, Job } from "../types";
|
||||||
import { seqtaFetchPayload } from "../api";
|
import { seqtaFetchPayload } from "../api";
|
||||||
|
|
||||||
|
import { verboseDebug } from '@/utils/verboseLog';
|
||||||
/**
|
/**
|
||||||
* Indexes report metadata from `/seqta/student/load/reports`.
|
* Indexes report metadata from `/seqta/student/load/reports`.
|
||||||
*
|
*
|
||||||
@@ -89,7 +90,7 @@ export const reportsJob: Job = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug(`[Reports job] Indexed ${items.length} reports.`);
|
verboseDebug(`[Reports job] Indexed ${items.length} reports.`);
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IndexItem, Job } from "../types";
|
import type { IndexItem, Job } from "../types";
|
||||||
|
|
||||||
|
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",
|
||||||
@@ -129,7 +130,7 @@ export const subjectsJob: Job = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug(`[Subjects job] Indexed ${items.length} subject items`);
|
verboseDebug(`[Subjects job] Indexed ${items.length} subject items`);
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import {
|
|||||||
pickId,
|
pickId,
|
||||||
pickTitle,
|
pickTitle,
|
||||||
} from "./extract";
|
} from "./extract";
|
||||||
|
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";
|
||||||
|
import { isIndexingPaused } from "./indexingPause";
|
||||||
|
import { isAssessmentListRoute } from "./routeFilters";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Passive network observer.
|
* Passive network observer.
|
||||||
@@ -296,6 +299,8 @@ function synthesizeItems(
|
|||||||
ctx: CapturedContext,
|
ctx: CapturedContext,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
): IndexItem[] {
|
): IndexItem[] {
|
||||||
|
if (isAssessmentListRoute(ctx.route)) return [];
|
||||||
|
|
||||||
const entities = entitiesFromPayload(payload);
|
const entities = entitiesFromPayload(payload);
|
||||||
if (entities.length === 0) return [];
|
if (entities.length === 0) return [];
|
||||||
|
|
||||||
@@ -379,7 +384,7 @@ function synthesizeItems(
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
async function persistItems(items: IndexItem[]): Promise<void> {
|
async function persistItems(items: IndexItem[]): Promise<void> {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0 || isIndexingPaused()) return;
|
||||||
|
|
||||||
// Dedupe against existing entries. We replace on collision so the latest
|
// Dedupe against existing entries. We replace on collision so the latest
|
||||||
// observation wins (e.g. if a message changes title).
|
// observation wins (e.g. if a message changes title).
|
||||||
@@ -400,16 +405,27 @@ async function persistItems(items: IndexItem[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleFlush() {
|
function scheduleFlush() {
|
||||||
if (pendingFlush) return;
|
if (pendingFlush || isIndexingPaused()) return;
|
||||||
pendingFlush = setTimeout(() => {
|
pendingFlush = setTimeout(() => {
|
||||||
pendingFlush = null;
|
pendingFlush = null;
|
||||||
if (!pendingDirty) return;
|
if (!pendingDirty || isIndexingPaused()) return;
|
||||||
pendingDirty = false;
|
pendingDirty = false;
|
||||||
void flushDynamicItems();
|
void flushDynamicItems();
|
||||||
}, FLUSH_DEBOUNCE_MS);
|
}, FLUSH_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drop queued passive captures after a manual index reset. */
|
||||||
|
export function pausePassiveObserver(): void {
|
||||||
|
pendingChangedItems.clear();
|
||||||
|
pendingDirty = false;
|
||||||
|
if (pendingFlush) {
|
||||||
|
clearTimeout(pendingFlush);
|
||||||
|
pendingFlush = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function flushDynamicItems(): Promise<void> {
|
async function flushDynamicItems(): Promise<void> {
|
||||||
|
if (isIndexingPaused()) return;
|
||||||
if (pendingChangedItems.size === 0) return;
|
if (pendingChangedItems.size === 0) return;
|
||||||
|
|
||||||
const rawChanged = Array.from(pendingChangedItems.values());
|
const rawChanged = Array.from(pendingChangedItems.values());
|
||||||
@@ -437,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,
|
||||||
@@ -446,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 {
|
||||||
@@ -525,7 +546,7 @@ export function installPassiveObserver(): void {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Never let observer errors bubble up to the host page.
|
// Never let observer errors bubble up to the host page.
|
||||||
console.debug("[Passive Observer] fetch hook error:", e);
|
verboseDebug("[Passive Observer] fetch hook error:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -562,33 +583,22 @@ 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) {
|
||||||
console.debug("[Passive Observer] xhr load error:", e);
|
verboseDebug("[Passive Observer] xhr load error:", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -599,7 +609,7 @@ export function installPassiveObserver(): void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug("[Passive Observer] Installed.");
|
verboseDebug("[Passive Observer] Installed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,82 +1,96 @@
|
|||||||
import { SCHEMA_VERSION_KEY } from "./schemaVersion";
|
import { SCHEMA_VERSION_KEY } from "./schemaVersion";
|
||||||
|
import { pauseIndexingUntilReload } from "./indexingPause";
|
||||||
|
import { pausePassiveObserver } from "./passiveObserver";
|
||||||
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
/**
|
export const RESET_INDEX_MESSAGE = "global-search-reset-index";
|
||||||
* Hard-reset of all global-search persistence.
|
|
||||||
*
|
let resetMessageListenerInstalled = false;
|
||||||
* This module is intentionally dependency-free (no imports from `db.ts`,
|
|
||||||
* the worker manager, embeddia, or any heavy bundle) so it can be
|
export async function notifyOpenTabsResetSearchIndex(): Promise<void> {
|
||||||
* statically imported from:
|
const tabs = await browser.tabs.query({});
|
||||||
*
|
await Promise.allSettled(
|
||||||
* - The always-loaded plugin shell (`lazy.ts`) for the manual
|
tabs.map((tab) =>
|
||||||
* "Reset Index" settings button. Statically importing means the button
|
tab.id != null
|
||||||
* keeps working across extension updates — there's no chunk hash to
|
? browser.tabs.sendMessage(tab.id, { type: RESET_INDEX_MESSAGE })
|
||||||
* chase via dynamic import, which previously produced
|
: Promise.resolve(),
|
||||||
* `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
|
export function installResetIndexMessageListener(): void {
|
||||||
* that fires whenever the extension's manifest version changes.
|
if (resetMessageListenerInstalled) return;
|
||||||
*
|
resetMessageListenerInstalled = true;
|
||||||
* The function:
|
|
||||||
* 1. Notifies in-process modules to drop in-memory caches and any open
|
browser.runtime.onMessage.addListener((message) => {
|
||||||
* IndexedDB connections via custom DOM events (best effort).
|
if (message?.type !== RESET_INDEX_MESSAGE) return;
|
||||||
* 2. Deletes the structured `betterseqta-index` and the vector
|
pauseIndexingUntilReload();
|
||||||
* `embeddiaDB` databases.
|
pausePassiveObserver();
|
||||||
* 3. Clears version-tracking localStorage keys so the next indexing
|
if (typeof window !== "undefined") {
|
||||||
* pass treats the world as fresh.
|
window.dispatchEvent(
|
||||||
*
|
new CustomEvent("indexing-progress", {
|
||||||
* It never throws on partial failure: each step is wrapped in try/catch
|
detail: {
|
||||||
* so a stuck connection on one DB doesn't block the other.
|
completed: 0,
|
||||||
*/
|
total: 0,
|
||||||
|
indexing: false,
|
||||||
|
status: "Indexing paused — reload to rebuild",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
void resetSearchIndexes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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";
|
||||||
|
|
||||||
function deleteIndexedDb(name: string): Promise<void> {
|
function delay(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
let resolved = false;
|
}
|
||||||
const finish = () => {
|
|
||||||
if (resolved) return;
|
|
||||||
resolved = true;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
|
function tryDeleteDatabase(
|
||||||
|
name: string,
|
||||||
|
): Promise<"success" | "blocked" | "error"> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
let req: IDBOpenDBRequest;
|
let req: IDBOpenDBRequest;
|
||||||
try {
|
try {
|
||||||
req = indexedDB.deleteDatabase(name);
|
req = indexedDB.deleteDatabase(name);
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.warn(`[Reset] Could not start delete of ${name}:`, e);
|
console.warn(`[Reset] Could not start delete of ${name}:`, error);
|
||||||
finish();
|
resolve("error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
req.onsuccess = () => finish();
|
req.onsuccess = () => resolve("success");
|
||||||
req.onerror = () => {
|
req.onerror = () => {
|
||||||
console.warn(`[Reset] Error deleting ${name}:`, req.error);
|
console.warn(`[Reset] Error deleting ${name}:`, req.error);
|
||||||
finish();
|
resolve("error");
|
||||||
};
|
|
||||||
req.onblocked = () => {
|
|
||||||
// Connections are still open in another tab. Wait briefly, retry,
|
|
||||||
// then resolve regardless so we never hang the caller forever.
|
|
||||||
console.warn(
|
|
||||||
`[Reset] Delete of ${name} blocked; will retry then resolve.`,
|
|
||||||
);
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
const retry = indexedDB.deleteDatabase(name);
|
|
||||||
retry.onsuccess = () => finish();
|
|
||||||
retry.onerror = () => finish();
|
|
||||||
retry.onblocked = () => finish();
|
|
||||||
} catch {
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
}, 600);
|
|
||||||
};
|
};
|
||||||
|
req.onblocked = () => resolve("blocked");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteIndexedDb(name: string): Promise<void> {
|
||||||
|
const maxAttempts = 6;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
const result = await tryDeleteDatabase(name);
|
||||||
|
if (result === "success") return;
|
||||||
|
|
||||||
|
if (result === "blocked") {
|
||||||
|
console.warn(
|
||||||
|
`[Reset] Delete of ${name} blocked (attempt ${attempt + 1}/${maxAttempts}); waiting for connections to close`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await delay(200 * (attempt + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`[Reset] Gave up deleting ${name} after ${maxAttempts} attempts`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function resetSearchIndexes(): Promise<void> {
|
export async function resetSearchIndexes(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -91,12 +105,10 @@ 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
|
await delay(300);
|
||||||
// the delete request below comes back with `onblocked`.
|
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 150));
|
|
||||||
|
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
deleteIndexedDb(STRUCTURED_DB),
|
deleteIndexedDb(STRUCTURED_DB),
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { isAssessmentListRoute } from "./routeFilters";
|
||||||
|
|
||||||
|
describe("isAssessmentListRoute", () => {
|
||||||
|
it("matches past and upcoming assessment list routes", () => {
|
||||||
|
expect(isAssessmentListRoute("/seqta/student/assessment/list/past?")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isAssessmentListRoute("/seqta/student/assessment/list/upcoming?"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not match unrelated routes", () => {
|
||||||
|
expect(isAssessmentListRoute("/seqta/student/load/courses")).toBe(false);
|
||||||
|
expect(isAssessmentListRoute("/seqta/student/load/messages")).toBe(false);
|
||||||
|
expect(isAssessmentListRoute("/seqta/student/assessment/save")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** Routes already indexed by the assignments job — passive capture would duplicate them. */
|
||||||
|
export function isAssessmentListRoute(route: string): boolean {
|
||||||
|
const normalized = route.toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized.includes("/assessment/list/past") ||
|
||||||
|
normalized.includes("/assessment/list/upcoming")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { runGlobalSearchSelfTests } from "./selfTests";
|
||||||
|
|
||||||
|
describe("globalSearch selfTests", () => {
|
||||||
|
it("all in-process cases pass", async () => {
|
||||||
|
const report = await runGlobalSearchSelfTests();
|
||||||
|
expect(report.failed).toBe(0);
|
||||||
|
expect(report.failures).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,12 +21,10 @@ import {
|
|||||||
/**
|
/**
|
||||||
* Lightweight in-process self-tests for the global-search overhaul.
|
* Lightweight in-process self-tests for the global-search overhaul.
|
||||||
*
|
*
|
||||||
* The repository does not (yet) ship with a test runner, so we instead
|
* Exposes a deterministic suite of assertions over the pure helpers that
|
||||||
* expose a deterministic suite of assertions over the pure helpers that
|
* back active jobs and the passive observer. Runs in Jest via
|
||||||
* back active jobs and the passive observer. This is intentionally
|
* `selfTests.test.ts`, and inside the extension page via
|
||||||
* dependency-free so it can run inside the extension page (`window.
|
* `window.globalSearchDebug.runSelfTests()`.
|
||||||
* globalSearchDebug.runSelfTests()`) and from any future Vitest harness
|
|
||||||
* without modification.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface TestCase {
|
interface TestCase {
|
||||||
@@ -319,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 {
|
|
||||||
console.info(
|
|
||||||
`[Global Search Self-Tests] All ${report.passed} cases passed`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +1,75 @@
|
|||||||
/**
|
|
||||||
* 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 = () => {
|
|
||||||
console.debug("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")) {
|
|
||||||
console.debug("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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.debug(`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) return new Set();
|
||||||
*/
|
|
||||||
|
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
@@ -140,7 +104,7 @@ export function htmlToPlainText(rawHtml: string): string {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let text = body.innerText || "";
|
let text = body.textContent || body.innerText || "";
|
||||||
|
|
||||||
text = text
|
text = text
|
||||||
.replace(/\u00A0/g, " ")
|
.replace(/\u00A0/g, " ")
|
||||||
|
|||||||
@@ -1,26 +1,41 @@
|
|||||||
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
|
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
|
||||||
import type { IndexItem } from "../types";
|
import type { IndexItem } from "../types";
|
||||||
|
|
||||||
|
import { verboseDebug } from "./workerVerboseLog";
|
||||||
|
|
||||||
|
let ortWasmBase: string | null = null;
|
||||||
|
|
||||||
|
async function configureOrtWasm(base: string): Promise<void> {
|
||||||
|
const { env } = await import("@huggingface/transformers");
|
||||||
|
env.backends.onnx.wasm = env.backends.onnx.wasm ?? {};
|
||||||
|
env.backends.onnx.wasm.wasmPaths = base.endsWith("/") ? base : `${base}/`;
|
||||||
|
}
|
||||||
let vectorIndex: EmbeddingIndex | null = null;
|
let vectorIndex: EmbeddingIndex | null = null;
|
||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
let initializationFailed = false;
|
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;
|
||||||
@@ -33,27 +48,30 @@ let streamingSession: {
|
|||||||
|
|
||||||
async function initWorker() {
|
async function initWorker() {
|
||||||
if (isInitialized) {
|
if (isInitialized) {
|
||||||
console.debug("Vector worker already initialized.");
|
verboseDebug("Vector worker already initialized.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip initialization in Firefox
|
// Skip initialization in Firefox
|
||||||
if (isFirefoxWorker()) {
|
if (isFirefoxWorker()) {
|
||||||
console.debug("[Vector Worker] Vector search not supported in Firefox - skipping initialization");
|
verboseDebug("[Vector Worker] Vector search not supported in Firefox - skipping initialization");
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
initializationFailed = true;
|
initializationFailed = true;
|
||||||
vectorIndex = null;
|
vectorIndex = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug("Initializing vector worker...");
|
verboseDebug("Initializing vector worker...");
|
||||||
try {
|
try {
|
||||||
|
if (ortWasmBase) {
|
||||||
|
await configureOrtWasm(ortWasmBase);
|
||||||
|
}
|
||||||
await initializeModel();
|
await initializeModel();
|
||||||
vectorIndex = new EmbeddingIndex([]);
|
vectorIndex = new EmbeddingIndex([]);
|
||||||
|
|
||||||
const stored = await vectorIndex.getAllObjectsFromIndexedDB();
|
const stored = await vectorIndex.getAllObjectsFromIndexedDB();
|
||||||
if (stored.length > 0) {
|
if (stored.length > 0) {
|
||||||
console.debug(`Found ${stored.length} existing items in IndexedDB`);
|
verboseDebug(`Found ${stored.length} existing items in IndexedDB`);
|
||||||
|
|
||||||
loadedItemIds.clear();
|
loadedItemIds.clear();
|
||||||
|
|
||||||
@@ -64,14 +82,14 @@ async function initWorker() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`Vector index loaded ${loadedItemIds.size} unique items from IndexedDB.`,
|
`Vector index loaded ${loadedItemIds.size} unique items from IndexedDB.`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.debug("No existing vector index found in IndexedDB.");
|
verboseDebug("No existing vector index found in IndexedDB.");
|
||||||
}
|
}
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
console.debug("Vector worker initialized successfully.");
|
verboseDebug("Vector worker initialized successfully.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[Vector Worker] Failed to initialize vector worker (will use text search only):", e);
|
console.warn("[Vector Worker] Failed to initialize vector worker (will use text search only):", e);
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
@@ -106,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,7 +156,7 @@ async function startStreamingSession(
|
|||||||
processingPromise: null,
|
processingPromise: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`Started streaming session for ${totalExpected} items with batch size ${batchSize}`,
|
`Started streaming session for ${totalExpected} items with batch size ${batchSize}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -175,7 +182,7 @@ async function processStreamingBatch(
|
|||||||
streamingSession.totalReceived += items.length;
|
streamingSession.totalReceived += items.length;
|
||||||
streamingSession.pendingItems.push(...items);
|
streamingSession.pendingItems.push(...items);
|
||||||
|
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`Received streaming batch: ${items.length} items (${streamingSession.totalReceived}/${streamingSession.totalExpected})`,
|
`Received streaming batch: ${items.length} items (${streamingSession.totalReceived}/${streamingSession.totalExpected})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -208,7 +215,7 @@ async function processStreamingItems() {
|
|||||||
|
|
||||||
if (unprocessedItems.length === 0) {
|
if (unprocessedItems.length === 0) {
|
||||||
streamingSession.totalProcessed += batchToProcess.length;
|
streamingSession.totalProcessed += batchToProcess.length;
|
||||||
console.debug(`Skipped ${batchToProcess.length} already processed items`);
|
verboseDebug(`Skipped ${batchToProcess.length} already processed items`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +238,7 @@ async function processStreamingItems() {
|
|||||||
loadedItemIds.size % 200 === 0
|
loadedItemIds.size % 200 === 0
|
||||||
) {
|
) {
|
||||||
await vectorIndex!.saveIndex("indexedDB");
|
await vectorIndex!.saveIndex("indexedDB");
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`Saved streaming index at ${streamingSession.totalProcessed} processed items (${loadedItemIds.size} total unique items)`,
|
`Saved streaming index at ${streamingSession.totalProcessed} processed items (${loadedItemIds.size} total unique items)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -272,7 +279,7 @@ async function finalizeStreamingSession() {
|
|||||||
try {
|
try {
|
||||||
if (vectorIndex) {
|
if (vectorIndex) {
|
||||||
await vectorIndex.saveIndex("indexedDB");
|
await vectorIndex.saveIndex("indexedDB");
|
||||||
console.debug("Final save of streaming index completed");
|
verboseDebug("Final save of streaming index completed");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error in final streaming save:", e);
|
console.error("Error in final streaming save:", e);
|
||||||
@@ -293,7 +300,7 @@ async function finalizeStreamingSession() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`Streaming session completed: ${totalProcessed}/${totalExpected} items processed`,
|
`Streaming session completed: ${totalProcessed}/${totalExpected} items processed`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -303,14 +310,14 @@ async function endStreamingSession() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug("Ending streaming session...");
|
verboseDebug("Ending streaming session...");
|
||||||
|
|
||||||
if (streamingSession.processingPromise) {
|
if (streamingSession.processingPromise) {
|
||||||
await streamingSession.processingPromise;
|
await streamingSession.processingPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamingSession.pendingItems.length > 0) {
|
if (streamingSession.pendingItems.length > 0) {
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`Processing ${streamingSession.pendingItems.length} remaining items before ending session`,
|
`Processing ${streamingSession.pendingItems.length} remaining items before ending session`,
|
||||||
);
|
);
|
||||||
streamingSession.processingPromise = processStreamingItems();
|
streamingSession.processingPromise = processStreamingItems();
|
||||||
@@ -320,7 +327,7 @@ async function endStreamingSession() {
|
|||||||
try {
|
try {
|
||||||
if (vectorIndex) {
|
if (vectorIndex) {
|
||||||
await vectorIndex.saveIndex("indexedDB");
|
await vectorIndex.saveIndex("indexedDB");
|
||||||
console.debug("Final save before ending streaming session");
|
verboseDebug("Final save before ending streaming session");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error in final save before ending session:", e);
|
console.error("Error in final save before ending session:", e);
|
||||||
@@ -341,16 +348,10 @@ async function endStreamingSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function processItems(items: IndexItem[], signal: AbortSignal) {
|
async function processItems(items: IndexItem[], signal: AbortSignal) {
|
||||||
console.debug("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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -378,7 +372,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
console.debug("Processing cancelled before starting.");
|
verboseDebug("Processing cancelled before starting.");
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: "progress",
|
type: "progress",
|
||||||
data: {
|
data: {
|
||||||
@@ -390,7 +384,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (unprocessedItems.length === 0) {
|
if (unprocessedItems.length === 0) {
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`No new items to process. ${loadedItemIds.size} items already in index.`,
|
`No new items to process. ${loadedItemIds.size} items already in index.`,
|
||||||
);
|
);
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
@@ -403,7 +397,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`Starting processing of ${unprocessedItems.length} items (${items.length - unprocessedItems.length} already processed).`,
|
`Starting processing of ${unprocessedItems.length} items (${items.length - unprocessedItems.length} already processed).`,
|
||||||
);
|
);
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
@@ -419,7 +413,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) {
|
|||||||
let processedCount = 0;
|
let processedCount = 0;
|
||||||
for (let i = 0; i < unprocessedItems.length; i += BATCH_SIZE) {
|
for (let i = 0; i < unprocessedItems.length; i += BATCH_SIZE) {
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
console.debug("Processing cancelled during batching.");
|
verboseDebug("Processing cancelled during batching.");
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: "progress",
|
type: "progress",
|
||||||
data: {
|
data: {
|
||||||
@@ -437,7 +431,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) {
|
|||||||
) as (IndexItem & { embedding: number[] })[];
|
) as (IndexItem & { embedding: number[] })[];
|
||||||
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
console.debug("Processing cancelled after vectorization batch.");
|
verboseDebug("Processing cancelled after vectorization batch.");
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: "progress",
|
type: "progress",
|
||||||
data: {
|
data: {
|
||||||
@@ -464,7 +458,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
console.debug("Processing cancelled before saving batch.");
|
verboseDebug("Processing cancelled before saving batch.");
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: "progress",
|
type: "progress",
|
||||||
data: {
|
data: {
|
||||||
@@ -481,7 +475,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await vectorIndex!.saveIndex("indexedDB");
|
await vectorIndex!.saveIndex("indexedDB");
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`Saved index after processing batch ${i / BATCH_SIZE + 1} (${loadedItemIds.size} total unique items)`,
|
`Saved index after processing batch ${i / BATCH_SIZE + 1} (${loadedItemIds.size} total unique items)`,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -505,7 +499,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`Processing complete. Total unique items in index: ${loadedItemIds.size}`,
|
`Processing complete. Total unique items in index: ${loadedItemIds.size}`,
|
||||||
);
|
);
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
@@ -520,7 +514,7 @@ async function processItems(items: IndexItem[], signal: AbortSignal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resetWorker() {
|
async function resetWorker() {
|
||||||
console.debug("Resetting vector worker state...");
|
verboseDebug("Resetting vector worker state...");
|
||||||
|
|
||||||
loadedItemIds.clear();
|
loadedItemIds.clear();
|
||||||
|
|
||||||
@@ -532,7 +526,7 @@ async function resetWorker() {
|
|||||||
if (vectorIndex) {
|
if (vectorIndex) {
|
||||||
try {
|
try {
|
||||||
await vectorIndex.saveIndex("indexedDB");
|
await vectorIndex.saveIndex("indexedDB");
|
||||||
console.debug("Saved index before reset");
|
verboseDebug("Saved index before reset");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Error saving index before reset:", e);
|
console.warn("Error saving index before reset:", e);
|
||||||
}
|
}
|
||||||
@@ -543,7 +537,7 @@ async function resetWorker() {
|
|||||||
|
|
||||||
await initWorker();
|
await initWorker();
|
||||||
|
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`Vector worker reset complete. Loaded ${loadedItemIds.size} items.`,
|
`Vector worker reset complete. Loaded ${loadedItemIds.size} items.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -561,6 +555,9 @@ self.addEventListener("message", async (e) => {
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "init":
|
case "init":
|
||||||
|
if (data?.ortWasmBase) {
|
||||||
|
ortWasmBase = data.ortWasmBase;
|
||||||
|
}
|
||||||
await initWorker();
|
await initWorker();
|
||||||
self.postMessage({ type: "ready" });
|
self.postMessage({ type: "ready" });
|
||||||
break;
|
break;
|
||||||
@@ -593,13 +590,3 @@ self.addEventListener("message", async (e) => {
|
|||||||
console.warn("Unknown message type:", type);
|
console.warn("Unknown message type:", type);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
initWorker()
|
|
||||||
.then(() => {
|
|
||||||
self.postMessage({ type: "ready" });
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("Initial worker initialization failed:", err);
|
|
||||||
|
|
||||||
self.postMessage({ type: "ready" });
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { refreshVectorCache } from "../../search/vector/vectorSearch";
|
import { refreshVectorCache } from "../../search/vector/vectorSearch";
|
||||||
import type { IndexItem } from "../types";
|
import type { IndexItem } from "../types";
|
||||||
import { isVectorSearchSupported } from "../../utils/browserDetection";
|
import { isVectorSearchSupported } from "../../utils/browserDetection";
|
||||||
|
import { getOrtWasmBaseUrl } from "@/lib/transformersExtension";
|
||||||
import vectorWorker from "./vectorWorker.ts?inlineWorker";
|
import vectorWorker from "./vectorWorker.ts?inlineWorker";
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -12,6 +14,7 @@ export type ProgressCallback = (data: {
|
|||||||
|
|
||||||
export class VectorWorkerManager {
|
export class VectorWorkerManager {
|
||||||
private static instance: VectorWorkerManager;
|
private static instance: VectorWorkerManager;
|
||||||
|
private static resetListenerInstalled = false;
|
||||||
private worker: Worker | null = null;
|
private worker: Worker | null = null;
|
||||||
private isInitialized = false;
|
private isInitialized = false;
|
||||||
private readyPromise: Promise<void> | null = null;
|
private readyPromise: Promise<void> | null = null;
|
||||||
@@ -38,16 +41,27 @@ export class VectorWorkerManager {
|
|||||||
|
|
||||||
static getInstance(): VectorWorkerManager {
|
static getInstance(): VectorWorkerManager {
|
||||||
if (!VectorWorkerManager.instance) {
|
if (!VectorWorkerManager.instance) {
|
||||||
console.debug("Creating new VectorWorkerManager instance");
|
verboseDebug("Creating new VectorWorkerManager instance");
|
||||||
VectorWorkerManager.instance = new VectorWorkerManager();
|
VectorWorkerManager.instance = new VectorWorkerManager();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!VectorWorkerManager.resetListenerInstalled &&
|
||||||
|
typeof window !== "undefined"
|
||||||
|
) {
|
||||||
|
VectorWorkerManager.resetListenerInstalled = true;
|
||||||
|
window.addEventListener("betterseqta-reset-search-index", () => {
|
||||||
|
VectorWorkerManager.getInstance().terminate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return VectorWorkerManager.instance;
|
return VectorWorkerManager.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initWorker(): Promise<void> {
|
private async initWorker(): Promise<void> {
|
||||||
// Skip initialization if vector search is not supported (e.g., Firefox)
|
// Skip initialization if vector search is not supported (e.g., Firefox)
|
||||||
if (!isVectorSearchSupported()) {
|
if (!isVectorSearchSupported()) {
|
||||||
console.debug("[VectorWorkerManager] Vector search not supported - skipping worker initialization");
|
verboseDebug("[VectorWorkerManager] Vector search not supported - skipping worker initialization");
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
@@ -55,19 +69,19 @@ export class VectorWorkerManager {
|
|||||||
if (this.isInitialized) return Promise.resolve();
|
if (this.isInitialized) return Promise.resolve();
|
||||||
if (this.readyPromise) return this.readyPromise;
|
if (this.readyPromise) return this.readyPromise;
|
||||||
|
|
||||||
console.debug("Lazy-loading vector worker...");
|
verboseDebug("Lazy-loading vector worker...");
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
if (this.worker) {
|
if (this.worker) {
|
||||||
console.debug("Terminating existing worker before creating new one");
|
verboseDebug("Terminating existing worker before creating new one");
|
||||||
this.worker.terminate();
|
this.worker.terminate();
|
||||||
this.worker = null;
|
this.worker = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug("Creating new vector worker instance");
|
verboseDebug("Creating new vector worker instance");
|
||||||
this.worker = vectorWorker();
|
this.worker = vectorWorker();
|
||||||
|
|
||||||
console.log("Worker initialized", this.worker);
|
verboseLog("Worker initialized", this.worker);
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
console.error("Vector worker initialization timed out");
|
console.error("Vector worker initialization timed out");
|
||||||
@@ -78,18 +92,18 @@ export class VectorWorkerManager {
|
|||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
|
|
||||||
reject(new Error("Worker initialization timed out"));
|
reject(new Error("Worker initialization timed out"));
|
||||||
}, 10000);
|
}, 60000);
|
||||||
|
|
||||||
this.worker!.addEventListener("message", (e) => {
|
this.worker!.addEventListener("message", (e) => {
|
||||||
const { type, data } = e.data;
|
const { type, data } = e.data;
|
||||||
console.debug("Message from vector worker:", type, data);
|
verboseDebug("Message from vector worker:", type, data);
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "ready":
|
case "ready":
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
this.updateActivity(); // Start idle timer after initialization
|
this.updateActivity(); // Start idle timer after initialization
|
||||||
console.debug("Vector worker initialized and ready.");
|
verboseDebug("Vector worker initialized and ready.");
|
||||||
resolve();
|
resolve();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -145,12 +159,15 @@ export class VectorWorkerManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.worker!.postMessage({ type: "init" });
|
this.worker!.postMessage({
|
||||||
|
type: "init",
|
||||||
|
data: { ortWasmBase: getOrtWasmBaseUrl() },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetWorkerState() {
|
private resetWorkerState() {
|
||||||
console.debug("Resetting vector worker state");
|
verboseDebug("Resetting vector worker state");
|
||||||
if (this.worker) {
|
if (this.worker) {
|
||||||
this.worker.terminate();
|
this.worker.terminate();
|
||||||
this.worker = null;
|
this.worker = null;
|
||||||
@@ -176,7 +193,7 @@ export class VectorWorkerManager {
|
|||||||
if (this.vectorizationLockCount > 0) return;
|
if (this.vectorizationLockCount > 0) return;
|
||||||
if (this.streamingSession?.isActive) return;
|
if (this.streamingSession?.isActive) return;
|
||||||
if (!this.isInitialized) return;
|
if (!this.isInitialized) return;
|
||||||
console.debug("[VectorWorker] Auto-shutting down due to 2 minutes of inactivity");
|
verboseDebug("[VectorWorker] Auto-shutting down due to 2 minutes of inactivity");
|
||||||
this.resetWorkerState();
|
this.resetWorkerState();
|
||||||
}, 120000); // 2 minutes
|
}, 120000); // 2 minutes
|
||||||
}
|
}
|
||||||
@@ -208,7 +225,7 @@ export class VectorWorkerManager {
|
|||||||
this.unloadTimer = setTimeout(() => {
|
this.unloadTimer = setTimeout(() => {
|
||||||
if (this.vectorizationLockCount > 0) return;
|
if (this.vectorizationLockCount > 0) return;
|
||||||
if (!this.streamingSession?.isActive && this.isInitialized) {
|
if (!this.streamingSession?.isActive && this.isInitialized) {
|
||||||
console.debug("[VectorWorker] Auto-unloading after processing complete");
|
verboseDebug("[VectorWorker] Auto-unloading after processing complete");
|
||||||
this.resetWorkerState();
|
this.resetWorkerState();
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
@@ -295,7 +312,7 @@ export class VectorWorkerManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (uniqueItems.length !== items.length) {
|
if (uniqueItems.length !== items.length) {
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`Filtered out ${items.length - uniqueItems.length} duplicate items before processing`,
|
`Filtered out ${items.length - uniqueItems.length} duplicate items before processing`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -350,7 +367,7 @@ export class VectorWorkerManager {
|
|||||||
};
|
};
|
||||||
this.progressCallback = wrap;
|
this.progressCallback = wrap;
|
||||||
|
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`Sending ${uniqueItems.length} unique items to worker for processing.`,
|
`Sending ${uniqueItems.length} unique items to worker for processing.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -375,23 +392,23 @@ export class VectorWorkerManager {
|
|||||||
onProgress?: ProgressCallback,
|
onProgress?: ProgressCallback,
|
||||||
batchSize: number = 10,
|
batchSize: number = 10,
|
||||||
jobId?: string,
|
jobId?: string,
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
// Skip if vector search is not supported
|
// Skip if vector search is not supported
|
||||||
if (!isVectorSearchSupported()) {
|
if (!isVectorSearchSupported()) {
|
||||||
console.debug("[VectorWorker] Vector search not supported - skipping streaming session");
|
verboseDebug("[VectorWorker] Vector search not supported - skipping streaming session");
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
onProgress({
|
onProgress({
|
||||||
status: "complete",
|
status: "complete",
|
||||||
message: "Vector search not available - using text search only",
|
message: "Vector search not available - using text search only",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only initialize if we expect items to process
|
// Only initialize if we expect items to process
|
||||||
if (totalExpectedItems === 0) {
|
if (totalExpectedItems === 0) {
|
||||||
console.debug("[VectorWorker] No items expected, not starting streaming session");
|
verboseDebug("[VectorWorker] No items expected, not starting streaming session");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.ensureReady();
|
await this.ensureReady();
|
||||||
@@ -405,8 +422,8 @@ export class VectorWorkerManager {
|
|||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
} else {
|
} else {
|
||||||
console.debug(`Streaming session for job ${jobId} already active`);
|
verboseDebug(`Streaming session for job ${jobId} already active`);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +442,7 @@ export class VectorWorkerManager {
|
|||||||
lastActivityTime: Date.now(),
|
lastActivityTime: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`Starting streaming session for job ${jobId} with ${totalExpectedItems} items (batch size ${batchSize})`,
|
`Starting streaming session for job ${jobId} with ${totalExpectedItems} items (batch size ${batchSize})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -442,13 +459,20 @@ export class VectorWorkerManager {
|
|||||||
message: `Starting streaming vectorization for ${jobId}`,
|
message: `Starting streaming vectorization for ${jobId}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async streamItems(items: IndexItem[]): Promise<void> {
|
async streamItems(items: IndexItem[]): Promise<void> {
|
||||||
|
if (!isVectorSearchSupported()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.streamingSession?.isActive) {
|
if (!this.streamingSession?.isActive) {
|
||||||
throw new Error(
|
verboseDebug(
|
||||||
"No active streaming session. Call startStreamingSession first.",
|
"[VectorWorker] streamItems skipped — no active streaming session",
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueItems = items.filter((item, index, arr) => {
|
const uniqueItems = items.filter((item, index, arr) => {
|
||||||
@@ -456,7 +480,7 @@ export class VectorWorkerManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (uniqueItems.length !== items.length) {
|
if (uniqueItems.length !== items.length) {
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`[Streaming] Filtered out ${items.length - uniqueItems.length} duplicate items before streaming`,
|
`[Streaming] Filtered out ${items.length - uniqueItems.length} duplicate items before streaming`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -472,7 +496,7 @@ export class VectorWorkerManager {
|
|||||||
|
|
||||||
this.streamingSession.inactivityTimer = setTimeout(() => {
|
this.streamingSession.inactivityTimer = setTimeout(() => {
|
||||||
if (this.streamingSession?.isActive) {
|
if (this.streamingSession?.isActive) {
|
||||||
console.debug(
|
verboseDebug(
|
||||||
"[VectorWorker] Auto-ending streaming session due to inactivity",
|
"[VectorWorker] Auto-ending streaming session due to inactivity",
|
||||||
);
|
);
|
||||||
this.endStreamingSession();
|
this.endStreamingSession();
|
||||||
@@ -513,7 +537,7 @@ export class VectorWorkerManager {
|
|||||||
this.streamingSession.flushTimer = null;
|
this.streamingSession.flushTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug(
|
verboseDebug(
|
||||||
`Streaming batch of ${batch.length} items to worker (${this.streamingSession.totalSent}/${this.streamingSession.totalExpected})`,
|
`Streaming batch of ${batch.length} items to worker (${this.streamingSession.totalSent}/${this.streamingSession.totalExpected})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -549,7 +573,7 @@ export class VectorWorkerManager {
|
|||||||
type: "endStreaming",
|
type: "endStreaming",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.debug("Streaming session ended");
|
verboseDebug("Streaming session ended");
|
||||||
|
|
||||||
if (this.progressCallback) {
|
if (this.progressCallback) {
|
||||||
this.progressCallback({
|
this.progressCallback({
|
||||||
@@ -590,12 +614,12 @@ export class VectorWorkerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
terminate() {
|
terminate() {
|
||||||
console.debug("Terminating Vector Worker Manager...");
|
verboseDebug("Terminating Vector Worker Manager...");
|
||||||
this.resetWorkerState();
|
this.resetWorkerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetWorker(): Promise<void> {
|
async resetWorker(): Promise<void> {
|
||||||
console.debug("Resetting vector worker...");
|
verboseDebug("Resetting vector worker...");
|
||||||
|
|
||||||
if (this.streamingSession?.isActive) {
|
if (this.streamingSession?.isActive) {
|
||||||
await this.endStreamingSession();
|
await this.endStreamingSession();
|
||||||
@@ -605,6 +629,6 @@ export class VectorWorkerManager {
|
|||||||
|
|
||||||
this.worker!.postMessage({ type: "reset" });
|
this.worker!.postMessage({ type: "reset" });
|
||||||
|
|
||||||
console.debug("Reset command sent to worker");
|
verboseDebug("Reset command sent to worker");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** Worker-safe logging — no webextension-polyfill or SettingsState. */
|
||||||
|
|
||||||
|
export function verboseDebug(...args: unknown[]): void {
|
||||||
|
if (typeof console !== "undefined") console.debug(...args);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import {
|
||||||
|
assessmentDestinationKey,
|
||||||
|
dedupeCombinedResultsByCourseNav,
|
||||||
|
dedupeIndexItemsForSearch,
|
||||||
|
} from "./dedupeIndexItems";
|
||||||
|
import type { IndexItem } from "../indexing/types";
|
||||||
|
|
||||||
|
function makeItem(overrides: Partial<IndexItem> & Pick<IndexItem, "id">): IndexItem {
|
||||||
|
return {
|
||||||
|
text: "SAT 1: Differential Calculus",
|
||||||
|
category: "assignments",
|
||||||
|
content: "Subject: Mathematical Methods",
|
||||||
|
dateAdded: 1,
|
||||||
|
metadata: {},
|
||||||
|
actionId: "assessment",
|
||||||
|
renderComponentId: "assessment",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("assessmentDestinationKey", () => {
|
||||||
|
it("keys curated assignment items by assessment id", () => {
|
||||||
|
const item = makeItem({
|
||||||
|
id: "assignment-19748",
|
||||||
|
metadata: { assessmentId: 19748 },
|
||||||
|
});
|
||||||
|
expect(assessmentDestinationKey(item)).toBe("assessment:19748");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keys passive past items by entity id", () => {
|
||||||
|
const item = makeItem({
|
||||||
|
id: "passive-past-19748",
|
||||||
|
category: "past",
|
||||||
|
actionId: "passive",
|
||||||
|
renderComponentId: "passive",
|
||||||
|
metadata: {
|
||||||
|
entityId: 19748,
|
||||||
|
route: "/seqta/student/assessment/list/past",
|
||||||
|
source: "passive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(assessmentDestinationKey(item)).toBe("assessment:19748");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dedupeIndexItemsForSearch assessments", () => {
|
||||||
|
it("keeps curated assignment over passive past duplicate", () => {
|
||||||
|
const passive = makeItem({
|
||||||
|
id: "passive-past-19748",
|
||||||
|
category: "past",
|
||||||
|
actionId: "passive",
|
||||||
|
renderComponentId: "passive",
|
||||||
|
dateAdded: 2,
|
||||||
|
metadata: {
|
||||||
|
entityId: 19748,
|
||||||
|
route: "/seqta/student/assessment/list/past",
|
||||||
|
source: "passive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const curated = makeItem({
|
||||||
|
id: "assignment-19748",
|
||||||
|
category: "assignments",
|
||||||
|
actionId: "assessment",
|
||||||
|
renderComponentId: "assessment",
|
||||||
|
dateAdded: 1,
|
||||||
|
metadata: {
|
||||||
|
assessmentId: 19748,
|
||||||
|
programmeId: 3705,
|
||||||
|
metaclassId: 10337,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = dedupeIndexItemsForSearch([passive, curated]);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("assignment-19748");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves unrelated items", () => {
|
||||||
|
const course = makeItem({
|
||||||
|
id: "course-1",
|
||||||
|
category: "courses",
|
||||||
|
actionId: "course",
|
||||||
|
renderComponentId: "course",
|
||||||
|
metadata: { programmeId: 1, metaclassId: 2 },
|
||||||
|
});
|
||||||
|
const assignment = makeItem({
|
||||||
|
id: "assignment-99",
|
||||||
|
metadata: { assessmentId: 99 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = dedupeIndexItemsForSearch([course, assignment]);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dedupeCombinedResultsByCourseNav assessments", () => {
|
||||||
|
it("collapses hybrid results for the same assessment id", () => {
|
||||||
|
const passive = makeItem({
|
||||||
|
id: "passive-past-19748",
|
||||||
|
category: "past",
|
||||||
|
actionId: "passive",
|
||||||
|
renderComponentId: "passive",
|
||||||
|
metadata: {
|
||||||
|
entityId: 19748,
|
||||||
|
route: "/seqta/student/assessment/list/past",
|
||||||
|
source: "passive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const curated = makeItem({
|
||||||
|
id: "assignment-19748",
|
||||||
|
metadata: { assessmentId: 19748, programmeId: 3705, metaclassId: 10337 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = dedupeCombinedResultsByCourseNav([
|
||||||
|
{ type: "dynamic", id: passive.id, score: 0.9, item: passive },
|
||||||
|
{ type: "dynamic", id: curated.id, score: 0.8, item: curated },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].id).toBe("assignment-19748");
|
||||||
|
expect(results[0].score).toBe(0.9);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -29,31 +28,69 @@ function shouldDedupeAsSameCourseSPA(item: IndexItem): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function programmeMetaclassIds(
|
||||||
|
item: IndexItem,
|
||||||
|
): { programme?: number; metaclass?: number } {
|
||||||
|
const md = item.metadata ?? {};
|
||||||
|
return {
|
||||||
|
programme: toFiniteNumber(
|
||||||
|
md.programme ?? md.programmeId ?? md.programmeID,
|
||||||
|
),
|
||||||
|
metaclass: toFiniteNumber(
|
||||||
|
md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function courseDestinationKey(item: IndexItem): string | undefined {
|
export function courseDestinationKey(item: IndexItem): string | undefined {
|
||||||
if (!shouldDedupeAsSameCourseSPA(item)) return undefined;
|
if (!shouldDedupeAsSameCourseSPA(item)) return undefined;
|
||||||
const md = item.metadata ?? {};
|
const { programme, metaclass } = programmeMetaclassIds(item);
|
||||||
const programme = toFiniteNumber(
|
|
||||||
md.programme ?? md.programmeId ?? md.programmeID,
|
|
||||||
);
|
|
||||||
const metaclass = toFiniteNumber(
|
|
||||||
md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId,
|
|
||||||
);
|
|
||||||
if (programme === undefined || metaclass === undefined) return undefined;
|
if (programme === undefined || metaclass === undefined) return undefined;
|
||||||
return `course:${programme}:${metaclass}`;
|
return `course:${programme}:${metaclass}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldDedupeAsSameAssessmentSPA(item: IndexItem): boolean {
|
||||||
|
if (item.actionId === "assessment") return true;
|
||||||
|
if (item.actionId !== "passive") return false;
|
||||||
|
|
||||||
|
const md = item.metadata ?? {};
|
||||||
|
const route = typeof md.route === "string" ? md.route.toLowerCase() : "";
|
||||||
|
if (route.includes("/assessment/list/")) return true;
|
||||||
|
|
||||||
|
const cat = item.category?.toLowerCase();
|
||||||
|
return cat === "past" || cat === "upcoming";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assessmentDestinationKey(item: IndexItem): string | undefined {
|
||||||
|
if (!shouldDedupeAsSameAssessmentSPA(item)) return undefined;
|
||||||
|
const md = item.metadata ?? {};
|
||||||
|
const assessmentId = toFiniteNumber(
|
||||||
|
md.assessmentId ?? md.assessmentID ?? md.entityId,
|
||||||
|
);
|
||||||
|
if (assessmentId === undefined) return undefined;
|
||||||
|
return `assessment:${assessmentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchDedupeKey(item: IndexItem): string | undefined {
|
||||||
|
return courseDestinationKey(item) ?? assessmentDestinationKey(item);
|
||||||
|
}
|
||||||
|
|
||||||
function isPassiveLike(item: IndexItem): boolean {
|
function isPassiveLike(item: IndexItem): boolean {
|
||||||
return (
|
return (
|
||||||
item.actionId === "passive" || item.metadata?.source === "passive"
|
item.actionId === "passive" || item.metadata?.source === "passive"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasProgrammeMetaclass(item: IndexItem): boolean {
|
||||||
|
const { programme, metaclass } = programmeMetaclassIds(item);
|
||||||
|
return programme !== undefined && metaclass !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem {
|
function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem {
|
||||||
const aP = isPassiveLike(a);
|
const aP = isPassiveLike(a);
|
||||||
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")
|
||||||
@@ -65,28 +102,51 @@ function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem {
|
|||||||
return ad >= bd ? a : b;
|
return ad >= bd ? a : b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function pickBetterAssessmentDuplicate(a: IndexItem, b: IndexItem): IndexItem {
|
||||||
* Collapses multiple index rows that open the same course hash route
|
const aP = isPassiveLike(a);
|
||||||
* (e.g. `course` job + passive `/load/courses` capture) so search shows one hit.
|
const bP = isPassiveLike(b);
|
||||||
*/
|
if (aP && !bP) return b;
|
||||||
export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
|
if (!aP && bP) return a;
|
||||||
const winners = new Map<string, IndexItem>();
|
if (a.category === "assignments" && b.category !== "assignments") return a;
|
||||||
|
if (b.category === "assignments" && a.category !== "assignments") return b;
|
||||||
|
const aPm = hasProgrammeMetaclass(a);
|
||||||
|
const bPm = hasProgrammeMetaclass(b);
|
||||||
|
if (aPm && !bPm) return a;
|
||||||
|
if (!aPm && bPm) return b;
|
||||||
|
const ad = typeof a.dateAdded === "number" ? a.dateAdded : 0;
|
||||||
|
const bd = typeof b.dateAdded === "number" ? b.dateAdded : 0;
|
||||||
|
return ad >= bd ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBetterSearchDuplicate(
|
||||||
|
a: IndexItem,
|
||||||
|
b: IndexItem,
|
||||||
|
key: string,
|
||||||
|
): IndexItem {
|
||||||
|
return key.startsWith("assessment:")
|
||||||
|
? pickBetterAssessmentDuplicate(a, b)
|
||||||
|
: pickBetterCourseNavDuplicate(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeByCanonicalKey<T>(
|
||||||
|
items: T[],
|
||||||
|
getKey: (item: T) => string | undefined,
|
||||||
|
pickWinner: (a: T, b: T, key: string) => T,
|
||||||
|
): T[] {
|
||||||
|
const winners = new Map<string, T>();
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const key = courseDestinationKey(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 ? pickBetterCourseNavDuplicate(prev, item) : 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 = courseDestinationKey(item);
|
const key = getKey(item);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
out.push(item);
|
out.push(item);
|
||||||
continue;
|
continue;
|
||||||
@@ -99,53 +159,38 @@ export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dynamicCourseKey(row: CombinedResult): string | undefined {
|
export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
|
||||||
if (row.type !== "dynamic") return undefined;
|
return dedupeByCanonicalKey(items, searchDedupeKey, pickBetterSearchDuplicate);
|
||||||
return courseDestinationKey(row.item as IndexItem);
|
}
|
||||||
|
|
||||||
|
function dynamicSearchKey(row: CombinedResult): string | undefined {
|
||||||
|
if (row.type !== "dynamic") return undefined;
|
||||||
|
return searchDedupeKey(row.item as IndexItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCombinedDuplicates(
|
||||||
|
a: CombinedResult,
|
||||||
|
b: CombinedResult,
|
||||||
|
key: string,
|
||||||
|
): CombinedResult {
|
||||||
|
const aItem = a.item as IndexItem;
|
||||||
|
const bItem = b.item as IndexItem;
|
||||||
|
const winnerItem = pickBetterSearchDuplicate(aItem, bItem, key);
|
||||||
|
const envelope = winnerItem.id === aItem.id ? a : b;
|
||||||
|
return {
|
||||||
|
...envelope,
|
||||||
|
score: Math.max(a.score, b.score),
|
||||||
|
id: winnerItem.id,
|
||||||
|
item: winnerItem,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Final pass after hybrid expansion: vector-only recall can still surface a
|
|
||||||
* second row for the same `/courses/P:M` SPA route using a stale passive id.
|
|
||||||
*/
|
|
||||||
export function dedupeCombinedResultsByCourseNav(
|
export function dedupeCombinedResultsByCourseNav(
|
||||||
results: CombinedResult[],
|
results: CombinedResult[],
|
||||||
): CombinedResult[] {
|
): CombinedResult[] {
|
||||||
const best = new Map<string, CombinedResult>();
|
return dedupeByCanonicalKey(
|
||||||
|
results,
|
||||||
for (const r of results) {
|
dynamicSearchKey,
|
||||||
const key = dynamicCourseKey(r);
|
mergeCombinedDuplicates,
|
||||||
if (!key) continue;
|
);
|
||||||
const prev = best.get(key);
|
|
||||||
if (!prev) {
|
|
||||||
best.set(key, r);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const aItem = prev.item as IndexItem;
|
|
||||||
const bItem = r.item as IndexItem;
|
|
||||||
const winnerItem = pickBetterCourseNavDuplicate(aItem, bItem);
|
|
||||||
const envelope = winnerItem.id === aItem.id ? prev : r;
|
|
||||||
best.set(key, {
|
|
||||||
...envelope,
|
|
||||||
score: Math.max(prev.score, r.score),
|
|
||||||
id: winnerItem.id,
|
|
||||||
item: winnerItem,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const seenCanon = new Set<string>();
|
|
||||||
const out: CombinedResult[] = [];
|
|
||||||
|
|
||||||
for (const r of results) {
|
|
||||||
const key = dynamicCourseKey(r);
|
|
||||||
if (!key) {
|
|
||||||
out.push(r);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (seenCanon.has(key)) continue;
|
|
||||||
seenCanon.add(key);
|
|
||||||
out.push(best.get(key)!);
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
isStrongLexicalMatch,
|
isStrongLexicalMatch,
|
||||||
STRONG_LEXICAL_THRESHOLD,
|
STRONG_LEXICAL_THRESHOLD,
|
||||||
} from "./lexicalMatch";
|
} from "./lexicalMatch";
|
||||||
|
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 {
|
||||||
@@ -62,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() });
|
||||||
}
|
}
|
||||||
@@ -91,14 +85,11 @@ function setCachedResults(query: string, results: CombinedResult[]) {
|
|||||||
*/
|
*/
|
||||||
export function clearSearchCache(): void {
|
export function clearSearchCache(): void {
|
||||||
searchCache.clear();
|
searchCache.clear();
|
||||||
console.debug("[Search] Search result cache cleared");
|
verboseDebug("[Search] Search result cache cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for cache clear events (e.g., on extension update)
|
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. */
|
||||||
|
|||||||
@@ -2,31 +2,31 @@ import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
|
|||||||
import type { IndexItem } from "../../indexing/types";
|
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 { verboseDebug } 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()) {
|
||||||
console.debug("[Vector Search] Vector search not supported in Firefox - using text search only");
|
verboseDebug("[Vector Search] Vector search not supported in Firefox - using text search only");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initializationAttempted) {
|
if (initializationAttempted) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
initializationAttempted = true;
|
initializationAttempted = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await ensureTransformersEnv();
|
||||||
await initializeModel();
|
await initializeModel();
|
||||||
vectorIndex = new EmbeddingIndex([]);
|
vectorIndex = new EmbeddingIndex([]);
|
||||||
vectorIndex.preloadIndexedDB();
|
vectorIndex.preloadIndexedDB();
|
||||||
console.debug("[Vector Search] Initialized successfully");
|
verboseDebug("[Vector Search] Initialized successfully");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[Vector Search] Failed to initialize vector search (will use text search only):", e);
|
console.warn("[Vector Search] Failed to initialize vector search (will use text search only):", e);
|
||||||
initializationFailed = true;
|
initializationFailed = true;
|
||||||
@@ -38,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();
|
||||||
console.debug("[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);
|
||||||
@@ -110,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 [];
|
||||||
@@ -130,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?: readonly FuseResultMatch[]) {
|
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,12 +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";
|
||||||
|
|
||||||
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;
|
||||||
@@ -16,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);
|
||||||
@@ -28,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);
|
||||||
@@ -42,36 +39,21 @@ 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) {
|
||||||
console.debug(
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
verboseLog(
|
||||||
`[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, resetting search index...`,
|
`[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, resetting search index...`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -79,57 +61,40 @@ export async function checkAndHandleUpdate(): Promise<boolean> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await resetSearchIndexes();
|
await resetSearchIndexes();
|
||||||
console.log(
|
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")) {
|
|
||||||
console.debug("[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")) {
|
|
||||||
console.debug("[Version Check] Could not clear embedding cache:", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
console.debug("[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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,21 @@ import type { Plugin } from "@/plugins/core/types";
|
|||||||
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
|
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
import { processMenuItemNode } from "@/seqta/utils/sidebarMenuIcons";
|
import {
|
||||||
|
ensureAnalyticsMenuOrder,
|
||||||
|
insertMenuItemAfterKey,
|
||||||
|
processMenuItemNode,
|
||||||
|
} from "@/seqta/utils/sidebarMenuIcons";
|
||||||
|
import {
|
||||||
|
ChangeMenuItemPositions,
|
||||||
|
MenuOptionsOpen,
|
||||||
|
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
||||||
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
import { 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<{}> = {
|
||||||
@@ -17,7 +26,7 @@ const gradeAnalyticsPlugin: Plugin<{}> = {
|
|||||||
"Adds an analytics page with grade trends, distribution charts, and assessment history",
|
"Adds an analytics page with grade trends, distribution charts, and assessment history",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
settings: {},
|
settings: {},
|
||||||
disableToggle: false,
|
disableToggle: true,
|
||||||
styles,
|
styles,
|
||||||
|
|
||||||
run: async () => {
|
run: async () => {
|
||||||
@@ -36,37 +45,40 @@ 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 homeButton = document.getElementById("homebutton");
|
const syncAnalyticsMenu = () => {
|
||||||
if (homeButton?.parentElement === menuList) {
|
insertMenuItemAfterKey(menuList, analyticsItem, "courses");
|
||||||
homeButton.insertAdjacentElement("afterend", analyticsItem);
|
ensureAnalyticsMenuOrder();
|
||||||
} else {
|
if (settingsState.menuorder.length > 0) {
|
||||||
menuList.insertBefore(analyticsItem, menuList.firstChild);
|
ChangeMenuItemPositions(settingsState.menuorder);
|
||||||
}
|
}
|
||||||
|
processMenuItemNode(analyticsItem);
|
||||||
|
applyMenuItemVisibility();
|
||||||
|
};
|
||||||
|
|
||||||
processMenuItemNode(analyticsItem);
|
syncAnalyticsMenu();
|
||||||
|
|
||||||
const menuObserver = new MutationObserver(() => {
|
const menuObserver = new MutationObserver(() => {
|
||||||
if (!menuList.contains(analyticsItem)) {
|
if (MenuOptionsOpen || menuList.contains(analyticsItem)) return;
|
||||||
if (homeButton?.parentElement === menuList) {
|
syncAnalyticsMenu();
|
||||||
homeButton.insertAdjacentElement("afterend", analyticsItem);
|
|
||||||
} else {
|
|
||||||
menuList.insertBefore(analyticsItem, menuList.firstChild);
|
|
||||||
}
|
|
||||||
processMenuItemNode(analyticsItem);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
menuObserver.observe(menuList, { childList: true });
|
menuObserver.observe(menuList, { childList: true });
|
||||||
|
|
||||||
const onClick = (e: Event) => {
|
analyticsItem.addEventListener("click", (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
MenuOptionsOpen ||
|
||||||
|
analyticsItem.classList.contains("draggable") ||
|
||||||
|
target.closest(".onoffswitch, .editmenuoption-container")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.history.pushState({}, "", "/#?page=/analytics");
|
window.history.pushState({}, "", "/#?page=/analytics");
|
||||||
void loadAnalyticsPage();
|
void loadAnalyticsPage();
|
||||||
};
|
});
|
||||||
analyticsItem.addEventListener("click", onClick);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
menuObserver.disconnect();
|
menuObserver.disconnect();
|
||||||
analyticsItem.removeEventListener("click", onClick);
|
|
||||||
analyticsItem.remove();
|
analyticsItem.remove();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const gradeAnalyticsPluginLazy = defineLazyPlugin({
|
|||||||
"Grade trends, distribution charts, and assessment history synced from SEQTA",
|
"Grade trends, distribution charts, and assessment history synced from SEQTA",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
settings,
|
settings,
|
||||||
disableToggle: false,
|
disableToggle: true,
|
||||||
defaultEnabled: true,
|
defaultEnabled: true,
|
||||||
styles,
|
styles,
|
||||||
loader: () => import("./core/index"),
|
loader: () => import("./core/index"),
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
--bsplus-analytics-radius: 16px;
|
--bsplus-analytics-radius: 16px;
|
||||||
--bsplus-analytics-radius-sm: 12px;
|
--bsplus-analytics-radius-sm: 12px;
|
||||||
--bsplus-analytics-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
--bsplus-analytics-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--bsplus-analytics-surface: var(--background-primary, #ffffff);
|
--bsplus-analytics-surface: var(--theme-primary, var(--background-primary, #ffffff));
|
||||||
--bsplus-analytics-surface-2: var(--background-secondary, #f8fafc);
|
--bsplus-analytics-surface-2: var(--theme-secondary, var(--background-secondary, #f8fafc));
|
||||||
--bsplus-analytics-text: var(--text-primary, #1a1a1a);
|
--bsplus-analytics-text: var(--text-primary, #1a1a1a);
|
||||||
--bsplus-analytics-muted: color-mix(
|
--bsplus-analytics-muted: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
@@ -937,7 +937,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bsplus-analytics-chart-cell > :global(.bsplus-analytics-card) {
|
.bsplus-analytics-chart-cell > .bsplus-analytics-card {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
|||||||
import { mount, unmount } from "svelte";
|
import { mount, unmount } from "svelte";
|
||||||
import GradeAnalyticsPage from "./GradeAnalyticsPage.svelte";
|
import GradeAnalyticsPage from "./GradeAnalyticsPage.svelte";
|
||||||
import { buildContrastAccentPalette } from "./utils/accentColor";
|
import { buildContrastAccentPalette } from "./utils/accentColor";
|
||||||
|
import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor";
|
||||||
|
|
||||||
type ThemeSettingKey =
|
type ThemeSettingKey =
|
||||||
| "selectedColor"
|
| "selectedColor"
|
||||||
@@ -62,26 +63,6 @@ const ACCENT_CSS_VARS = [
|
|||||||
"--colour-betterseqta-blue",
|
"--colour-betterseqta-blue",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Resolve a solid colour for charts (gradients → first stop). */
|
|
||||||
function extractSolidColor(value: string): string | null {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed || trimmed === "initial") return null;
|
|
||||||
if (
|
|
||||||
trimmed.startsWith("#") ||
|
|
||||||
trimmed.startsWith("rgb") ||
|
|
||||||
trimmed.startsWith("hsl")
|
|
||||||
) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
if (trimmed.includes("gradient")) {
|
|
||||||
const match = trimmed.match(
|
|
||||||
/#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/i,
|
|
||||||
);
|
|
||||||
return match?.[0] ?? null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const THEME_ACCENT_OVERRIDES: Record<string, string> = {
|
const THEME_ACCENT_OVERRIDES: Record<string, string> = {
|
||||||
"bb0aaf40-55ef-40f7-bc64-93b67ef96c01": "#4ade80",
|
"bb0aaf40-55ef-40f7-bc64-93b67ef96c01": "#4ade80",
|
||||||
};
|
};
|
||||||
@@ -108,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();
|
||||||
@@ -132,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() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Color from "color";
|
import Color from "color";
|
||||||
|
import { parseCssColor } from "@/seqta/ui/colors/parseCssColor";
|
||||||
|
|
||||||
export type ContrastAccentPalette = {
|
export type ContrastAccentPalette = {
|
||||||
accent: string;
|
accent: string;
|
||||||
@@ -52,8 +53,8 @@ export function buildContrastAccentPalette(
|
|||||||
accentRaw: string,
|
accentRaw: string,
|
||||||
backgroundRaw: string,
|
backgroundRaw: string,
|
||||||
): ContrastAccentPalette {
|
): ContrastAccentPalette {
|
||||||
const accent = Color(accentRaw);
|
const accent = parseCssColor(accentRaw);
|
||||||
const background = Color(backgroundRaw);
|
const background = parseCssColor(backgroundRaw, "#ffffff");
|
||||||
const isDark = background.isDark();
|
const isDark = background.isDark();
|
||||||
|
|
||||||
const { h, s } = accent.hsl().object();
|
const { h, s } = accent.hsl().object();
|
||||||
|
|||||||
@@ -388,8 +388,8 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
background: var(--background-primary, #fff);
|
background: var(--theme-primary, var(--background-primary, #fff));
|
||||||
border: 1px solid var(--background-secondary, #e0e0e0);
|
border: 1px solid var(--theme-secondary, var(--background-secondary, #e0e0e0));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
|
||||||
|
|
||||||
|
type RawNotification = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface ArchivedNotification {
|
||||||
|
notificationID: number;
|
||||||
|
firstSavedAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
raw: RawNotification;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArchiveMap = Record<string, ArchivedNotification>;
|
||||||
|
export type ArchivesByUser = Record<string, ArchiveMap>;
|
||||||
|
|
||||||
|
export async function resolveNotificationUserKey(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const info = await getUserInfo();
|
||||||
|
const id = info?.id ?? info?.personUUID ?? info?.username;
|
||||||
|
if (id == null || id === "") return null;
|
||||||
|
const label =
|
||||||
|
info?.displayName ?? info?.name ?? info?.username ?? String(id);
|
||||||
|
return `${location.hostname}:${id}:${String(label).slice(0, 64)}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllNotifications(): Promise<RawNotification[]> {
|
||||||
|
const res = await fetch(`${location.origin}/seqta/student/heartbeat?`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
timestamp: "1970-01-01 00:00:00.0",
|
||||||
|
hash: "#?page=/notifications",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
notifications?: RawNotification[];
|
||||||
|
payload?: { notifications?: RawNotification[] };
|
||||||
|
};
|
||||||
|
const list = json.notifications ?? json.payload?.notifications;
|
||||||
|
return Array.isArray(list) ? list : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveTimestamp(
|
||||||
|
item: ArchivedNotification & { timestamp?: string },
|
||||||
|
): number {
|
||||||
|
const ms = new Date(
|
||||||
|
String(item.raw?.timestamp ?? item.timestamp ?? 0),
|
||||||
|
).getTime();
|
||||||
|
return Number.isNaN(ms) ? 0 : ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeNotificationsIntoArchive(
|
||||||
|
existing: ArchiveMap,
|
||||||
|
notifications: RawNotification[],
|
||||||
|
): { archive: ArchiveMap; changed: boolean } {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
let changed = false;
|
||||||
|
const archive = { ...existing };
|
||||||
|
|
||||||
|
for (const raw of notifications) {
|
||||||
|
const notificationID = Number(raw.notificationID);
|
||||||
|
if (!notificationID || Number.isNaN(notificationID)) continue;
|
||||||
|
|
||||||
|
const key = String(notificationID);
|
||||||
|
const prev = archive[key];
|
||||||
|
archive[key] = prev
|
||||||
|
? { ...prev, lastSeenAt: now, raw: { ...prev.raw, ...raw } }
|
||||||
|
: { notificationID, firstSavedAt: now, lastSeenAt: now, raw: { ...raw } };
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { archive, changed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listArchivedNotifications(
|
||||||
|
archive: ArchiveMap,
|
||||||
|
): ArchivedNotification[] {
|
||||||
|
return Object.values(archive).sort(
|
||||||
|
(a, b) => archiveTimestamp(b) - archiveTimestamp(a),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function archivedToApiNotification(
|
||||||
|
item: ArchivedNotification,
|
||||||
|
): RawNotification {
|
||||||
|
return { ...item.raw, notificationID: item.notificationID };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user