diff --git a/.github/actions/build-extension/action.yml b/.github/actions/build-extension/action.yml index c5368033..57918b56 100644 --- a/.github/actions/build-extension/action.yml +++ b/.github/actions/build-extension/action.yml @@ -33,14 +33,8 @@ outputs: runs: using: composite steps: - - name: Use Node.js 20.x - uses: actions/setup-node@v4 - with: - node-version: 20.x - - - name: Install dependencies - shell: bash - run: npm install --legacy-peer-deps + - name: Setup Node and dependencies + uses: ./.github/actions/setup-node-deps - name: Read version id: version @@ -62,14 +56,4 @@ runs: env: UPDATE_CHANNEL: ${{ inputs.update_channel }} BUILD_LABEL: ${{ inputs.build_label }} - run: | - 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" + run: node scripts/package-extension-zips.mjs "${{ steps.version.outputs.version }}" diff --git a/.github/actions/run-lint/action.yml b/.github/actions/run-lint/action.yml new file mode 100644 index 00000000..6f9e9ae4 --- /dev/null +++ b/.github/actions/run-lint/action.yml @@ -0,0 +1,11 @@ +name: Run lint +description: Run ESLint on src. + +runs: + using: composite + steps: + - name: Lint + shell: bash + run: npm run lint + env: + ESLINT_USE_FLAT_CONFIG: "false" diff --git a/.github/actions/run-smoke-tests/action.yml b/.github/actions/run-smoke-tests/action.yml new file mode 100644 index 00000000..4723f5ce --- /dev/null +++ b/.github/actions/run-smoke-tests/action.yml @@ -0,0 +1,9 @@ +name: Run smoke tests +description: Verify built extension dist output. + +runs: + using: composite + steps: + - name: Smoke tests + shell: bash + run: npm run test:smoke diff --git a/.github/actions/run-unit-tests/action.yml b/.github/actions/run-unit-tests/action.yml new file mode 100644 index 00000000..6c92a48a --- /dev/null +++ b/.github/actions/run-unit-tests/action.yml @@ -0,0 +1,9 @@ +name: Run unit tests +description: Run Jest unit tests. + +runs: + using: composite + steps: + - name: Unit tests + shell: bash + run: npm run test:unit diff --git a/.github/actions/setup-node-deps/action.yml b/.github/actions/setup-node-deps/action.yml new file mode 100644 index 00000000..c1e247c6 --- /dev/null +++ b/.github/actions/setup-node-deps/action.yml @@ -0,0 +1,14 @@ +name: Setup Node and dependencies +description: Install Node.js 22.x and npm dependencies. + +runs: + using: composite + steps: + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: 22.x + + - name: Install dependencies + shell: bash + run: npm install --legacy-peer-deps diff --git a/.github/workflows/mvp.yml b/.github/workflows/mvp.yml deleted file mode 100644 index c461ef9f..00000000 --- a/.github/workflows/mvp.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: NodeJS Build - -on: - push: - branches: ["main"] - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [20.x] - - steps: - - uses: actions/checkout@v4 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - - name: Build - run: | - npm install --legacy-peer-deps - npm run build - - - name: Zip dist folder - run: | - zip -r dist.zip dist - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: dist-zip - path: dist.zip diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5974de2c..cc7dcb78 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,11 +1,18 @@ # Nightly release workflow — updates the same "nightly" release with fresh builds from main. # 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 on: 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: permissions: @@ -13,19 +20,41 @@ permissions: env: NIGHTLY_TAG: nightly + GH_TOKEN: ${{ github.token }} jobs: nightly: - runs-on: ubuntu-latest + runs-on: windows-latest + defaults: + run: + shell: bash 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 + if: steps.time_check.outputs.proceed == 'true' - name: Set 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 id: build + if: steps.time_check.outputs.proceed == 'true' uses: ./.github/actions/build-extension with: gh_release_update_check: "true" @@ -34,6 +63,7 @@ jobs: release_repo: ${{ github.repository }} - name: Ensure nightly release exists + if: steps.time_check.outputs.proceed == 'true' run: | TITLE="Nightly (${{ steps.build_date.outputs.date }})" if ! gh release view "${{ env.NIGHTLY_TAG }}" 2>/dev/null; then @@ -46,6 +76,7 @@ jobs: fi - name: Upload nightly assets + if: steps.time_check.outputs.proceed == 'true' run: | gh release upload "${{ env.NIGHTLY_TAG }}" \ --clobber \ diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index dbc5ab58..fbf2ce76 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -1,35 +1,63 @@ -name: PR CI +name: CI on: pull_request: branches: ["main"] + push: + branches: ["main"] jobs: - ci: - runs-on: ubuntu-latest + lint: + # 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: - uses: actions/checkout@v4 - - name: Use Node.js 20.x - uses: actions/setup-node@v4 - with: - node-version: 20.x + - name: Setup Node and dependencies + uses: ./.github/actions/setup-node-deps - - name: Install dependencies - run: npm install --legacy-peer-deps + - name: Run lint + uses: ./.github/actions/run-lint - - name: Lint - run: npm run lint - env: - ESLINT_USE_FLAT_CONFIG: "false" + unit-tests: + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 - - name: Unit tests - run: npm test + - name: Setup Node and dependencies + uses: ./.github/actions/setup-node-deps + + - name: Run unit tests + uses: ./.github/actions/run-unit-tests + + build-and-smoke: + needs: [lint, unit-tests] + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 - name: Build extension + id: build uses: ./.github/actions/build-extension with: gh_release_update_check: "false" - - name: Smoke tests - run: npm run test:smoke + - name: Upload extension zips + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10596a81..bbf2646f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,9 @@ on: permissions: contents: write +env: + GH_TOKEN: ${{ github.token }} + jobs: release: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 302d14da..311312a3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ bun.lock # PDF.js extension assets (copied by postinstall from pdfjs-dist) src/public/resources/pdfjs/pdf.worker.min.mjs src/public/resources/pdfjs/pdf.legacy.min.mjs +# ONNX Runtime WASM assets (copied by postinstall from @huggingface/transformers) +src/public/resources/ort/ort-wasm-simd-threaded.jsep.mjs +src/public/resources/ort/ort-wasm-simd-threaded.jsep.wasm # Build extension.zip diff --git a/jest.config.js b/jest.config.js index 428e2515..a63ca1c1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,11 +8,17 @@ export default { ], transform: { '^.+\\.ts$': 'ts-jest', + '^.+\\.js$': ['ts-jest', { tsconfig: { allowJs: true } }], }, + transformIgnorePatterns: [ + '/node_modules/(?!(color|color-string|color-convert|color-name)/)', + ], moduleNameMapper: { '^@/(.*)$': '/src/$1', + '^color$': '/src/test/mocks/color.ts', '^webextension-polyfill$': '/src/test/mocks/webextension-polyfill.ts', }, + setupFilesAfterEnv: ['/src/test/jest.setup.ts'], moduleFileExtensions: ['ts', 'js', 'json'], collectCoverageFrom: [ 'src/**/*.ts', diff --git a/lib/closePlugin.ts b/lib/closePlugin.ts index 7a043fd3..9494f298 100644 --- a/lib/closePlugin.ts +++ b/lib/closePlugin.ts @@ -1,58 +1,20 @@ // ref: https://stackoverflow.com/a/76920975 import type { Plugin } from "vite"; -/** - * Creates a Vite plugin designed to gracefully handle the conclusion of the build process. - * This plugin utilizes the `buildEnd` and `closeBundle` hooks provided by Vite. - * It checks for errors at the end of the build: - * - If an error occurred during the build (`buildEnd` hook receives an error), it logs the error - * and explicitly exits the Node.js process with a status code of 1 (indicating failure). - * - If the build completes without errors and the bundle is successfully generated - * (`closeBundle` hook is called), it logs a success message and exits the process - * with a status code of 0 (indicating success). - * This explicit process exiting can be useful in CI/CD environments or scripts that - * rely on the process status code to determine the build outcome. - * The core logic for using these hooks to exit the process is inspired by - * a solution found on StackOverflow (https://stackoverflow.com/a/76920975). - * - * @returns {Plugin} A Vite plugin object configured with `name`, `buildEnd`, and `closeBundle` hooks. - */ +/** Exit with code 1 on build failure; do not exit on success (multi-target builds). */ export default function ClosePlugin(): Plugin { return { - /** - * The unique name of this Vite plugin. This name is used by Vite for identification - * purposes and will appear in warnings, errors, and logs related to this plugin. - * @type {string} - */ - name: "ClosePlugin", // required, will show up in warnings and errors - - /** - * A Vite hook that is called when the build process has finished, regardless of - * whether it was successful or encountered an error. - * - * @param {Error} [error] An optional error object. If the build failed, this parameter - * will contain the error that occurred. If the build was successful, - * this parameter will be undefined or null. - */ + name: "ClosePlugin", buildEnd(error) { if (error) { - console.error("Error bundling"); - console.error(error); - process.exit(1); // Exit with status 1 indicating an error + console.error("Error bundling", error); + process.exit(1); } else { - console.log("Build ended"); // Log successful completion of the build phase + console.log("Build ended"); } }, - - /** - * A Vite hook that is called after the `buildEnd` hook, but only if the build - * was successful (i.e., no errors were passed to `buildEnd`) and all output - * files have been generated and written to disk. This signifies the successful - * completion of the entire bundling process. - */ closeBundle() { - console.log("Bundle closed"); // Log successful closure of the bundle - process.exit(0); // Exit with status 0 indicating a successful build + console.log("Bundle closed"); }, }; } diff --git a/lib/extensionChunkUrls.ts b/lib/extensionChunkUrls.ts new file mode 100644 index 00000000..0cc492ea --- /dev/null +++ b/lib/extensionChunkUrls.ts @@ -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)})` }; + } + }, + }, + }; + }, + }; +} diff --git a/lib/inlineWorker.ts b/lib/inlineWorker.ts index a877a05b..cfd846b5 100644 --- a/lib/inlineWorker.ts +++ b/lib/inlineWorker.ts @@ -1,70 +1,32 @@ -// vite-plugin-inline-worker-dev.ts -// vite-plugin-inline-worker-dev.ts import { Plugin } from "vite"; -import fs from "fs/promises"; import { build } from "esbuild"; -/** - * Creates a Vite plugin designed for bundling and inlining web worker scripts during development. - * This plugin specifically targets module imports that include a `?inlineWorker` query parameter. - * When such an import is encountered, the plugin bundles the worker script using `esbuild` - * and then generates JavaScript code that inlines this bundled worker as a Blob, - * creating the worker instance via `URL.createObjectURL()`. - * The name "vite:inline-worker-dev" suggests it's primarily intended for development builds. - * - * @returns {Plugin} A Vite plugin object with `name` and `load` properties. - */ +/** Bundle worker entry points imported with `?inlineWorker` as Blob-backed Workers in dev. */ export default function InlineWorkerDevPlugin(): Plugin { return { - /** - * The unique name of this Vite plugin. - * @type {string} - */ name: "vite:inline-worker-dev", - /** - * The Vite hook responsible for loading and transforming modules. - * This function intercepts modules imported with `?inlineWorker`. - * For such modules, it bundles the worker script and returns JavaScript code - * that, when executed, will create an instance of this worker from an inlined Blob. - * - * @async - * @param {string} id The path or ID of the module Vite is attempting to load, - * potentially including query parameters (e.g., "/path/to/worker.ts?inlineWorker"). - * @returns {Promise} A promise that resolves to: - * - `null` if the module ID does not include `?inlineWorker`. - * - A string of JavaScript code if the module is an inline worker. - * This code will define a default export function (e.g., `InlineWorker`) - * that, when called, creates and returns a new `Worker` instance - * from the bundled and inlined worker script. - */ async load(id) { - if (id.includes("?inlineWorker")) { - const [cleanPath] = id.split("?"); - // Note: Original code had `await fs.readFile(cleanPath, "utf-8");` but `code` wasn't used. - // `esbuild` directly takes `cleanPath` as an entry point. - const result = await build({ - entryPoints: [cleanPath], // 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 - }); + if (!id.includes("?inlineWorker")) return null; - const workerCode = result.outputFiles[0].text; + const [cleanPath] = id.split("?"); + const result = await build({ + entryPoints: [cleanPath], + bundle: true, + write: false, + platform: "browser", + format: "iife", + target: "esnext", + external: ["webextension-polyfill"], + }); - // Construct JavaScript code that will create the worker from a Blob. - // This code is what gets returned to Vite and replaces the original import. - const workerBlobCode = ` - const code = ${JSON.stringify(workerCode)}; - export default function InlineWorker() { - const blob = new Blob([code], { type: 'application/javascript' }); - return new Worker(URL.createObjectURL(blob), { type: 'module' }); - } - `; - return workerBlobCode; - } - return null; // Let Vite handle other modules normally + const workerCode = result.outputFiles[0].text; + return ` + const code = ${JSON.stringify(workerCode)}; + export default function InlineWorker() { + const blob = new Blob([code], { type: 'application/javascript' }); + return new Worker(URL.createObjectURL(blob), { type: 'module' }); + } + `; }, }; } diff --git a/lib/publish.js b/lib/publish.js index a6263bf3..aba03a18 100644 --- a/lib/publish.js +++ b/lib/publish.js @@ -11,7 +11,7 @@ * or `node lib/publish.js --b firefox` */ -const glob = require("glob"); +const { globSync } = require("glob"); const semver = require("semver"); const { execSync } = require("child_process"); const path = require("path"); @@ -98,7 +98,7 @@ function getLatestFiles(browser) { const pattern = `dist/betterseqtaplus@*-*${browser}.zip`; console.log("Glob pattern:", pattern); - const files = glob.sync(pattern); + const files = globSync(pattern); console.log("Files found for browser", browser, ":", files); if (files.length === 0) { diff --git a/package.json b/package.json index f3e12894..fda71559 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,17 @@ { "name": "betterseqtaplus", - "version": "3.7.2", + "version": "3.7.3", "type": "module", "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", "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", "dev": "cross-env MODE=chrome vite dev", "dev:firefox": "cross-env MODE=firefox vite build --watch", "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:firefox": "cross-env MODE=firefox 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", "dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg", "lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint \"src/**/*.{js,ts}\"", - "test": "jest", + "test": "npm run test:unit", + "test:unit": "jest", "test:smoke": "node scripts/smoke-test.mjs", + "test:ci": "npm run test:unit && npm run build && npm run test:smoke", "release": "gh release create $npm_package_version --repo BetterSEQTA/BetterSEQTA-Plus ./dist/*.zip --generate-notes", "publish": "bun lib/publish.js --b", "zip": "bedframe zip" @@ -55,8 +58,9 @@ "dependency-cruiser": "^17.0.1", "eslint": "^9.33.0", "eslint-plugin-import": "^2.31.0", - "glob": "^11.0.1", + "glob": "^13.0.6", "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", "mime-types": "^3.0.1", "prettier": "^3.5.3", "process": "^0.11.10", @@ -97,6 +101,7 @@ "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "dompurify": "^3.2.4", + "@huggingface/transformers": "^3.8.1", "embeddia": "^1.3.0", "embla-carousel-autoplay": "^8.5.2", "embla-carousel-svelte": "^8.5.2", @@ -124,5 +129,13 @@ "uuid": "^11.1.0", "vite": "^6.2.1", "webextension-polyfill": "^0.12.0" + }, + "overrides": { + "glob": "^13.0.6" + }, + "pnpm": { + "overrides": { + "glob": "^13.0.6" + } } } diff --git a/scripts/compile-layerchart-vendor.mjs b/scripts/compile-layerchart-vendor.mjs new file mode 100644 index 00000000..c3f84ea2 --- /dev/null +++ b/scripts/compile-layerchart-vendor.mjs @@ -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(" /\.(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`); diff --git a/scripts/copy-ort-wasm-assets.mjs b/scripts/copy-ort-wasm-assets.mjs new file mode 100644 index 00000000..9fc172b9 --- /dev/null +++ b/scripts/copy-ort-wasm-assets.mjs @@ -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)); +} diff --git a/scripts/package-extension-zips.mjs b/scripts/package-extension-zips.mjs new file mode 100644 index 00000000..015d96fc --- /dev/null +++ b/scripts/package-extension-zips.mjs @@ -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`); +} diff --git a/src/SEQTA.ts b/src/SEQTA.ts index 60a4b80a..2cbb3239 100644 --- a/src/SEQTA.ts +++ b/src/SEQTA.ts @@ -10,6 +10,9 @@ import { init as Monofile } from "@/plugins/monofile"; import { main } from "@/seqta/main"; import { delay } from "./seqta/utils/delay"; 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() { browser.runtime.onMessage.addListener((request, _sender, sendResponse) => { @@ -46,6 +49,10 @@ if (document.childNodes[1]) { document.childNodes[1].textContent?.includes( "Copyright (c) SEQTA Software", ) ?? false; + if (hasSEQTAText) { + installSeqtaMenuColourPatch(); + installThemeImagePagePatch(); + } init(); } @@ -57,7 +64,7 @@ async function init() { !IsSEQTAPage ) { IsSEQTAPage = true; - console.info("[BetterSEQTA+] Verified SEQTA Page"); + verboseInfo("[BetterSEQTA+] Verified SEQTA Page"); if (typeof window !== "undefined" && window === window.top) { void browser.runtime.sendMessage({ type: "cloudSettingsPoll" }).catch(() => {}); @@ -96,6 +103,7 @@ async function init() { try { await initializeSettingsState(); + initVerboseLogging(); if (typeof settingsState.onoff === "undefined") { await browser.runtime.sendMessage({ type: "setDefaultStorage" }); @@ -115,7 +123,7 @@ async function init() { initializeHideSensitiveToggle(); } - console.info( + verboseInfo( "[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.", ); } catch (error) { diff --git a/src/background.ts b/src/background.ts index 6cd7f938..e4110da5 100644 --- a/src/background.ts +++ b/src/background.ts @@ -12,6 +12,7 @@ import { runCloudSettingsPoll, withSuppressedCloudAutoUpload, } from "./background/cloudSettingsAutoSync"; +import { getBsplusDeviceName } from "@/seqta/utils/bsplusDeviceName"; import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl"; import { initCalendarBackground } from "./background/calendarBackground"; import { @@ -233,25 +234,33 @@ function handleCloudLogin( sendResponse({ error: "Unauthorized sender" }); 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) { sendResponse({ error: "Missing client_id, redirect_uri, login, or password" }); return false; } - fetch("https://accounts.betterseqta.org/api/bsplus/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ client_id, redirect_uri, login, password }), - }) - .then(async (r) => { + void (async () => { + const loginBody: Record = { + client_id, + redirect_uri, + login, + password, + device_name: device_name ?? await getBsplusDeviceName(), + }; + try { + const r = await fetch("https://accounts.betterseqta.org/api/bsplus/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(loginBody), + }); const data = await parseJsonResponse(r); if (!r.ok) sendResponse({ error: data?.error ?? "Login failed" }); else sendResponse(data); - }) - .catch((err) => { + } catch (err) { console.error("[Background] cloudLogin error:", err); - sendResponse({ error: err?.message ?? "Network error" }); - }); + sendResponse({ error: (err as Error)?.message ?? "Network error" }); + } + })(); return true; } @@ -717,6 +726,7 @@ browser.runtime.onInstalled.addListener(function (event) { void migrateGlobalSearchDefaultsFor365Upgrade(event.previousVersion); void resetThemeOfTheMonthDisabledFor366Upgrade(event.previousVersion); void resetThemeOfTheMonthDismissalFor370Upgrade(event.previousVersion); + reloadSeqtaPages(); } }); diff --git a/src/css/injected.scss b/src/css/injected.scss index 4e6a1b6e..d0efd9a8 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -34,7 +34,8 @@ display: none; } -button.uiButton.timetable-zoom.iconFamily, +button.timetable-zoom.iconFamily, +button.bsplus-timetable-control.iconFamily, .iconFamily { font-family: "IconFamily" !important; } @@ -63,9 +64,13 @@ body { select { border-radius: 16px !important; - border: 1px solid color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 78%, transparent) !important; - background: color-mix(in srgb, var(--background-primary) 90%, transparent) !important; + border: 1px solid var(--theme-offset-bg, var(--theme-secondary, var(--background-secondary))) !important; + background: var(--theme-primary, var(--background-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: background-color 180ms ease, border-color 180ms ease, @@ -73,14 +78,14 @@ select { } select:hover { - background: color-mix(in srgb, var(--background-primary) 94%, var(--background-secondary) 6%) !important; - border-color: color-mix(in srgb, var(--theme-offset-bg, var(--background-secondary)) 92%, transparent) !important; + background: var(--theme-secondary, var(--background-secondary)) !important; + border-color: var(--theme-offset-bg, var(--theme-secondary, var(--background-secondary))) !important; } select:focus { outline: none !important; - background: color-mix(in srgb, var(--background-primary) 96%, var(--background-secondary) 4%) !important; - border-color: color-mix(in srgb, var(--text-primary) 18%, var(--theme-offset-bg, var(--background-secondary)) 82%) !important; + background: var(--theme-secondary, var(--background-secondary)) !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; } @@ -89,12 +94,12 @@ select[size="1"] { appearance: none; -webkit-appearance: none; -moz-appearance: none; + color-scheme: light; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23999'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important; background-position: right 0.9rem center !important; background-repeat: no-repeat !important; background-size: 1rem !important; padding-right: 2.6rem !important; - color-scheme: light; } select::-ms-expand { @@ -102,19 +107,19 @@ select::-ms-expand { } select option { - background: var(--background-primary) !important; - color: var(--text-primary) !important; + background-color: #ffffff !important; + color: #18181b !important; +} + +.dark select option { + background-color: #1a1a1a !important; + color: #ffffff !important; } .dark select:not([multiple]):not([size]), .dark select[size="1"] { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23c9c9c9'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important; color-scheme: dark; -} - -.dark select option { - background: var(--background-primary) !important; - color: var(--text-primary) !important; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23c9c9c9'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z' clip-rule='evenodd'/%3E%3C/svg%3E") !important; } #container { background: var(--auto-background) !important; @@ -215,6 +220,13 @@ select option { 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 { fill: var(--theme-primary) !important; } @@ -331,12 +343,18 @@ select option { } .timetable-zoom, -.timetable-hide { +.timetable-hide, +.bsplus-timetable-control { font-size: 14px !important; line-height: 1 !important; display: inline-flex !important; align-items: center; justify-content: center; + background: transparent; + border: none; + color: var(--text-primary); + cursor: pointer; + padding: 4px 8px; } #main > .dashboard { @@ -421,6 +439,7 @@ ul.magicDelete > li.deleting { .addedButton svg { margin: 6px; fill: var(--theme-primary); + color: var(--theme-primary); } #menu, .sub, @@ -508,6 +527,54 @@ ul.magicDelete > li.deleting { #menu:has(> ul > li.hasChildren.active) > ul > li:not(.hasChildren.active) { pointer-events: none !important; } + +/* Edit Sidebar: every row + toggle must stay clickable (drill stack disables siblings). */ +#menu.bsplus-sidebar-edit-mode li.item, +#menu.bsplus-sidebar-edit-mode section.item, +#menu.bsplus-sidebar-edit-mode .bsplus-sidebar-offscreen, +#menu.bsplus-sidebar-edit-mode .bsplus-sidebar-offscreen * { + pointer-events: auto !important; + user-select: auto !important; +} + +#menu.bsplus-sidebar-edit-mode:has(> ul > li.hasChildren.active) + > ul + > li:not(.hasChildren.active) { + pointer-events: auto !important; +} + +#menu.bsplus-sidebar-edit-mode > ul > .bsplus-sidebar-offscreen:not(.hasChildren.active), +#menu.bsplus-sidebar-edit-mode .sub .bsplus-sidebar-offscreen:not(.hasChildren.active) { + position: relative !important; + left: auto !important; + width: auto !important; + height: auto !important; + margin: inherit !important; + padding: inherit !important; + overflow: visible !important; + clip: auto !important; + opacity: 1 !important; + visibility: visible !important; +} + +#menu.bsplus-sidebar-edit-mode .item.draggable { + display: flex !important; + align-items: center; + gap: 0.5rem; +} + +#menu.bsplus-sidebar-edit-mode .item.draggable > label { + flex: 1; + min-width: 0; +} + +#menu.bsplus-sidebar-edit-mode .onoffswitch { + pointer-events: auto !important; + flex-shrink: 0; + position: relative; + z-index: 2; +} + #menu section > label { align-items: center; box-sizing: border-box; @@ -796,6 +863,11 @@ ol:has([class*="MessageList__avatar___"] svg) { .quickbar .actions [title="Choose a colour"] > svg { scale: 0.9; } + +.quickbar .actions .timetable-edit-quickbar-btn > svg { + scale: 0.9; + padding-top: 1px; +} .quickbar[data-yiq="light"] .actions { color: white !important; } @@ -1026,7 +1098,13 @@ div > ol:has(.uiFileHandlerWrapper) { min-height: 128px !important; } body.student #menu > ul::before { + content: ""; + display: block; + width: 100%; background-image: var(--betterseqta-logo) !important; + background-position: center; + background-repeat: no-repeat; + background-size: auto 48px; position: -webkit-sticky; position: sticky; top: 0; @@ -2660,11 +2738,24 @@ body { .days { width: 100%; } -.modaliser { - display: none; +/* Do not hide .modaliser globally — SEQTA Modaliser relies on transitionend to + 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); } +/* 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___"] { position: relative; background: var(--background-secondary, rgb(228 225 225)); @@ -2742,39 +2833,9 @@ body { .defaultWelcomeWrapper { background: unset !important; } -.clr-swatches button::after, -.clr-dark .clr-preview::after, -.clr-field button::after { - 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; -} + +/* Coloris (timetable subject colours): cosmetic only — do not unset + transforms/animations on ::after (breaks picker reopen). */ #clr-color-preview { margin: 15px 0 20px 20px; border: 0; @@ -2783,6 +2844,18 @@ body { 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 [class*="MessageList__MessageList___"] > ol diff --git a/src/declarations.d.ts b/src/declarations.d.ts index 92f92efd..33871e2e 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -10,6 +10,22 @@ declare module "*?inlineWorker" { 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" { const value: string; export default value; diff --git a/src/interface/components/CloudPfpAvatar.svelte b/src/interface/components/CloudPfpAvatar.svelte index d44a181a..c8bf4891 100644 --- a/src/interface/components/CloudPfpAvatar.svelte +++ b/src/interface/components/CloudPfpAvatar.svelte @@ -1,5 +1,5 @@ -
- - + + {options.find((option) => option.value === value)?.label ?? value} + + + + + {#if isOpen} +
onKeydown(event, true)} + > + {#each options as option, index (option.value)} + + {/each} +
+ {/if}
diff --git a/src/interface/components/icons/LucideMoon.svelte b/src/interface/components/icons/LucideMoon.svelte new file mode 100644 index 00000000..beb0973d --- /dev/null +++ b/src/interface/components/icons/LucideMoon.svelte @@ -0,0 +1,15 @@ + + + diff --git a/src/interface/components/icons/LucideSun.svelte b/src/interface/components/icons/LucideSun.svelte new file mode 100644 index 00000000..97a9cab0 --- /dev/null +++ b/src/interface/components/icons/LucideSun.svelte @@ -0,0 +1,15 @@ + + + diff --git a/src/interface/components/store/ThemeModal.svelte b/src/interface/components/store/ThemeModal.svelte index 8dd09da7..130686af 100644 --- a/src/interface/components/store/ThemeModal.svelte +++ b/src/interface/components/store/ThemeModal.svelte @@ -1,978 +1,472 @@ - - -
{ - - if (e.target === e.currentTarget) hideModal() - - }} - - onkeydown={(e) => { - - if (e.target === e.currentTarget && e.key === 'Escape') hideModal() - - }} - - role="presentation" - - transition:fade - +class="flex fixed inset-0 z-50 justify-center items-end bg-black/70 backdrop-blur-sm" +onclick={(e) => { +if (e.target === e.currentTarget) hideModal() +}} +onkeydown={(e) => { +if (e.target === e.currentTarget && e.key === 'Escape') hideModal() +}} +role="presentation" +transition:fade > - - - -
e.stopPropagation()} - - onkeydown={(e) => e.stopPropagation()} - - role="dialog" - - aria-modal="true" - - tabindex="-1" - - > - - {#if theme} - -
- -
- - - -
- -
- -

- - {theme.name} - -

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

- - By {theme.author} - -

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

Variants

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

+{theme.name} +

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

+By {theme.author} +

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

Variants

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

- - {theme.description} - -

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

- - Related themes - -

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

+{theme.description} +

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

+Related themes +

+
+{#each relatedThemes as relatedTheme (relatedTheme.id)} + +{/each} +
+{/if} + +{:else} +
+ +
+{/if} + - - - diff --git a/src/interface/components/themes/BackgroundSelector.svelte b/src/interface/components/themes/BackgroundSelector.svelte index b6be121b..2be4c7f7 100644 --- a/src/interface/components/themes/BackgroundSelector.svelte +++ b/src/interface/components/themes/BackgroundSelector.svelte @@ -1,10 +1,9 @@ -
+
{#if !(imageBackgrounds.length === 0 && isEditMode)}

Background Images

@@ -198,7 +145,7 @@ handleFileChange(e.detail)} /> {/if} {#each imageBackgrounds as bg (bg.id)} - {#if isVisible && bg.blob} + {#if bg.url} handleFileChange(e.detail)} /> {/if} {#each videoBackgrounds as bg (bg.id)} - {#if isVisible && bg.blob} + {#if bg.url} {/if} -
\ No newline at end of file +
diff --git a/src/interface/components/themes/ThemeBlobImage.svelte b/src/interface/components/themes/ThemeBlobImage.svelte new file mode 100644 index 00000000..a7af43af --- /dev/null +++ b/src/interface/components/themes/ThemeBlobImage.svelte @@ -0,0 +1,34 @@ + + +{#if src} + +{/if} diff --git a/src/interface/components/themes/ThemeSelector.svelte b/src/interface/components/themes/ThemeSelector.svelte index 6fade68d..1599bbae 100644 --- a/src/interface/components/themes/ThemeSelector.svelte +++ b/src/interface/components/themes/ThemeSelector.svelte @@ -7,6 +7,7 @@ import { ThemeManager } from '@/plugins/built-in/themes/theme-manager' import { cloudAuth } from '@/seqta/utils/CloudAuth' import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte' + import ThemeBlobImage from '@/interface/components/themes/ThemeBlobImage.svelte' const themeManager = ThemeManager.getInstance(); @@ -242,8 +243,8 @@
{#if theme.coverImage} - {theme.name} diff --git a/src/interface/contentShadow.css b/src/interface/contentShadow.css new file mode 100644 index 00000000..d12e9377 --- /dev/null +++ b/src/interface/contentShadow.css @@ -0,0 +1,21 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +button { + @apply cursor-pointer; +} + +::-webkit-scrollbar { + display: none; +} + +input { + &:focus { + box-shadow: unset !important; + } +} + +.no-scrollbar { + scrollbar-width: none !important; +} diff --git a/src/interface/index.ts b/src/interface/index.ts index ff8d60b7..5003af7b 100644 --- a/src/interface/index.ts +++ b/src/interface/index.ts @@ -5,9 +5,10 @@ import browser from "webextension-polyfill"; import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import renderSvelte from "./main"; import { initializeSettingsState } from "@/seqta/utils/listeners/SettingsState"; +import { initVerboseLogging, verboseInfo } from "@/utils/verboseLog"; function InjectCustomIcons() { - console.info("[BetterSEQTA+] Injecting Icons"); + verboseInfo("[BetterSEQTA+] Injecting Icons"); const style = document.createElement("style"); style.setAttribute("type", "text/css"); @@ -31,5 +32,6 @@ InjectCustomIcons(); (async () => { await initializeSettingsState(); + initVerboseLogging(); renderSvelte(Settings, mountPoint, { standalone: true }); })(); diff --git a/src/interface/main.d.ts b/src/interface/main.d.ts index 38431407..0357e4f1 100644 --- a/src/interface/main.d.ts +++ b/src/interface/main.d.ts @@ -1,5 +1,3 @@ -import "./index.css"; - declare module "*.png"; declare module "*.svg"; declare module "*.jpeg"; diff --git a/src/interface/pages/settings.svelte b/src/interface/pages/settings.svelte index c9dac7ae..4f778991 100644 --- a/src/interface/pages/settings.svelte +++ b/src/interface/pages/settings.svelte @@ -106,10 +106,15 @@ showCloudPanel = true; }; - const showDisclaimer = (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => { + const showDisclaimer = ( + onConfirm: () => void, + onCancel: () => void, + title = "Confirm", + message = "", + ) => { disclaimerCallbacks = { onConfirm, onCancel }; - disclaimerTitle = title ?? "Confirm"; - disclaimerMessage = message ?? ""; + disclaimerTitle = title; + disclaimerMessage = message; showDisclaimerModal = true; }; diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index 6238eef7..fe69e291 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -250,7 +250,7 @@ id: 10, Component: Select, props: { - state: $settingsState.defaultPage ?? "home", + value: $settingsState.defaultPage ?? "home", onChange: (value: string) => (settingsState.defaultPage = value), options: [ { value: "home", label: "Home" }, @@ -269,7 +269,7 @@ id: 11, Component: Select, props: { - state: $settingsState.newsSource, + value: $settingsState.newsSource, onChange: (value: string) => settingsState.newsSource = value, options: [ { value: "australia", label: "Australia" }, @@ -290,6 +290,65 @@ {@render Setting(option)} {/each} +
+
+
+
+

Home Page Assessments

+

Limit upcoming assessments shown on the home page by subject

+
+
+
+
+

Include Past Assessments

+

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

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

Maximum Subjects

+

Number of subjects to include, ordered by soonest due date

+
+ (settingsState.homeUpcomingAssessmentsPerSubjectMax = Number(value))} + options={[ + { value: "0", label: "All" }, + { value: "1", label: "1" }, + { value: "2", label: "2" }, + { value: "3", label: "3" }, + { value: "5", label: "5" }, + { value: "10", label: "10" }, + ]} + /> +
+
+
+
@@ -406,7 +465,7 @@ /> {:else if setting.type === 'select'} {:else if item.type === 'lightDarkToggle'} {/if}
@@ -330,7 +328,7 @@ {/if} {#if theme.coverImage}
- Cover + {/if}
diff --git a/src/interface/renderInShadow.ts b/src/interface/renderInShadow.ts new file mode 100644 index 00000000..c54f1361 --- /dev/null +++ b/src/interface/renderInShadow.ts @@ -0,0 +1,26 @@ +import { mount } from "svelte"; +import type { SvelteComponent } from "svelte"; +import style from "./contentShadow.css?inline"; + +/** Mount Svelte UI inside a shadow root from content scripts (decoupled from settings popup CSS). */ +export default function renderInShadow( + Component: SvelteComponent | any, + mountPoint: ShadowRoot | HTMLElement, + props: Record = {}, +) { + const app = mount(Component, { + target: mountPoint, + props: { + standalone: false, + ...props, + }, + }); + + if (mountPoint instanceof ShadowRoot) { + const styleElement = document.createElement("style"); + styleElement.textContent = style; + mountPoint.appendChild(styleElement); + } + + return app; +} diff --git a/src/interface/utils/syncPageTheme.ts b/src/interface/utils/syncPageTheme.ts index 7e45a197..77612741 100644 --- a/src/interface/utils/syncPageTheme.ts +++ b/src/interface/utils/syncPageTheme.ts @@ -7,6 +7,8 @@ const THEME_CSS_VARS = [ "--text-color", "--background-primary", "--background-secondary", + "--theme-primary", + "--theme-secondary", "--text-primary", "--theme-offset-bg", "--better-sub", diff --git a/src/lib/extensionAssetUrl.ts b/src/lib/extensionAssetUrl.ts index 095599ef..ccc75d91 100644 --- a/src/lib/extensionAssetUrl.ts +++ b/src/lib/extensionAssetUrl.ts @@ -7,3 +7,4 @@ export function resolveExtensionAssetUrl(url: string): string { if (/^(?:chrome|moz)-extension:\/\/|https?:|data:/.test(url)) return url; return browser.runtime.getURL(url.replace(/^\/+/, "")); } + diff --git a/src/lib/extensionPageScriptUrl.test.ts b/src/lib/extensionPageScriptUrl.test.ts new file mode 100644 index 00000000..bcd7726f --- /dev/null +++ b/src/lib/extensionPageScriptUrl.test.ts @@ -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", + ); + }); +}); diff --git a/src/lib/extensionPageScriptUrl.ts b/src/lib/extensionPageScriptUrl.ts new file mode 100644 index 00000000..fbc48195 --- /dev/null +++ b/src/lib/extensionPageScriptUrl.ts @@ -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(/^\/+/, "")); +} diff --git a/src/lib/icons/lucideMoon.ts b/src/lib/icons/lucideMoon.ts new file mode 100644 index 00000000..c1040780 --- /dev/null +++ b/src/lib/icons/lucideMoon.ts @@ -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 = + ``; diff --git a/src/lib/icons/lucideSun.ts b/src/lib/icons/lucideSun.ts new file mode 100644 index 00000000..543731e5 --- /dev/null +++ b/src/lib/icons/lucideSun.ts @@ -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 = + ``; diff --git a/src/lib/transformersExtension.ts b/src/lib/transformersExtension.ts new file mode 100644 index 00000000..3e872478 --- /dev/null +++ b/src/lib/transformersExtension.ts @@ -0,0 +1,43 @@ +import browser from "webextension-polyfill"; + +const ORT_RESOURCE_DIR = "resources/ort/"; + +let configured = false; + +function extensionAssetUrl(relativePath: string): string { + return browser.runtime.getURL(relativePath.replace(/^\/+/, "")); +} + +/** + * Point HuggingFace transformers / onnxruntime at extension-local WASM files + * instead of CDN (required on SEQTA pages where page CSP blocks jsdelivr). + * Safe to call multiple times; must run before embeddia `initializeModel()`. + */ +export async function ensureTransformersEnv( + ortWasmBase?: string, +): Promise { + if (configured) return; + + const { env } = await import("@huggingface/transformers"); + const base = ortWasmBase ?? extensionAssetUrl(ORT_RESOURCE_DIR); + + env.backends.onnx.wasm = env.backends.onnx.wasm ?? {}; + env.backends.onnx.wasm.wasmPaths = base.endsWith("/") ? base : `${base}/`; + + configured = true; +} + +export function getOrtWasmBaseUrl(): string { + const base = extensionAssetUrl(ORT_RESOURCE_DIR); + return base.endsWith("/") ? base : `${base}/`; +} + +/** For page-origin blob workers that cannot call `browser.runtime.getURL`. */ +export async function configureTransformersEnvForBase( + ortWasmBase: string, +): Promise { + configured = false; + await ensureTransformersEnv(ortWasmBase); +} + +export { ORT_RESOURCE_DIR }; diff --git a/src/manifests/manifest.json b/src/manifests/manifest.json index 439930a8..82970383 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -47,6 +47,8 @@ "resources/update-image.webp", "resources/pdfjs/pdf.worker.min.mjs", "resources/pdfjs/pdf.legacy.min.mjs", + "resources/ort/*", + "assets/*.css", "assets/*" ], "matches": ["*://*/*"] diff --git a/src/plugins/built-in/animatedBackground/backgroundLayers.ts b/src/plugins/built-in/animatedBackground/backgroundLayers.ts new file mode 100644 index 00000000..c5f8531d --- /dev/null +++ b/src/plugins/built-in/animatedBackground/backgroundLayers.ts @@ -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 { + 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 + } +} diff --git a/src/plugins/built-in/animatedBackground/index.ts b/src/plugins/built-in/animatedBackground/index.ts index 40dff577..b5972447 100644 --- a/src/plugins/built-in/animatedBackground/index.ts +++ b/src/plugins/built-in/animatedBackground/index.ts @@ -6,7 +6,11 @@ import { Setting, } from "@/plugins/core/settingsHelpers"; import styles from "./styles.css?inline"; -import { waitForElm } from "@/seqta/utils/waitForElm"; +import { + removeAnimatedBackgroundLayers, + syncAnimatedBackground, + updateAnimationSpeed, +} from "./backgroundLayers"; const settings = defineSettings({ speed: numberSetting({ @@ -36,48 +40,25 @@ const animatedBackgroundPlugin: Plugin = { settings: instance.settings, run: async (api) => { - const [container, menu] = await Promise.all([ - waitForElm("#container", true), - waitForElm("#menu", true), - ]); + await syncAnimatedBackground(api); + const resync = () => void syncAnimatedBackground(api); - const backgrounds = [ - { classes: ["bg"] }, - { classes: ["bg", "bg2"] }, - { classes: ["bg", "bg3"] }, - ]; + const speedUnregister = api.settings.onChange("speed", updateAnimationSpeed); + const pageChangeUnregister = api.seqta.onPageChange(resync); + window.addEventListener("pageshow", resync); - backgrounds.forEach(({ classes }) => { - const bk = document.createElement("div"); - classes.forEach((cls) => bk.classList.add(cls)); - container.insertBefore(bk, menu); - }); + const containerObserver = new MutationObserver(resync); + const container = document.getElementById("container"); + if (container) containerObserver.observe(container, { childList: true }); - // Set initial speed - updateAnimationSpeed(api.settings.speed); - - // Listen for speed changes - const speedUnregister = api.settings.onChange( - "speed", - updateAnimationSpeed, - ); - - // Return cleanup function return () => { speedUnregister.unregister(); - // Remove background elements - const backgrounds = document.getElementsByClassName("bg"); - Array.from(backgrounds).forEach((element) => element.remove()); + pageChangeUnregister.unregister(); + window.removeEventListener("pageshow", resync); + 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; diff --git a/src/plugins/built-in/assessmentsAverage/extractWeightFromCoversheetText.test.ts b/src/plugins/built-in/assessmentsAverage/extractWeightFromCoversheetText.test.ts new file mode 100644 index 00000000..0e0e6f23 --- /dev/null +++ b/src/plugins/built-in/assessmentsAverage/extractWeightFromCoversheetText.test.ts @@ -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(); + }); +}); diff --git a/src/plugins/built-in/assessmentsAverage/extractWeightFromCoversheetText.ts b/src/plugins/built-in/assessmentsAverage/extractWeightFromCoversheetText.ts new file mode 100644 index 00000000..0946fcec --- /dev/null +++ b/src/plugins/built-in/assessmentsAverage/extractWeightFromCoversheetText.ts @@ -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; +} diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index e17c35b3..39eda214 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -11,6 +11,9 @@ import { getPdfjsPageContextUrls, } from "@/lib/pdfjsExtension.ts"; import * as pdfjs from "pdfjs-dist"; +import { extractWeightFromCoversheetText } from "./extractWeightFromCoversheetText"; + +export { extractWeightFromCoversheetText }; ensurePdfjsWorker(); @@ -552,135 +555,67 @@ export async function extractPDFText(url: string): Promise { return new Promise((resolve, reject) => { const script = document.createElement("script"); + script.type = "module"; const requestId = `pdf-extract-${Date.now()}-${Math.random()}`; const escapedUrl = escJsSingleQuoted(url); + // Import the legacy build in page context so it can set + // globalThis.pdfjsLib, then parse the coversheet PDF. script.textContent = ` - (function() { - const requestId = '${requestId}'; - const pageOrigin = '${escapedOrigin}'; - const url = '${escapedUrl}'; - const pdfLibSrc = '${pdfLibInj}'; - const pdfWorkerSrc = '${pdfWorkerInj}'; - - if (window.pdfjsLib) { - extractPDF(); + const requestId = '${requestId}'; + const pageOrigin = '${escapedOrigin}'; + const url = '${escapedUrl}'; + const pdfWorkerSrc = '${pdfWorkerInj}'; + + function postResult(payload) { + window.postMessage({ type: requestId, ...payload }, pageOrigin); + } + + try { + await import('${pdfLibInj}'); + const pdfjsLib = globalThis.pdfjsLib; + if (!pdfjsLib?.getDocument) { + postResult({ success: false, error: 'pdfjsLib missing after import' }); } else { - const pdfjsScript = document.createElement('script'); - pdfjsScript.src = pdfLibSrc; - pdfjsScript.type = 'module'; - - pdfjsScript.onload = function() { - extractPDF(); - }; - pdfjsScript.onerror = function() { - 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); + pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc; + + const response = await fetch(url, { + credentials: 'include', + redirect: 'follow', + }); + if (!response.ok) { + throw new Error('HTTP ' + response.status + ': ' + response.statusText); } + + 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) => { @@ -723,6 +658,9 @@ export async function extractPDFText(url: string): Promise { const pdf = await pdfjs.getDocument({ data: arrayBuffer, useSystemFonts: true, + verbosity: 0, + useWorkerFetch: false, + isEvalSupported: false, }).promise; let text = ""; @@ -740,6 +678,85 @@ export async function extractPDFText(url: string): Promise { } } +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 { + 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 { + 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) { const assessmentID = assessmentIdKey(mark); const metaclassID = mark.metaclassID; @@ -749,13 +766,25 @@ async function handleWeightings(mark: any, api: any) { | WeightingEntry | 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.weight !== "processing" && - existing.fingerprint === fingerprint && - existing.pluginVersion === WEIGHTING_SCHEMA_VERSION; + existing.weight !== "N/A" && + !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 // 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 // refetch for the same id while this one is still in flight. const placeholder: WeightingEntry = - existing && existing.weight !== "processing" + existing && hasNumericWeight ? { ...existing, fingerprint, @@ -807,34 +836,13 @@ async function handleWeightings(mark: any, api: any) { const userInfo = await getUserInfo(); const userID = userInfo.id; - const filename = - "BetterSEQTA-" + - String(Math.floor(Math.random() * 1e15)).padStart(15, "0"); - - 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}`, - ); - } - + const reportFile = await requestStudentAssessmentPdf({ + assessmentID, + metaclassID, + studentID: userID, + }); await new Promise((resolve) => setTimeout(resolve, 1000)); - - pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`; + pdfUrl = getStudentAssessmentReportUrl(reportFile); } if (pdfUrl.startsWith("blob:")) { @@ -843,32 +851,37 @@ async function handleWeightings(mark: any, api: any) { let text: string; try { - text = await extractPDFText(pdfUrl); + text = await extractPDFTextWithRetry(pdfUrl); } catch (error: any) { if ( isFirefox && (error?.message?.includes("blob") || error?.message?.includes("Security") || - error?.message?.includes("CSP")) + error?.message?.includes("CSP") || + error?.message?.includes("empty")) ) { await new Promise((resolve) => setTimeout(resolve, 2000)); - text = await extractPDFText(pdfUrl); + text = await extractPDFTextWithRetry(pdfUrl, 2, 2000); } else { 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, [assessmentID]: { - weight: match ? match[1] : "N/A", + weight: weight ?? "N/A", fingerprint, pluginVersion: WEIGHTING_SCHEMA_VERSION, }, }; } catch (error: any) { + console.error( + `[BetterSEQTA+] Weighting fetch failed for assessment ${assessmentID}:`, + error, + ); api.storage.weightings = { ...api.storage.weightings, [assessmentID]: { diff --git a/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte b/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte index 354c1c20..a12c6043 100644 --- a/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte +++ b/src/plugins/built-in/assessmentsOverview/AssessmentsOverview.svelte @@ -1,5 +1,11 @@ - {#each segments as segment} + {#each segments as segment, i (i)} {#if segment.highlight} {segment.text} {:else} {segment.text} {/if} {/each} - \ No newline at end of file + diff --git a/src/plugins/built-in/globalSearch/src/utils/hotkeyUtils.ts b/src/plugins/built-in/globalSearch/src/utils/hotkeyUtils.ts index 884f51d5..d22660d6 100644 --- a/src/plugins/built-in/globalSearch/src/utils/hotkeyUtils.ts +++ b/src/plugins/built-in/globalSearch/src/utils/hotkeyUtils.ts @@ -1,3 +1,7 @@ +export function getDefaultSearchHotkey(): string { + return navigator.platform.toUpperCase().includes("MAC") ? "cmd+k" : "ctrl+k"; +} + export interface ParsedHotkey { ctrl: boolean; meta: boolean; diff --git a/src/plugins/built-in/globalSearch/src/utils/versionCheck.ts b/src/plugins/built-in/globalSearch/src/utils/versionCheck.ts index 2c4e9b04..6bef9dba 100644 --- a/src/plugins/built-in/globalSearch/src/utils/versionCheck.ts +++ b/src/plugins/built-in/globalSearch/src/utils/versionCheck.ts @@ -1,12 +1,15 @@ import browser from "webextension-polyfill"; import { resetSearchIndexes } from "../indexing/resetIndexes"; +import { verboseDebug, verboseLog } from "@/utils/verboseLog"; const VERSION_STORAGE_KEY = "betterseqta-global-search-version"; const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version"; -/** - * Gets the current extension version from the manifest - */ +const isAssetLoadError = (e: unknown) => { + const msg = (e as { message?: string })?.message ?? ""; + return msg.includes("preload CSS") || msg.includes("MIME type"); +}; + export function getCurrentVersion(): string { try { return browser.runtime.getManifest().version; @@ -16,9 +19,6 @@ export function getCurrentVersion(): string { } } -/** - * Gets the last stored version from localStorage - */ export function getStoredVersion(): string | null { try { return localStorage.getItem(VERSION_STORAGE_KEY); @@ -28,9 +28,6 @@ export function getStoredVersion(): string | null { } } -/** - * Stores the current version in localStorage - */ export function storeVersion(version: string): void { try { localStorage.setItem(VERSION_STORAGE_KEY, version); @@ -42,36 +39,21 @@ export function storeVersion(version: string): void { /** * Checks if the extension has been updated and clears caches + resets the - * search index if needed. - * - * The reset is intentionally aggressive: every manifest version bump - * triggers a full IndexedDB wipe so changes to indexer extraction logic, - * job sets, or item shape can never serve stale results from an older - * build. The next indexing pass will repopulate from scratch in the - * background. Re-population is bounded by the per-job rate limits in - * `api.ts` so it can't hammer SEQTA after an update. - * - * Returns true if an update was detected. + * search index if needed. Returns true if an update was detected. */ export async function checkAndHandleUpdate(): Promise { const currentVersion = getCurrentVersion(); const storedVersion = getStoredVersion(); - // First run: just remember the version, don't reset (the user likely - // just installed the extension; the index is already empty). if (!storedVersion) { - console.debug( - `[Version Check] First run detected, storing version ${currentVersion}`, - ); + verboseDebug(`[Version Check] First run detected, storing version ${currentVersion}`); storeVersion(currentVersion); return false; } - if (storedVersion === currentVersion) { - return false; - } + if (storedVersion === currentVersion) return false; - console.log( + verboseLog( `[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, resetting search index...`, ); @@ -79,57 +61,40 @@ export async function checkAndHandleUpdate(): Promise { try { await resetSearchIndexes(); - console.log( - "[Version Check] Search index reset; next indexing pass will repopulate from scratch.", - ); + verboseLog("[Version Check] Search index reset; next indexing pass will repopulate from scratch."); } catch (e) { console.warn("[Version Check] resetSearchIndexes failed:", e); } storeVersion(currentVersion); - return true; } -/** - * Clears all search-related caches - */ export async function clearAllCaches(): Promise { try { - // Clear search result cache (in-memory Map) - if (typeof window !== 'undefined') { - // Dispatch event to clear caches in other modules - window.dispatchEvent(new CustomEvent('betterseqta-clear-search-cache')); - window.dispatchEvent(new CustomEvent('betterseqta-clear-embedding-cache')); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("betterseqta-clear-search-cache")); + window.dispatchEvent(new CustomEvent("betterseqta-clear-embedding-cache")); } - - // Also try to directly clear caches if modules are already loaded - // Use setTimeout to avoid blocking and handle CSS preload errors + setTimeout(async () => { try { const { clearSearchCache } = await import("../search/searchUtils"); clearSearchCache(); - } catch (e: any) { - // Module might not be loaded yet, or CSS preload error - that's okay - if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) { - console.debug("[Version Check] Could not clear search cache:", e); - } + } catch (e) { + if (!isAssetLoadError(e)) verboseDebug("[Version Check] Could not clear search cache:", e); } - + try { const { clearEmbeddingCache } = await import("../search/vector/vectorSearch"); clearEmbeddingCache(); - } catch (e: any) { - // Module might not be loaded yet, or CSS preload error - that's okay - if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) { - console.debug("[Version Check] Could not clear embedding cache:", e); - } + } catch (e) { + if (!isAssetLoadError(e)) verboseDebug("[Version Check] Could not clear embedding cache:", e); } }, 50); - - console.debug("[Version Check] All caches cleared"); + + verboseDebug("[Version Check] All caches cleared"); } catch (e) { console.error("[Version Check] Error clearing caches:", e); } } - diff --git a/src/plugins/built-in/gradeAnalytics/core/index.ts b/src/plugins/built-in/gradeAnalytics/core/index.ts index 16d36394..9d32c241 100644 --- a/src/plugins/built-in/gradeAnalytics/core/index.ts +++ b/src/plugins/built-in/gradeAnalytics/core/index.ts @@ -2,12 +2,21 @@ import type { Plugin } from "@/plugins/core/types"; import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json"; import { waitForElm } from "@/seqta/utils/waitForElm"; import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; -import { processMenuItemNode } from "@/seqta/utils/sidebarMenuIcons"; +import { + 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 styles from "../styles.css?inline"; const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics; - const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item"; const gradeAnalyticsPlugin: Plugin<{}> = { @@ -17,7 +26,7 @@ const gradeAnalyticsPlugin: Plugin<{}> = { "Adds an analytics page with grade trends, distribution charts, and assessment history", version: "1.0.0", settings: {}, - disableToggle: false, + disableToggle: true, styles, run: async () => { @@ -36,37 +45,40 @@ const gradeAnalyticsPlugin: Plugin<{}> = { analyticsItem.dataset.betterseqta = "true"; analyticsItem.innerHTML = ``; - const homeButton = document.getElementById("homebutton"); - if (homeButton?.parentElement === menuList) { - homeButton.insertAdjacentElement("afterend", analyticsItem); - } else { - menuList.insertBefore(analyticsItem, menuList.firstChild); - } + const syncAnalyticsMenu = () => { + insertMenuItemAfterKey(menuList, analyticsItem, "courses"); + ensureAnalyticsMenuOrder(); + if (settingsState.menuorder.length > 0) { + ChangeMenuItemPositions(settingsState.menuorder); + } + processMenuItemNode(analyticsItem); + applyMenuItemVisibility(); + }; - processMenuItemNode(analyticsItem); + syncAnalyticsMenu(); const menuObserver = new MutationObserver(() => { - if (!menuList.contains(analyticsItem)) { - if (homeButton?.parentElement === menuList) { - homeButton.insertAdjacentElement("afterend", analyticsItem); - } else { - menuList.insertBefore(analyticsItem, menuList.firstChild); - } - processMenuItemNode(analyticsItem); - } + if (MenuOptionsOpen || menuList.contains(analyticsItem)) return; + syncAnalyticsMenu(); }); 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(); window.history.pushState({}, "", "/#?page=/analytics"); void loadAnalyticsPage(); - }; - analyticsItem.addEventListener("click", onClick); + }); return () => { menuObserver.disconnect(); - analyticsItem.removeEventListener("click", onClick); analyticsItem.remove(); }; }, diff --git a/src/plugins/built-in/gradeAnalytics/lazy.ts b/src/plugins/built-in/gradeAnalytics/lazy.ts index 9b0c84e6..125db7d5 100644 --- a/src/plugins/built-in/gradeAnalytics/lazy.ts +++ b/src/plugins/built-in/gradeAnalytics/lazy.ts @@ -20,7 +20,7 @@ const gradeAnalyticsPluginLazy = defineLazyPlugin({ "Grade trends, distribution charts, and assessment history synced from SEQTA", version: "1.0.0", settings, - disableToggle: false, + disableToggle: true, defaultEnabled: true, styles, loader: () => import("./core/index"), diff --git a/src/plugins/built-in/gradeAnalytics/styles.css b/src/plugins/built-in/gradeAnalytics/styles.css index 0858b084..c065e53f 100644 --- a/src/plugins/built-in/gradeAnalytics/styles.css +++ b/src/plugins/built-in/gradeAnalytics/styles.css @@ -18,8 +18,8 @@ --bsplus-analytics-radius: 16px; --bsplus-analytics-radius-sm: 12px; --bsplus-analytics-ease: cubic-bezier(0.4, 0, 0.2, 1); - --bsplus-analytics-surface: var(--background-primary, #ffffff); - --bsplus-analytics-surface-2: var(--background-secondary, #f8fafc); + --bsplus-analytics-surface: var(--theme-primary, var(--background-primary, #ffffff)); + --bsplus-analytics-surface-2: var(--theme-secondary, var(--background-secondary, #f8fafc)); --bsplus-analytics-text: var(--text-primary, #1a1a1a); --bsplus-analytics-muted: color-mix( in srgb, @@ -937,7 +937,7 @@ min-width: 0; } -.bsplus-analytics-chart-cell > :global(.bsplus-analytics-card) { +.bsplus-analytics-chart-cell > .bsplus-analytics-card { flex: 1; width: 100%; min-width: 0; diff --git a/src/plugins/built-in/gradeAnalytics/ui.ts b/src/plugins/built-in/gradeAnalytics/ui.ts index 5beecc69..507a3773 100644 --- a/src/plugins/built-in/gradeAnalytics/ui.ts +++ b/src/plugins/built-in/gradeAnalytics/ui.ts @@ -4,6 +4,7 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { mount, unmount } from "svelte"; import GradeAnalyticsPage from "./GradeAnalyticsPage.svelte"; import { buildContrastAccentPalette } from "./utils/accentColor"; +import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor"; type ThemeSettingKey = | "selectedColor" @@ -62,26 +63,6 @@ const ACCENT_CSS_VARS = [ "--colour-betterseqta-blue", ] as const; -/** Resolve a solid colour for charts (gradients → first stop). */ -function extractSolidColor(value: string): string | null { - const trimmed = value.trim(); - if (!trimmed || trimmed === "initial") return null; - if ( - trimmed.startsWith("#") || - trimmed.startsWith("rgb") || - trimmed.startsWith("hsl") - ) { - return trimmed; - } - if (trimmed.includes("gradient")) { - const match = trimmed.match( - /#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/i, - ); - return match?.[0] ?? null; - } - return null; -} - const THEME_ACCENT_OVERRIDES: Record = { "bb0aaf40-55ef-40f7-bc64-93b67ef96c01": "#4ade80", }; @@ -108,11 +89,10 @@ function syncThemeFromPage(target: HTMLElement) { const computed = getComputedStyle(document.documentElement); for (const name of THEME_CSS_VARS) { - let value = computed.getPropertyValue(name).trim(); - value = document.documentElement.style.getPropertyValue(name).trim(); - if (value) { - target.style.setProperty(name, value); - } + const value = + document.documentElement.style.getPropertyValue(name).trim() || + computed.getPropertyValue(name).trim(); + if (value) target.style.setProperty(name, value); } const accent = resolvePageAccentColor(); @@ -132,11 +112,7 @@ function syncThemeFromPage(target: HTMLElement) { target.style.setProperty("--better-main", palette.accent); target.style.setProperty("--bsplus-theme-btn-primary-bg", palette.accent); target.style.setProperty("--bsplus-theme-btn-primary-color", palette.onAccent); - - target.classList.toggle( - "dark", - document.documentElement.classList.contains("dark"), - ); + target.classList.toggle("dark", document.documentElement.classList.contains("dark")); } function syncThemeToAnalyticsUi() { diff --git a/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts b/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts index 606b2042..2858771a 100644 --- a/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts +++ b/src/plugins/built-in/gradeAnalytics/utils/accentColor.ts @@ -1,4 +1,5 @@ import Color from "color"; +import { parseCssColor } from "@/seqta/ui/colors/parseCssColor"; export type ContrastAccentPalette = { accent: string; @@ -52,8 +53,8 @@ export function buildContrastAccentPalette( accentRaw: string, backgroundRaw: string, ): ContrastAccentPalette { - const accent = Color(accentRaw); - const background = Color(backgroundRaw); + const accent = parseCssColor(accentRaw); + const background = parseCssColor(backgroundRaw, "#ffffff"); const isDark = background.isDark(); const { h, s } = accent.hsl().object(); diff --git a/src/plugins/built-in/messageFolders/styles.css b/src/plugins/built-in/messageFolders/styles.css index 6b99b126..2e23d4dc 100644 --- a/src/plugins/built-in/messageFolders/styles.css +++ b/src/plugins/built-in/messageFolders/styles.css @@ -388,8 +388,8 @@ right: 0; margin-top: 4px; min-width: 180px; - background: var(--background-primary, #fff); - border: 1px solid var(--background-secondary, #e0e0e0); + background: var(--theme-primary, var(--background-primary, #fff)); + border: 1px solid var(--theme-secondary, var(--background-secondary, #e0e0e0)); border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); z-index: 1000; diff --git a/src/plugins/built-in/notificationCollector/archive.ts b/src/plugins/built-in/notificationCollector/archive.ts new file mode 100644 index 00000000..d3349184 --- /dev/null +++ b/src/plugins/built-in/notificationCollector/archive.ts @@ -0,0 +1,92 @@ +import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements"; + +type RawNotification = Record; + +export interface ArchivedNotification { + notificationID: number; + firstSavedAt: string; + lastSeenAt: string; + raw: RawNotification; +} + +export type ArchiveMap = Record; +export type ArchivesByUser = Record; + +export async function resolveNotificationUserKey(): Promise { + 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 { + 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 }; +} diff --git a/src/plugins/built-in/notificationCollector/index.ts b/src/plugins/built-in/notificationCollector/index.ts index bed27b5a..033f3373 100644 --- a/src/plugins/built-in/notificationCollector/index.ts +++ b/src/plugins/built-in/notificationCollector/index.ts @@ -1,18 +1,48 @@ import type { Plugin } from "../../core/types"; +import { booleanSetting } from "@/plugins/core/settingsHelpers"; import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; +import { verboseInfo } from "@/utils/verboseLog"; +import { + type ArchivesByUser, + fetchAllNotifications, + mergeNotificationsIntoArchive, + resolveNotificationUserKey, +} from "./archive"; +import { + injectArchivedForUser, + mountArchivedNotificationInjection, +} from "./injectArchivedNotifications"; +import styles from "./styles.css?inline"; + +const BUBBLE_SELECTOR = "[class*='notifications__bubble___']"; +const LIST_SELECTOR = '[class*="notifications__list___"]'; + +const notificationCollectorSettings = { + saveLocally: booleanSetting({ + default: true, + title: "Save notification history locally", + description: + "Saves notifications per account in extension storage and restores missing ones into the SEQTA list", + }), +} as const; interface NotificationCollectorStorage { lastNotificationCount: number; lastCheckedTime: string; consecutiveErrors: number; + archivesByUser: ArchivesByUser; } -const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = { +const notificationCollectorPlugin: Plugin< + typeof notificationCollectorSettings, + NotificationCollectorStorage +> = { id: "notificationCollector", name: "Notification Collector", - description: "Collects and displays SEQTA notifications", - version: "1.0.0", - settings: {}, + description: + "Tracks notifications and saves a local per-account archive that outlasts SEQTA's server retention", + version: "1.2.0", + settings: notificationCollectorSettings, disableToggle: true, run: async (api) => { @@ -20,66 +50,77 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = { return () => {}; } + const styleEl = document.createElement("style"); + styleEl.textContent = styles; + document.head.appendChild(styleEl); + + await api.storage.loaded; + await api.settings.loaded; + let pollInterval: number | null = null; let isVisible = !document.hidden; - let baseInterval = 30000; // 30 seconds - const maxInterval = 300000; // 5 minutes max + let archiveSyncInFlight = false; + const baseInterval = 30000; + const maxInterval = 300000; - // Store last notification count in storage - if (!api.storage.lastNotificationCount) { - api.storage.lastNotificationCount = 0; - } - if (!api.storage.consecutiveErrors) { - api.storage.consecutiveErrors = 0; - } + api.storage.lastNotificationCount ||= 0; + api.storage.consecutiveErrors ||= 0; + api.storage.archivesByUser ||= {}; + + const syncArchive = async () => { + if (!api.settings.saveLocally || archiveSyncInFlight) return; + + archiveSyncInFlight = true; + try { + const userKey = await resolveNotificationUserKey(); + if (!userKey) return; + + const notifications = await fetchAllNotifications(); + const archivesByUser = { ...(api.storage.archivesByUser ?? {}) }; + const existing = archivesByUser[userKey] ?? {}; + const { archive: merged, changed } = mergeNotificationsIntoArchive( + existing, + notifications, + ); + + if (changed) { + archivesByUser[userKey] = merged; + api.storage.archivesByUser = archivesByUser; + } else if (document.querySelector(LIST_SELECTOR)) { + await injectArchivedForUser(merged); + } + } catch (error) { + console.warn("[BetterSEQTA+] Notification archive sync failed:", error); + } finally { + archiveSyncInFlight = false; + } + }; const checkNotifications = async () => { - // Skip if tab is not visible to save battery - if (!isVisible) { - return; - } + if (!isVisible) return; try { - const alertDiv = document.querySelector( - "[class*='notifications__bubble___']", - ) as HTMLElement; + const alertDiv = document.querySelector(BUBBLE_SELECTOR) as HTMLElement; if (alertDiv && api.storage.lastNotificationCount !== 0) { alertDiv.textContent = api.storage.lastNotificationCount.toString(); } - const response = await fetch( - `${location.origin}/seqta/student/heartbeat?`, - { - method: "POST", - headers: { - "Content-Type": "application/json; charset=utf-8", - }, - body: JSON.stringify({ - timestamp: "1970-01-01 00:00:00.0", - hash: "#?page=/home", - }), - }, - ); + const notifications = await fetchAllNotifications(); + const notificationCount = notifications.length; - if (!response.ok) { - throw new Error(`Heartbeat HTTP ${response.status}`); - } - - const data = await response.json(); - - // Store notification count for history - const notificationCount = data.payload.notifications.length; api.storage.lastNotificationCount = notificationCount; api.storage.lastCheckedTime = new Date().toISOString(); - - // Reset error count on success api.storage.consecutiveErrors = 0; + if (api.settings.saveLocally) { + await syncArchive(); + } + if (alertDiv) { alertDiv.textContent = notificationCount.toString(); } else { - console.info("[BetterSEQTA+] No notifications currently"); + verboseInfo("[BetterSEQTA+] No notifications currently"); } } catch (error) { console.error("[BetterSEQTA+] Error fetching notifications:", error); @@ -89,7 +130,6 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = { }; const getNextInterval = () => { - // Exponential backoff on errors, max 5 minutes const errorMultiplier = Math.min( Math.pow(2, api.storage.consecutiveErrors || 0), 10, @@ -98,17 +138,14 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = { }; const startPolling = () => { - if (pollInterval) return; // Already polling + if (pollInterval) return; checkNotifications(); const scheduleNext = () => { const interval = getNextInterval(); pollInterval = window.setTimeout(() => { checkNotifications().then(() => { - if (pollInterval) { - // Only continue if not stopped - scheduleNext(); - } + if (pollInterval) scheduleNext(); }); }, interval); }; @@ -120,9 +157,7 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = { if (pollInterval) { window.clearTimeout(pollInterval); pollInterval = null; - const alertDiv = document.querySelector( - "[class*='notifications__bubble___']", - ) as HTMLElement; + const alertDiv = document.querySelector(BUBBLE_SELECTOR) as HTMLElement; if (alertDiv) { if (api.storage.lastNotificationCount > 9) { alertDiv.textContent = "9+"; @@ -133,29 +168,43 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = { } }; - // Listen for visibility changes to pause/resume polling const handleVisibilityChange = () => { isVisible = !document.hidden; if (isVisible && !pollInterval) { - // Resume polling when tab becomes visible - const alertDiv = document.querySelector( - "[class*='notifications__bubble___']", - ); - if (alertDiv) { - startPolling(); - } + if (document.querySelector(BUBBLE_SELECTOR)) startPolling(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); - api.seqta.onMount("[class*='notifications__bubble___']", (_) => { - startPolling(); + const pageChangeUnregister = api.seqta.onPageChange((page) => { + if (page === "notifications" && api.settings.saveLocally) { + void syncArchive(); + } }); + const teardownInjection = mountArchivedNotificationInjection( + api, + resolveNotificationUserKey, + ); + + const onBubbleMount = () => { + startPolling(); + if (api.settings.saveLocally) void syncArchive(); + }; + const onListMount = () => { + if (api.settings.saveLocally) void syncArchive(); + }; + + api.seqta.onMount(BUBBLE_SELECTOR, onBubbleMount); + api.seqta.onMount(LIST_SELECTOR, onListMount); + return () => { stopPolling(); + teardownInjection(); document.removeEventListener("visibilitychange", handleVisibilityChange); + pageChangeUnregister.unregister(); + styleEl.remove(); }; }, }; diff --git a/src/plugins/built-in/notificationCollector/injectArchivedNotifications.ts b/src/plugins/built-in/notificationCollector/injectArchivedNotifications.ts new file mode 100644 index 00000000..15b1ebdb --- /dev/null +++ b/src/plugins/built-in/notificationCollector/injectArchivedNotifications.ts @@ -0,0 +1,133 @@ +import type { PluginAPI } from "../../core/types"; +import ReactFiber from "@/seqta/utils/ReactFiber"; +import { delay } from "@/seqta/utils/delay"; +import { + archivedToApiNotification, + listArchivedNotifications, + type ArchiveMap, + type ArchivesByUser, +} from "./archive"; + +const LIST_SELECTOR = '[class*="notifications__list___"]'; +const ITEMS_SELECTOR = '[class*="notifications__items___"]'; +const ITEM_SELECTOR = '[class*="notifications__item___"]'; +const BACKED_UP_CLASS = "bsplus-notification-backed-up"; +const BACKUP_BADGE_CLASS = "bsplus-notification-backup-badge"; + +function notificationTimestamp(item: Record): number { + const ms = new Date(String(item.timestamp ?? 0)).getTime(); + return Number.isNaN(ms) ? 0 : ms; +} + +function mergeLiveWithArchived( + liveItems: Record[], + archive: ArchiveMap, +): Record[] | null { + const liveIds = new Set(liveItems.map((item) => Number(item.notificationID))); + const missing = listArchivedNotifications(archive) + .filter((item) => !liveIds.has(item.notificationID)) + .map((item) => archivedToApiNotification(item)); + + if (missing.length === 0) return null; + + return [...liveItems, ...missing].sort( + (a, b) => notificationTimestamp(b) - notificationTimestamp(a), + ); +} + +async function tryInjectArchived(archive: ArchiveMap): Promise { + if (!document.querySelector(LIST_SELECTOR)) return false; + + const state = await ReactFiber.find(LIST_SELECTOR).getState(); + if (!state || !Array.isArray(state.items)) return false; + + const liveItems = state.items as Record[]; + const merged = mergeLiveWithArchived(liveItems, archive); + if (!merged) return true; + + const sameOrder = + liveItems.length === merged.length && + liveItems.every( + (item, index) => + Number(item.notificationID) === Number(merged[index]?.notificationID), + ); + if (sameOrder) return true; + + await ReactFiber.find(LIST_SELECTOR).setState({ items: merged }); + return true; +} + +async function injectWithRetries(archive: ArchiveMap) { + for (let attempt = 0; attempt < 10; attempt++) { + if (await tryInjectArchived(archive)) break; + await delay(120); + } + applyBackupBadges(archive); +} + +export function applyBackupBadges(archive: ArchiveMap) { + const backedUpIds = new Set(Object.keys(archive)); + + for (const itemEl of document.querySelectorAll(ITEM_SELECTOR)) { + const id = itemEl.getAttribute("data-id"); + if (!id) continue; + + if (backedUpIds.has(id)) { + itemEl.classList.add(BACKED_UP_CLASS); + if (!itemEl.querySelector(`.${BACKUP_BADGE_CLASS}`)) { + const badge = document.createElement("span"); + badge.className = BACKUP_BADGE_CLASS; + badge.title = "Saved locally"; + badge.textContent = "✓"; + itemEl.appendChild(badge); + } + } else { + itemEl.classList.remove(BACKED_UP_CLASS); + itemEl.querySelector(`.${BACKUP_BADGE_CLASS}`)?.remove(); + } + } +} + +export function mountArchivedNotificationInjection( + api: PluginAPI, { archivesByUser?: ArchivesByUser }>, + getUserKey: () => Promise, +) { + let observer: MutationObserver | null = null; + let injectScheduled = false; + + const scheduleInject = () => { + if (injectScheduled) return; + injectScheduled = true; + window.setTimeout(async () => { + injectScheduled = false; + const userKey = await getUserKey(); + if (!userKey) return; + const archive = api.storage.archivesByUser?.[userKey] ?? {}; + if (Object.keys(archive).length === 0) return; + await injectWithRetries(archive); + }, 60); + }; + + const watchItemsContainer = () => { + const itemsEl = document.querySelector(ITEMS_SELECTOR); + if (!itemsEl) return; + observer?.disconnect(); + observer = new MutationObserver(scheduleInject); + observer.observe(itemsEl, { childList: true }); + }; + + const onNotificationsMount = () => { + scheduleInject(); + watchItemsContainer(); + }; + + api.seqta.onMount(LIST_SELECTOR, onNotificationsMount); + api.seqta.onMount(ITEMS_SELECTOR, onNotificationsMount); + api.storage.onChange("archivesByUser", scheduleInject); + + return () => observer?.disconnect(); +} + +export async function injectArchivedForUser(archive: ArchiveMap): Promise { + await injectWithRetries(archive); +} diff --git a/src/plugins/built-in/notificationCollector/styles.css b/src/plugins/built-in/notificationCollector/styles.css new file mode 100644 index 00000000..794cd98a --- /dev/null +++ b/src/plugins/built-in/notificationCollector/styles.css @@ -0,0 +1,20 @@ +[class*="notifications__item___"].bsplus-notification-backed-up { + position: relative; +} + +.bsplus-notification-backup-badge { + position: absolute; + top: 6px; + right: 6px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--better-main, #22c55e); + color: #fff; + font-size: 9px; + line-height: 14px; + text-align: center; + pointer-events: none; + z-index: 2; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} diff --git a/src/plugins/built-in/profilePicture/ProfilePictureSetting.svelte b/src/plugins/built-in/profilePicture/ProfilePictureSetting.svelte index 57600619..2a35d6b1 100644 --- a/src/plugins/built-in/profilePicture/ProfilePictureSetting.svelte +++ b/src/plugins/built-in/profilePicture/ProfilePictureSetting.svelte @@ -1,25 +1,34 @@ -
value ? null : triggerSelect()} - ondragover={(e) => { e.stopPropagation(); dragging = true }} - ondragleave={() => dragging = false} - ondrop={onDrop} - onkeydown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - triggerSelect() - } - }} - role="button" - tabindex="0" -> - {#if value} - Profile - + + {/if} + +
(value ? null : triggerSelect())} + ondragover={(e) => { e.stopPropagation(); dragging = true }} + ondragleave={() => dragging = false} + ondrop={onDrop} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + triggerSelect() + } }} - >× - {:else} -
- {'\ued47'} - Upload + role="button" + tabindex="0" + > + {#if value} +
+ Local profile + +
+ {:else} +
+ {'\ued47'} + Upload +
+ {/if} + + {#if dragging} +
+ {/if}
- {/if} - - {#if dragging} -
+
+ {#if cloudRefreshError} +

{cloudRefreshError}

{/if}
diff --git a/src/plugins/built-in/profilePicture/index.ts b/src/plugins/built-in/profilePicture/index.ts index 450b567e..4d5ad7b7 100644 --- a/src/plugins/built-in/profilePicture/index.ts +++ b/src/plugins/built-in/profilePicture/index.ts @@ -8,7 +8,7 @@ import ProfilePictureSetting from "./ProfilePictureSetting.svelte"; import { waitForElm } from "@/seqta/utils/waitForElm"; import browser from "webextension-polyfill"; import { cloudAuth } from "@/seqta/utils/CloudAuth"; -import { resolveCloudPfp } from "@/seqta/utils/cloudPfpCache"; +import { resolveCloudPfp, defaultAccountsPfpUrl } from "@/seqta/utils/cloudPfpCache"; import styles from "./styles.css?inline"; import localforage from "localforage"; @@ -64,10 +64,13 @@ const profilePicturePlugin: Plugin = { } const useCloud = api.settings.useCloudPfp; - const pfpUrl = cloudAuth.state.user?.pfpUrl; + const userId = cloudAuth.state.user?.id; + const pfpUrl = + cloudAuth.state.user?.pfpUrl ?? + (userId ? defaultAccountsPfpUrl(userId) : undefined); - if (useCloud && pfpUrl && cloudAuth.state.user?.id) { - const resolved = await resolveCloudPfp(cloudAuth.state.user.id, pfpUrl); + if (useCloud && pfpUrl && userId) { + const resolved = await resolveCloudPfp(userId, pfpUrl); if (resolved) { currentBlobUrl = resolved.src; img = document.createElement("img"); @@ -92,6 +95,13 @@ const profilePicturePlugin: Plugin = { } } + if (api.settings.useCloudPfp && cloudAuth.state.isLoggedIn) { + const { pullCloudProfilePictureFromServer } = await import( + "@/seqta/utils/cloudPfpSync" + ); + await pullCloudProfilePictureFromServer(); + } + await applyProfileImage(); const onLocalPictureUpdated = () => { @@ -114,11 +124,9 @@ const profilePicturePlugin: Plugin = { }); const useCloudUnreg = api.settings.onChange("useCloudPfp", (enabled: boolean) => { - if (enabled) { - void import("@/seqta/utils/cloudPfpSync").then(({ syncLocalProfilePictureToCloud }) => - syncLocalProfilePictureToCloud(), - ); - } + void import("@/seqta/utils/cloudPfpSync").then(({ onUseCloudPfpToggled }) => + onUseCloudPfpToggled(enabled), + ); void applyProfileImage(); }); diff --git a/src/plugins/built-in/themes/theme-manager.ts b/src/plugins/built-in/themes/theme-manager.ts index 73eeb1da..106ce06d 100644 --- a/src/plugins/built-in/themes/theme-manager.ts +++ b/src/plugins/built-in/themes/theme-manager.ts @@ -17,6 +17,12 @@ import { clearCustomThemeAdaptiveCssVariables, setCustomThemeAdaptiveCssVariables, } from "@/seqta/ui/colors/customThemeAdaptiveBindings"; +import { + clearThemeInPage, + syncThemeToPage, + type ThemePageSyncInput, +} from "@/seqta/utils/patchThemeImagesPageContext"; +import { verboseDebug, verboseInfo } from "@/utils/verboseLog"; import { clearThemeRuntime, injectThemeDom, @@ -26,6 +32,11 @@ import { validateThemeDom, validateThemeScript, } from "./theme-runtime"; +import { + base64ToBlob, + blobToBase64Data, + stripBase64Prefix, +} from "./themeImageUrl"; type ThemeContent = { id: string; @@ -56,18 +67,15 @@ export type InstallThemeMeta = { export class ThemeManager { private static instance: ThemeManager; private currentTheme: CustomTheme | null = null; - private styleElement: HTMLStyleElement | null = null; - private previewStyleElement: HTMLStyleElement | null = null; private previousImageVariableNames: string[] = []; + private lastSyncedImageKey: string | null = null; private originalPreviewColor: string | null = null; private originalPreviewTheme: boolean | null = null; - private imageUrlCache: Map = new Map(); private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 }; private storeUpdateCheckRunning = false; - private headObserver: MutationObserver | null = null; private constructor() { - console.debug("[ThemeManager] Initializing..."); + verboseDebug("[ThemeManager] Initializing..."); } public static getInstance(): ThemeManager { @@ -88,7 +96,7 @@ export class ThemeManager { * Get a theme by ID from storage */ public async getTheme(themeId: string): Promise { - console.debug("[ThemeManager] Getting theme:", themeId); + verboseDebug("[ThemeManager] Getting theme:", themeId); try { const theme = (await localforage.getItem(themeId)) as CustomTheme; return theme; @@ -164,17 +172,17 @@ export class ThemeManager { * Disable the current theme without deleting it */ public async disableTheme(): Promise { - console.debug("[ThemeManager] Disabling current theme"); + verboseDebug("[ThemeManager] Disabling current theme"); try { if (!this.currentTheme) { - console.debug("[ThemeManager] No theme to disable"); + verboseDebug("[ThemeManager] No theme to disable"); return; } await this.removeTheme(this.currentTheme); this.currentTheme = null; settingsState.selectedTheme = ""; - console.debug("[ThemeManager] Theme disabled successfully"); + verboseDebug("[ThemeManager] Theme disabled successfully"); } catch (error) { console.error("[ThemeManager] Error disabling theme:", error); } @@ -211,7 +219,7 @@ export class ThemeManager { * Initialize the theme system and restore previous state */ public async initialize(): Promise { - console.debug("[ThemeManager] Starting initialization"); + verboseDebug("[ThemeManager] Starting initialization"); try { const neumorphicThemeId = "9a9786d1-b5fc-4a91-8c7a-f8bf7f7679ad"; const migrationCSS = "#title {\nbackground: transparent !important;\n}"; @@ -224,7 +232,7 @@ export class ThemeManager { const themeCreatorOpen = localStorage.getItem("themeCreatorOpen"); if (themeCreatorOpen === "true") { - console.debug( + verboseDebug( "[ThemeManager] Theme creator was open, clearing preview state", ); this.clearPreview(); @@ -232,7 +240,7 @@ export class ThemeManager { } if (settingsState.selectedTheme) { - console.debug( + verboseDebug( "[ThemeManager] Found selected theme, restoring:", settingsState.selectedTheme, ); @@ -249,7 +257,7 @@ export class ThemeManager { * Clean up theme system resources */ public async cleanup(): Promise { - console.debug("[ThemeManager] Cleaning up resources"); + verboseDebug("[ThemeManager] Cleaning up resources"); try { if (this.currentTheme) { await this.removeTheme(this.currentTheme, false); @@ -263,7 +271,7 @@ export class ThemeManager { * Set and apply a theme by ID */ public async setTheme(themeId: string, applyViewTransition: boolean = true): Promise { - console.debug("[ThemeManager] Setting theme:", themeId); + verboseDebug("[ThemeManager] Setting theme:", themeId); try { const theme = (await localforage.getItem(themeId)) as CustomTheme; if (!theme) { @@ -273,7 +281,7 @@ export class ThemeManager { // Store original settings before applying new theme if (!settingsState.selectedTheme) { - console.debug("[ThemeManager] Storing original settings"); + verboseDebug("[ThemeManager] Storing original settings"); settingsState.originalSelectedColor = settingsState.selectedColor; if (shouldForceThemeAppearance(theme)) { @@ -286,7 +294,7 @@ export class ThemeManager { await this.applyViewTransition(async () => { // Remove current theme if exists if (this.currentTheme) { - console.debug("[ThemeManager] Removing current theme"); + verboseDebug("[ThemeManager] Removing current theme"); await this.removeThemeWithoutTransition(this.currentTheme); } @@ -298,7 +306,7 @@ export class ThemeManager { } else { // Remove current theme if exists if (this.currentTheme) { - console.debug("[ThemeManager] Removing current theme"); + verboseDebug("[ThemeManager] Removing current theme"); await this.removeThemeWithoutTransition(this.currentTheme); } @@ -317,7 +325,7 @@ export class ThemeManager { * Apply theme components (CSS, images, settings) */ private async applyTheme(theme: CustomTheme): Promise { - console.debug("[ThemeManager] Applying theme:", theme.name); + verboseDebug("[ThemeManager] Applying theme:", theme.name); try { // Run the theme script BEFORE injecting CustomCSS so any state the // script publishes (e.g. `data-city-state` and `--city-sky-color` for @@ -326,40 +334,34 @@ export class ThemeManager { // its previous state before snapping to the right colour. runThemeScript(theme.themeScript); - // Apply custom CSS - if (theme.CustomCSS) { - console.debug("[ThemeManager] Applying custom CSS"); - this.applyCustomCSS(theme.CustomCSS); - } - - // Apply custom images - if (theme.CustomImages) { - console.debug("[ThemeManager] Applying custom images"); - theme.CustomImages.forEach((image) => { - const imageUrl = URL.createObjectURL(image.blob); - document.documentElement.style.setProperty( - "--" + image.variableName, - `url(${imageUrl})`, - ); - }); + // Custom CSS + images must be applied in page context (Firefox). + verboseDebug("[ThemeManager] Applying theme styles in page context"); + await syncThemeToPage({ + customCss: theme.CustomCSS || "", + images: theme.CustomImages ?? [], + }); + if (theme.CustomImages?.length) { + this.lastSyncedImageKey = this.imageSyncKey(theme.CustomImages); + } else { + this.lastSyncedImageKey = null; } // Apply theme settings if (shouldForceThemeAppearance(theme)) { const dark = getForcedDarkMode(theme); - console.debug("[ThemeManager] Setting dark mode:", dark); + verboseDebug("[ThemeManager] Setting dark mode:", dark); settingsState.DarkMode = dark; } // Use the stored selected color if available, otherwise use the default if (theme.selectedColor) { - console.debug( + verboseDebug( "[ThemeManager] Restoring saved color:", theme.selectedColor, ); settingsState.selectedColor = theme.selectedColor; } else if (theme.defaultColour) { - console.debug( + verboseDebug( "[ThemeManager] Using default color:", theme.defaultColour, ); @@ -381,7 +383,7 @@ export class ThemeManager { theme: CustomTheme, clearSelectedTheme: boolean = true, ): Promise { - console.debug("[ThemeManager] Removing theme with transition:", theme.name); + verboseDebug("[ThemeManager] Removing theme with transition:", theme.name); try { await this.applyViewTransition(async () => { await this.removeThemeWithoutTransition(theme, clearSelectedTheme); @@ -398,37 +400,13 @@ export class ThemeManager { theme: CustomTheme, clearSelectedTheme: boolean = true, ): Promise { - console.debug("[ThemeManager] Removing theme:", theme.name); + verboseDebug("[ThemeManager] Removing theme:", theme.name); try { clearThemeRuntime(); - // Disconnect the head observer BEFORE removing the style element, - // otherwise the removal fires the observer and it would no-op only - // because the style is already gone — wasted work, but harmless. - this.disconnectStyleObserver(); - - // Remove custom CSS - if (this.styleElement) { - console.debug("[ThemeManager] Removing custom CSS"); - this.styleElement.remove(); - this.styleElement = null; - } - - // Remove custom images - if (theme.CustomImages) { - console.debug("[ThemeManager] Removing custom images"); - theme.CustomImages.forEach((image) => { - const value = document.documentElement.style.getPropertyValue( - "--" + image.variableName, - ); - if (value) { - URL.revokeObjectURL(value.slice(4, -1)); // Remove url() wrapper - } - document.documentElement.style.removeProperty( - "--" + image.variableName, - ); - }); - } + verboseDebug("[ThemeManager] Removing theme page styles"); + clearThemeInPage(); + this.lastSyncedImageKey = null; if (this.currentTheme) { // Store the current color with the theme before removing it @@ -449,7 +427,7 @@ export class ThemeManager { // Restore original settings if (settingsState.originalSelectedColor) { - console.debug( + verboseDebug( "[ThemeManager] Restoring original color:", settingsState.originalSelectedColor, ); @@ -457,7 +435,7 @@ export class ThemeManager { } if (settingsState.originalDarkMode !== undefined) { - console.debug( + verboseDebug( "[ThemeManager] Restoring original dark mode:", settingsState.originalDarkMode, ); @@ -476,58 +454,21 @@ export class ThemeManager { } /** - * Apply custom CSS to the document. The `