Compare commits

..

1 Commits

Author SHA1 Message Date
AdenMGB 6c08bea5f7 feat: start secuirity thingo 2026-05-08 17:18:23 +09:30
218 changed files with 3597 additions and 23834 deletions
+22 -21
View File
@@ -5,30 +5,31 @@
"es2021": true,
"node": true
},
"extends": ["eslint:recommended"],
"parser": "@typescript-eslint/parser",
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint", "import"],
"ignorePatterns": ["**/*.d.ts"],
"globals": {
"__ENABLE_GH_RELEASE_UPDATE_CHECK__": "readonly",
"__GH_RELEASE_REPO__": "readonly",
"__UPDATE_CHANNEL__": "readonly",
"__BUILD_LABEL__": "readonly"
},
"rules": {
"no-unused-vars": "off",
"no-undef": "off",
"no-useless-escape": "off",
"no-prototype-builtins": "off",
"no-empty": "off",
"no-case-declarations": "off",
"no-irregular-whitespace": "off",
"sort-imports": "off",
"import/extensions": "off",
"no-async-promise-executor": "off"
}
// allow importing ts extensions
"sort-imports": [
"error",
{
"ignoreCase": true,
"ignoreDeclarationSort": true,
"ignoreMemberSort": false,
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
}
],
"import/extensions": [
"error",
"ignorePackages",
{
"js": "never",
"ts": "never",
"tsx": "never"
}
]
},
"plugins": ["import"]
}
@@ -1,75 +0,0 @@
name: Build Extension
description: Install dependencies, build Chrome and Firefox extensions, and package zips.
inputs:
gh_release_update_check:
description: Enable GitHub release update checker in the built extension.
required: false
default: "false"
update_channel:
description: Update channel baked into the build (stable or nightly).
required: false
default: stable
build_label:
description: Optional build label for nightly display (e.g. UTC build date).
required: false
default: ""
release_repo:
description: GitHub repo slug for the update checker (owner/name).
required: false
default: BetterSEQTA/BetterSEQTA-Plus
outputs:
version:
description: Version from package.json
value: ${{ steps.version.outputs.version }}
chrome_zip:
description: Path to the Chrome extension zip
value: ${{ steps.zip.outputs.chrome_zip }}
firefox_zip:
description: Path to the Firefox extension zip
value: ${{ steps.zip.outputs.firefox_zip }}
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: Read version
id: version
shell: bash
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Build extension
shell: bash
env:
GH_RELEASE_UPDATE_CHECK: ${{ inputs.gh_release_update_check }}
UPDATE_CHANNEL: ${{ inputs.update_channel }}
GH_RELEASE_REPO: ${{ inputs.release_repo }}
BUILD_LABEL: ${{ inputs.build_label }}
run: npm run build
- name: Package zips
id: zip
shell: bash
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"
-1
View File
@@ -1 +0,0 @@
Experimental nightly build from `main`. May break. **Do not upload to Chrome Web Store or Firefox Add-ons.** Download, replace your sideloaded copy, and reload the extension.
+2
View File
@@ -3,6 +3,8 @@ name: NodeJS Build
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
-53
View File
@@ -1,53 +0,0 @@
# Nightly release workflow — updates the same "nightly" release with fresh builds from main.
# Runs only on BetterSEQTA/BetterSEQTA-Plus. Uses the default GITHUB_TOKEN.
name: Nightly Release
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
permissions:
contents: write
env:
NIGHTLY_TAG: nightly
jobs:
nightly:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set build date
id: build_date
run: echo "date=$(date -u +'%Y-%m-%d')" >> "$GITHUB_OUTPUT"
- name: Build extension
id: build
uses: ./.github/actions/build-extension
with:
gh_release_update_check: "true"
update_channel: nightly
build_label: ${{ steps.build_date.outputs.date }}
release_repo: ${{ github.repository }}
- name: Ensure nightly release exists
run: |
TITLE="Nightly (${{ steps.build_date.outputs.date }})"
if ! gh release view "${{ env.NIGHTLY_TAG }}" 2>/dev/null; then
gh release create "${{ env.NIGHTLY_TAG }}" \
--prerelease \
--title "$TITLE" \
--notes-file .github/nightly-release-notes.md
else
gh release edit "${{ env.NIGHTLY_TAG }}" --title "$TITLE"
fi
- name: Upload nightly assets
run: |
gh release upload "${{ env.NIGHTLY_TAG }}" \
--clobber \
"${{ steps.build.outputs.chrome_zip }}" \
"${{ steps.build.outputs.firefox_zip }}"
-35
View File
@@ -1,35 +0,0 @@
name: PR CI
on:
pull_request:
branches: ["main"]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Lint
run: npm run lint
env:
ESLINT_USE_FLAT_CONFIG: "false"
- name: Unit tests
run: npm test
- name: Build extension
uses: ./.github/actions/build-extension
with:
gh_release_update_check: "false"
- name: Smoke tests
run: npm run test:smoke
-56
View File
@@ -1,56 +0,0 @@
# Manual release workflow only — runs on BetterSEQTA/BetterSEQTA-Plus via workflow_dispatch.
# Bump package.json version on main, confirm when dispatching, then run.
# Uses the default GITHUB_TOKEN (contents: write).
name: Release
on:
workflow_dispatch:
inputs:
version_updated:
description: I have already updated the version in package.json
type: boolean
required: true
default: false
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Confirm version was updated
if: inputs.version_updated != true
run: |
echo "Check the confirmation box: you must update package.json version before releasing."
exit 1
- uses: actions/checkout@v4
- name: Read version
id: version
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Build extension
id: build
uses: ./.github/actions/build-extension
with:
gh_release_update_check: "true"
update_channel: stable
release_repo: ${{ github.repository }}
- name: Create release if missing
run: |
if ! gh release view "${{ steps.version.outputs.version }}" 2>/dev/null; then
gh release create "${{ steps.version.outputs.version }}" \
--title "${{ steps.version.outputs.version }}" \
--notes "Edit this release description on GitHub."
fi
- name: Upload release assets
run: |
gh release upload "${{ steps.version.outputs.version }}" \
--clobber \
"${{ steps.build.outputs.chrome_zip }}" \
"${{ steps.build.outputs.firefox_zip }}"
-1
View File
@@ -18,7 +18,6 @@ betterseqtaplus-safari/
.million/
.vscode/
.cursor/
**/.DS_Store
.parcel-cache
.env
+178 -1041
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6 -12
View File
@@ -28,7 +28,7 @@ Use the same **access tokens** issued by the existing BetterSEQTA+ OAuth flows (
### `PUT /api/bsplus/settings/sync`
Upserts the callers settings backup. The server **merges** `data` into the stored JSON document; keys omitted from the patch are **not** deleted.
Upserts the callers settings backup.
**Request body (JSON):**
@@ -37,14 +37,12 @@ Upserts the callers settings backup. The server **merges** `data` into the st
"schemaVersion": 1,
"themeId": "uuid-string-or-empty",
"data": {
"DarkMode": true,
"...": "flat key-value map mirroring extension storage (see Payload shape)",
"selectedTheme": "uuid-or-empty-string"
}
}
```
The extension sends a **sparse patch**: only keys that changed since the last successful upload (or, on first upload, keys that differ from schema defaults). A full snapshot is not required.
- **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations.
- **`themeId`**: optional but recommended duplicate of **`data.selectedTheme`**. Should be the UUID of the **installed** BetterSEQTA store theme (`selectedTheme`). This may be a normal theme id **or** a **flavour (slave) variant** id from themes with **`flavours[]`** — the extension uses it after restore to prefetch `theme.json` when missing locally (same **`GET …/themes/{id}/download`** as the store UI). Persist and return **`themeId`** in sync with **`data.selectedTheme`**.
- **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`).
@@ -53,12 +51,11 @@ The extension sends a **sparse patch**: only keys that changed since the last su
```json
{
"updated_at": "2026-04-07T12:00:00.000Z",
"patch": { "DarkMode": true }
"updated_at": "2026-04-07T12:00:00.000Z"
}
```
`updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields. Optional `patch` may echo the merged keys applied server-side.
`updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields.
**Error responses:** Standard JSON error body if applicable, e.g. `{ "error": "message" }`, with appropriate HTTP status (`401`, `413`, `422`, etc.).
@@ -106,8 +103,7 @@ Unique constraint on `user_id`.
## Semantics
- **Merge on PUT:** each `PUT` deep-merges `data` into the stored backup for that user. Keys not present in the request body remain unchanged on the server.
- **Full document on GET:** restore and poll download still receive the complete hydrated settings object.
- **Last write wins:** each `PUT` replaces the stored backup for that user.
- **Optional later:** `If-Unmodified-Since` or a `revision` field for conflict detection (not required for v1).
## Security and privacy
@@ -124,8 +120,6 @@ The backup is a flat JSON map of **`chrome.storage.local`** keys. It does **not*
- **OAuth / session keys** — `bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`, plus legacy `cloudAccessToken` / `cloudUsername`.
- **Assessment Averages caches** — `plugin.assessments-average.storage.assessments`, `plugin.assessments-average.storage.weightings` (school assessment data).
- **Keys under** `plugin.global-search.storage.*` — reserved so any future plugin storage cache there is not synced.
- **Grade Analytics** — keys under `bsplus.analytics.*` (synced assessment cache and per-school chart preferences).
- **`bsplus_cloud_settings_last_uploaded_snapshot`** — client-only normalized map last acked by PUT; used to compute sparse upload patches (not part of the cloud backup blob).
- **`bsplus_cloud_settings_known_remote_updated_at`** — client-only watermark for auto-sync (not part of the cloud backup blob).
On restore, those keys are **not** taken from the server; the device keeps its current local values.
@@ -141,6 +135,6 @@ This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages,
## Client reference (extension)
- Upload (sparse patch): `buildUploadPatch` / `putSettingsOnce` in `src/background/cloudSettingsAutoSync.ts`; baseline from `bsplus_cloud_settings_last_uploaded_snapshot` or schema defaults; dev full export via `getSnapshotForUpload` / `buildUploadPayload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, client-only metadata — includes **`themeId`** aligned with **`selectedTheme`**).
- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, **`bsplus_pending_theme_ensure_after_cloud`**, and **`bsplus_cloud_settings_known_remote_updated_at`** — includes **`themeId`** aligned with **`selectedTheme`**).
- Download: resolve id via **`resolveThemeIdForPostSyncDownload`** → **`applyDownloadedEnvelope`** after `GET` → prefetch theme blobs in page context if needed (**`prepareThemeAfterCloudSync`** in **`ThemeManager`**) → reload SEQTA tabs; local auth keys, sensitive device keys, client-only watermark, and **`bsplus_pending_theme_ensure_after_cloud`** semantics preserved as documented above.
- Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`.
-1
View File
@@ -9,7 +9,6 @@
- [Documentation home](https://docs.betterseqta.org/)
- [Installation](https://docs.betterseqta.org/install/)
- [Contributing](https://docs.betterseqta.org/contributing/)
- [GitHub releases & CI](RELEASES.md) — workflows, nightly builds, update detector
- [Architecture](https://docs.betterseqta.org/architecture/)
- [Contribution guidelines (repository)](../CONTRIBUTING.md)
- [Troubleshooting](https://docs.betterseqta.org/troubleshooting/)
-267
View File
@@ -1,267 +0,0 @@
# GitHub releases, CI, and the update detector
BetterSEQTA+ is distributed on the Chrome Web Store and Firefox Add-ons, but some users sideload builds from GitHub. This document explains how automated builds, releases, and the in-extension update badge work.
All published releases target the upstream repository: **[BetterSEQTA/BetterSEQTA-Plus](https://github.com/BetterSEQTA/BetterSEQTA-Plus)**.
---
## Overview
```mermaid
flowchart TB
subgraph ci [Continuous integration]
PrCi[pr-ci.yml — every PR]
Mvp[mvp.yml — push to main]
end
subgraph releases [GitHub releases]
Manual[release.yml — manual stable]
Nightly[nightly.yml — daily cron]
end
subgraph output [Outputs]
Zips["betterseqtaplus-VERSION-chrome.zip\nbetterseqtaplus-VERSION-firefox.zip"]
GhStable["Release tag: 3.7.0"]
GhNightly["Release tag: nightly"]
Badge[Update badge in settings]
end
PrCi -->|build only| NoRelease[No release]
Mvp -->|artifact| DistZip[dist.zip artifact]
Manual --> Zips
Manual --> GhStable
Manual --> Badge
Nightly --> Zips
Nightly --> GhNightly
Nightly --> Badge
```
| Workflow | Trigger | Creates a release? | Update detector in build? |
|----------|---------|------------------|---------------------------|
| `pr-ci.yml` | Every pull request to `main` | No | No |
| `mvp.yml` | Every push to `main` | No | No |
| `release.yml` | Manual (`workflow_dispatch`) | Yes — stable version tag | Yes — stable channel |
| `nightly.yml` | Daily at 03:00 UTC + manual | Yes — reuses `nightly` tag | Yes — nightly channel |
---
## Shared build action
All release and CI builds that need packaged extensions use the composite action at [`.github/actions/build-extension/action.yml`](../.github/actions/build-extension/action.yml).
It:
1. Installs dependencies (`npm install --legacy-peer-deps`)
2. Runs `npm run build` (Chrome then Firefox via Vite)
3. Zips each unpacked folder into sideload-ready archives:
- `dist/betterseqtaplus-{version}-chrome.zip`
- `dist/betterseqtaplus-{version}-firefox.zip`
The `{version}` comes from `package.json`.
### Build-time flags (release builds only)
Release and nightly workflows pass environment variables into Vite, which bakes them into the extension at compile time via `define` in [`vite.config.ts`](../vite.config.ts):
| Variable | Stable release | Nightly | PR / local dev |
|----------|----------------|---------|----------------|
| `GH_RELEASE_UPDATE_CHECK` | `true` | `true` | `false` / unset |
| `UPDATE_CHANNEL` | `stable` | `nightly` | `stable` (unused) |
| `GH_RELEASE_REPO` | `BetterSEQTA/BetterSEQTA-Plus` | same | same |
| `BUILD_LABEL` | empty | UTC build date (`YYYY-MM-DD`) | empty |
When `GH_RELEASE_UPDATE_CHECK` is not `true`, the update-checker code is tree-shaken out of the bundle. PR CI builds and local `npm run build` do **not** include the update badge.
To test a release-style build locally:
```bash
# PowerShell
$env:GH_RELEASE_UPDATE_CHECK="true"
$env:UPDATE_CHANNEL="stable"
npm run build
# bash
GH_RELEASE_UPDATE_CHECK=true UPDATE_CHANNEL=stable npm run build
```
---
## Stable release (`release.yml`)
**Purpose:** A safe, manual way to publish versioned builds that users can download from GitHub.
**Trigger:** Manual only — Actions → **Release****Run workflow**. There is no schedule or automatic trigger.
When dispatching, check **“I have already updated the version in package.json”**. The workflow will not run without that confirmation.
### Before you run it
1. Merge your changes to `main`.
2. Bump `version` in [`package.json`](../package.json) (e.g. `3.7.0``3.8.0`).
3. Commit and push that bump.
### What the workflow does
1. Aborts if the version confirmation was not checked.
2. Reads the version from `package.json`.
3. Builds Chrome and Firefox with the update detector enabled (stable channel).
4. If no release exists for that tag: creates one (e.g. `3.8.0`) with a placeholder description.
5. If a release already exists for that tag: **only replaces the zip assets** (`--clobber`). Title and body are left unchanged.
6. Uploads `betterseqtaplus-{version}-chrome.zip` and `-firefox.zip`.
On first publish, edit the **title** and **release notes** on GitHub afterward. Re-running for the same version refreshes the files only.
### Downloading and installing
1. Open [github.com/BetterSEQTA/BetterSEQTA-Plus/releases](https://github.com/BetterSEQTA/BetterSEQTA-Plus/releases).
2. Pick the version you want.
3. Download `betterseqtaplus-{version}-chrome.zip` or `-firefox.zip`.
4. Unzip and load the unpacked folder as a temporary extension (Chrome: Extensions → Load unpacked; Firefox: `about:debugging` → Load Temporary Add-on).
**These GitHub builds are for sideloading only. Do not upload them to the Chrome Web Store or Firefox Add-ons.**
---
## Nightly release (`nightly.yml`)
**Purpose:** Continuous experimental builds from `main` so testers always have a single place to get the latest code.
**Trigger:**
- Automatically every day at **03:00 UTC**
- Manually via Actions → **Nightly Release****Run workflow**
### What the workflow does
1. Builds from the current `main` branch with the update detector enabled (nightly channel).
2. Uses a fixed release tag: **`nightly`** (marked as prerelease).
3. On first run: creates the `nightly` release with the text in [`.github/nightly-release-notes.md`](../.github/nightly-release-notes.md).
4. On every run: sets the release title to **`Nightly (YYYY-MM-DD)`** (UTC build date) and **replaces** the zip assets on the same release (`--clobber`). The release body is not rewritten.
5. Packages zips as `betterseqtaplus-nightly-{date}-chrome.zip` / `-firefox.zip`.
The nightly release body warns that builds are experimental and must not be uploaded to extension stores.
---
## PR CI (`pr-ci.yml`)
**Purpose:** Verify that every pull request builds cleanly in a fresh environment.
**Trigger:** Every pull request targeting `main`.
**Steps:**
1. `npm install --legacy-peer-deps`
2. `npm run lint` (ESLint on `src/**/*.{js,ts}`)
3. Build via the shared action with **no** update detector
No release is created and no artifacts are published for end users.
---
## Push CI (`mvp.yml`)
**Purpose:** Build verification when code lands on `main`.
**Trigger:** Push to `main` only (not pull requests — those use `pr-ci.yml`).
Uploads a `dist.zip` artifact containing the full `dist/` folder for debugging. No release, no update detector.
---
## Authentication
Release workflows run **only on [BetterSEQTA/BetterSEQTA-Plus](https://github.com/BetterSEQTA/BetterSEQTA-Plus)**. They use the default `GITHUB_TOKEN` with `contents: write` — no extra secrets or PATs required.
---
## Update detector (in-extension)
GitHub release builds include a small feature that tells sideload users when a newer build is available, so they do not have to check GitHub manually.
### Where it appears
In the **settings popup** header (top-right): an amber **“Update available — X.X.X”** pill when an update exists, plus a muted line:
> GitHub release build — do not upload to extension stores.
Clicking the badge opens the relevant GitHub releases page.
### How it checks for updates
Implementation: [`src/utils/githubReleaseUpdate.ts`](../src/utils/githubReleaseUpdate.ts)
**Stable channel** (from `release.yml` builds):
1. Fetches releases from the GitHub API for `BetterSEQTA/BetterSEQTA-Plus`.
2. Ignores the `nightly` tag and any non-semver tags.
3. Finds the highest semver tag.
4. Compares it to `browser.runtime.getManifest().version`.
5. Shows the badge if the remote version is newer.
**Nightly channel** (from `nightly.yml` builds):
1. Fetches the `nightly` release.
2. Compares its `published_at` timestamp to `lastSeenNightlyPublishedAt` in extension storage.
3. On first install, records the current publish time without showing a badge.
4. Shows **“Update available — nightly (YYYY-MM-DD)”** when a newer nightly has been published.
Checks are throttled to once every **6 hours** per browser profile (`localStorage` key `bsplus_lastGhReleaseCheck`). Recent results are cached in memory for the session.
### Dev override (testing)
To test the badge without publishing a real release:
1. Open settings and unlock **dev mode** (click the logo, type `dev`).
2. In the developer section, set **GitHub latest version override** to a version higher than your installed one (e.g. `9.9.9`).
3. Reopen settings — the badge should appear.
This only applies when dev mode is on. Clear the field to return to normal API checks.
---
## File reference
| Path | Role |
|------|------|
| `.github/actions/build-extension/action.yml` | Shared install, build, zip |
| `.github/workflows/release.yml` | Manual stable releases |
| `.github/workflows/nightly.yml` | Nightly releases |
| `.github/workflows/pr-ci.yml` | PR lint + build |
| `.github/workflows/mvp.yml` | Push-to-main build artifact |
| `.github/nightly-release-notes.md` | Static body for the `nightly` release |
| `vite.config.ts` | Injects build-time `define` flags |
| `src/env.d.ts` | TypeScript declarations for those flags |
| `src/utils/githubReleaseUpdate.ts` | Update check logic |
| `src/interface/pages/settings.svelte` | Badge and disclaimer UI |
| `src/interface/pages/settings/general.svelte` | Dev override input |
| `src/types/storage.ts` | `devGhReleaseVersionOverride`, `lastSeenNightlyPublishedAt` |
---
## Quick reference
### Publish a stable release
```text
1. Bump version in package.json on main
2. Actions → Release → Run workflow → confirm version checkbox
3. Edit release title/notes on GitHub (first time only)
4. Re-run with same version to refresh zips without changing release text
```
### Get the latest nightly
```text
https://github.com/BetterSEQTA/BetterSEQTA-Plus/releases/tag/nightly
```
### Verify a PR locally
```bash
npm run lint
npm run build
```
+3 -23
View File
@@ -119,13 +119,12 @@ git checkout -b feature/my-new-feature
If your changes require documentation updates, include them in the same PR.
4. **Run CI checks locally**
4. **Run Tests**
Pull requests trigger **PR CI**, which lints and builds in a clean environment:
Make sure all tests pass before submitting your PR:
```bash
npm run lint
npm run build
npm test
```
5. **Submit Your PR**
@@ -140,25 +139,6 @@ git checkout -b feature/my-new-feature
Once approved, a maintainer will merge your PR.
### GitHub Actions workflows
See [RELEASES.md](RELEASES.md) for a full guide (workflows, sideload installs, and update detector).
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `pr-ci.yml` | Every PR to `main` | Typecheck, lint, and build (no release) |
| `mvp.yml` | Push to `main` | Build and upload `dist.zip` artifact |
| `release.yml` | Manual (`workflow_dispatch`) | Stable release on [BetterSEQTA/BetterSEQTA-Plus](https://github.com/BetterSEQTA/BetterSEQTA-Plus) tagged with `package.json` version |
| `nightly.yml` | Daily cron + manual | Updates the fixed `nightly` prerelease with latest `main` builds |
**Releasing a stable version:** bump `version` in `package.json` on `main`, then manually run the **Release** workflow and confirm the version checkbox. Edit the release title and notes on GitHub after the first publish. Re-running for the same tag only updates the zip files. Assets are `betterseqtaplus-{version}-chrome.zip` and `-firefox.zip`.
**Nightly builds** replace assets on the same `nightly` release. The release body warns that builds are experimental and must not be uploaded to extension stores.
**GitHub release builds** include an in-extension update checker (settings header badge). **PR CI and local dev builds do not.**
Release workflows are dispatched only on the main **BetterSEQTA/BetterSEQTA-Plus** repository and use the default `GITHUB_TOKEN`.
### Coding Standards
We follow TypeScript best practices and have a consistent code style:
-4
View File
@@ -9,10 +9,6 @@ export default {
transform: {
'^.+\\.ts$': 'ts-jest',
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^webextension-polyfill$': '<rootDir>/src/test/mocks/webextension-polyfill.ts',
},
moduleFileExtensions: ['ts', 'js', 'json'],
collectCoverageFrom: [
'src/**/*.ts',
-19
View File
@@ -1,19 +0,0 @@
import type { Plugin } from "vite";
/**
* crxjs 2.6.x only replaces the first `__LIVE_RELOAD__` in `@crx/client-worker`,
* which crashes the service worker when the dev server reconnects.
*/
export default function fixCrxWorkerLiveReload(): Plugin {
return {
name: "fix-crx-worker-live-reload",
apply: "serve",
enforce: "post",
transform(code, id) {
if (!id.includes("@crx/client-worker") || !code.includes("__LIVE_RELOAD__")) {
return;
}
return code.replaceAll("__LIVE_RELOAD__", "true");
},
};
}
+13 -22
View File
@@ -1,6 +1,6 @@
{
"name": "betterseqtaplus",
"version": "3.7.1",
"version": "3.6.4",
"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",
@@ -17,12 +17,10 @@
"build:dev": "cross-env MODE=chrome SOURCEMAP=true vite build && cross-env MODE=firefox SOURCEMAP=true vite build",
"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:smoke": "node scripts/smoke-test.mjs",
"release": "gh release create $npm_package_version --repo BetterSEQTA/BetterSEQTA-Plus ./dist/*.zip --generate-notes",
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
"publish": "bun lib/publish.js --b",
"zip": "bedframe zip"
"zip": "bedframe zip",
"test": "vitest run"
},
"targets": {
"prod": {
@@ -42,21 +40,16 @@
"@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.6.1",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.1.8",
"@types/jest": "^30.0.0",
"@crxjs/vite-plugin": "^2.4.0",
"@types/jsdom": "^28.0.1",
"@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"cross-env": "^10.0.0",
"dependency-cruiser": "^17.0.1",
"eslint": "^9.33.0",
"eslint-plugin-import": "^2.31.0",
"glob": "^11.0.1",
"jest": "^30.4.2",
"jsdom": "^29.1.1",
"mime-types": "^3.0.1",
"prettier": "^3.5.3",
"process": "^0.11.10",
@@ -65,8 +58,8 @@
"sass-loader": "^16.0.5",
"semver": "^7.7.1",
"tailwindcss": "3",
"ts-jest": "^29.4.11",
"url": "^0.11.4"
"url": "^0.11.4",
"vitest": "^4.1.5"
},
"dependencies": {
"@bedframe/core": "^0.1.0",
@@ -77,7 +70,7 @@
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.1.4",
@@ -94,10 +87,8 @@
"canvas-confetti": "^1.9.3",
"codemirror": "^6.0.1",
"color": "^5.0.0",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"dompurify": "^3.2.4",
"embeddia": "^1.3.0",
"embeddia": "^1.2.1",
"embla-carousel-autoplay": "^8.5.2",
"embla-carousel-svelte": "^8.5.2",
"esbuild": "^0.25.3",
@@ -105,7 +96,7 @@
"flexsearch": "^0.8.147",
"fuse.js": "^7.1.0",
"idb": "^8.0.2",
"layerchart": "2.0.0-next.27",
"jspdf": "^4.2.1",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"mathjs": "^14.4.0",
@@ -122,7 +113,7 @@
"svelte": "^5.46.4",
"typescript": "^5.8.2",
"uuid": "^11.1.0",
"vite": "^6.2.1",
"vite": "^8.0.5",
"webextension-polyfill": "^0.12.0"
}
}
-49
View File
@@ -1,49 +0,0 @@
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
function fail(message) {
console.error(`[smoke-test] ${message}`);
process.exit(1);
}
function assertManifest(browserDir) {
const manifestPath = path.join(root, "dist", browserDir, "manifest.json");
if (!fs.existsSync(manifestPath)) {
fail(`Missing ${manifestPath} — run npm run build first`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (!manifest.manifest_version) fail(`${browserDir} manifest missing manifest_version`);
if (!manifest.name) fail(`${browserDir} manifest missing name`);
if (!manifest.version) fail(`${browserDir} manifest missing version`);
const sw =
manifest.background?.service_worker ??
manifest.background?.scripts?.[0] ??
null;
if (sw) {
const swPath = path.join(root, "dist", browserDir, sw);
if (!fs.existsSync(swPath)) {
fail(`${browserDir} service worker not found on disk: ${sw}`);
}
}
}
function assertAssets(browserDir) {
const assetsDir = path.join(root, "dist", browserDir, "assets");
if (!fs.existsSync(assetsDir)) {
fail(`Missing assets directory: dist/${browserDir}/assets`);
}
const jsFiles = fs.readdirSync(assetsDir).filter((f) => f.endsWith(".js"));
if (jsFiles.length === 0) {
fail(`No JS assets in dist/${browserDir}/assets`);
}
}
for (const browser of ["chrome", "firefox"]) {
assertManifest(browser);
assertAssets(browser);
}
console.log("[smoke-test] dist/chrome and dist/firefox look OK");
+3 -4
View File
@@ -6,7 +6,7 @@ import documentLoadCSS from "@/css/documentload.scss?inline";
import icon48 from "@/resources/icons/icon-48.png?base64";
import browser from "webextension-polyfill";
import { init as Monofile } from "@/plugins/monofile";
import * as plugins from "@/plugins";
import { main } from "@/seqta/main";
import { delay } from "./seqta/utils/delay";
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
@@ -104,11 +104,10 @@ async function init() {
}
await main();
Monofile();
plugins.Monofile();
if (settingsState.onoff) {
const { initializePlugins } = await import("@/plugins/runtime");
await initializePlugins();
await plugins.initializePlugins();
}
if (settingsState.devMode) {
+117 -345
View File
@@ -1,8 +1,5 @@
import browser from "webextension-polyfill";
import semver from "semver";
import type { SettingsState } from "@/types/storage";
import { getDefaultSettingsState } from "@/seqta/utils/defaultSettings";
import { ensureSyncableStorageDefaults } from "@/seqta/utils/ensureSyncableStorageDefaults";
import { fetchNews } from "./background/news";
import {
initCloudSettingsAutoSync,
@@ -10,24 +7,7 @@ import {
performCloudSettingsUploadWithRetry,
requestCloudSettingsDebouncedUpload,
runCloudSettingsPoll,
withSuppressedCloudAutoUpload,
} from "./background/cloudSettingsAutoSync";
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
/**
* Session-only dev-mode override of the content API base.
*
* Stored in a module-level variable (not `chrome.storage`) so it is wiped
* automatically when the browser/service-worker process restarts. Content
* scripts re-sync this on every page load via `setDevApiBase` so the value
* survives transient service-worker terminations within the same browser
* session.
*/
const DEFAULT_API_BASE = "https://betterseqta.org";
let DEV_API_BASE: string | null = null;
function apiBase(): string {
return DEV_API_BASE ?? DEFAULT_API_BASE;
}
function reloadSeqtaPages() {
const result = browser.tabs.query({});
@@ -47,140 +27,52 @@ function reloadSeqtaPages() {
/** Callback for sending a response back to the message sender */
type MessageSender = { (response?: unknown): void };
async function getAccessTokenFromStorage(): Promise<string | null> {
const { bsplus_token } = await browser.storage.local.get("bsplus_token");
return typeof bsplus_token === "string" && bsplus_token.length > 0 ? bsplus_token : null;
}
/** Accept API + GitHub fallback shapes; always return `{ success, data?: { themes } }`. */
function normalizeFetchThemesResponse(json: unknown): {
success: boolean;
data?: { themes: unknown[] };
error?: string;
} {
if (!json || typeof json !== "object") {
return { success: false, error: "Invalid themes response" };
}
const body = json as Record<string, unknown>;
if (body.success === false) {
return {
success: false,
error: typeof body.error === "string" ? body.error : "Failed to fetch themes",
};
}
const data = body.data;
let themes: unknown[] | null = null;
if (data && typeof data === "object" && !Array.isArray(data)) {
const nested = (data as Record<string, unknown>).themes;
if (Array.isArray(nested)) themes = nested;
} else if (Array.isArray(data)) {
themes = data;
}
if (!themes && Array.isArray(body.themes)) {
themes = body.themes;
}
if (!themes) {
return { success: false, error: "Themes list missing from response" };
}
return { success: true, data: { themes } };
}
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
void (async () => {
const token = await getAccessTokenFromStorage();
const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(apiUrl, { cache: "no-store", headers })
.then(async (r) => {
const json = await r.json();
if (!r.ok) {
throw new Error(
(json && typeof json === "object" && "error" in json && typeof (json as { error?: string }).error === "string"
? (json as { error: string }).error
: null) ?? `Themes API HTTP ${r.status}`,
);
}
return normalizeFetchThemesResponse(json);
})
.then(sendResponse)
.catch((err) => {
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
fetch(githubUrl, { cache: "no-store" })
.then(async (r) => {
if (!r.ok) throw new Error(`GitHub fallback HTTP ${r.status}`);
const data = await r.json();
const themes = Array.isArray(data) ? data : (data?.themes ?? []);
return normalizeFetchThemesResponse({ success: true, data: { themes } });
})
.then(sendResponse)
.catch((fallbackErr) => {
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
sendResponse({ success: false, error: fallbackErr?.message });
});
});
})();
const { token } = request;
const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(apiUrl, { cache: "no-store", headers })
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
fetch(githubUrl, { cache: "no-store" })
.then((r) => r.json())
.then((data) => sendResponse({ success: true, data: { themes: data.themes ?? [] } }))
.catch((fallbackErr) => {
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
sendResponse({ success: false, error: fallbackErr?.message });
});
});
return true;
}
function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boolean {
const { themeId } = request;
const { themeId, token } = request;
if (!themeId || typeof themeId !== "string") {
sendResponse({ success: false, error: "Missing themeId" });
return false;
}
void (async () => {
const token = await getAccessTokenFromStorage();
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers })
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.error("[Background] fetchThemeDetails error:", err);
sendResponse({ success: false, error: err?.message });
});
})();
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers })
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.error("[Background] fetchThemeDetails error:", err);
sendResponse({ success: false, error: err?.message });
});
return true;
}
function isTrustedSender(sender?: browser.Runtime.MessageSender): boolean {
if (!sender) return false;
if (sender.id && sender.id !== browser.runtime.id) return false;
const urls = [sender.url, sender.tab?.url].filter(Boolean) as string[];
for (const pageUrl of urls) {
if (/^chrome-extension:\/\//.test(pageUrl) || /^moz-extension:\/\//.test(pageUrl)) {
return true;
}
try {
if (isSeqtaOrigin(new URL(pageUrl).origin)) return true;
} catch {
// try next URL
}
}
return false;
}
function handleFetchFromUrl(
request: any,
sendResponse: MessageSender,
sender?: browser.Runtime.MessageSender,
): boolean {
if (!isTrustedSender(sender)) {
sendResponse({ error: "Unauthorized sender" });
return false;
}
function handleFetchFromUrl(request: any, sendResponse: MessageSender): boolean {
const { url } = request;
if (!url || typeof url !== "string") {
sendResponse({ error: "Missing url" });
return false;
}
if (!isAllowedFetchUrl(url)) {
sendResponse({ error: "URL not allowed" });
return false;
}
fetch(url, { cache: "no-store" })
.then((r) => r.json())
.then((data) => sendResponse({ data }))
@@ -219,15 +111,7 @@ function handleCloudReserveClient(request: any, sendResponse: MessageSender): bo
return true;
}
function handleCloudLogin(
request: any,
sendResponse: MessageSender,
sender?: browser.Runtime.MessageSender,
): boolean {
if (!isTrustedSender(sender)) {
sendResponse({ error: "Unauthorized sender" });
return false;
}
function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
const { client_id, redirect_uri, login, password } = request;
if (!client_id || !redirect_uri || !login || !password) {
sendResponse({ error: "Missing client_id, redirect_uri, login, or password" });
@@ -341,18 +225,10 @@ function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean
return true;
}
function handleCloudSettingsUpload(
request: any,
sendResponse: MessageSender,
sender?: browser.Runtime.MessageSender,
): boolean {
if (!isTrustedSender(sender)) {
sendResponse({ success: false, error: "Unauthorized sender" });
return false;
}
function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = await getAccessTokenFromStorage();
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
@@ -374,18 +250,10 @@ function handleCloudSettingsUpload(
return true;
}
function handleCloudSettingsDownload(
request: any,
sendResponse: MessageSender,
sender?: browser.Runtime.MessageSender,
): boolean {
if (!isTrustedSender(sender)) {
sendResponse({ success: false, error: "Unauthorized sender" });
return false;
}
function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = await getAccessTokenFromStorage();
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
@@ -409,29 +277,22 @@ function handleCloudSettingsDownload(
}
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
const { themeId, action } = request;
if (!themeId) {
sendResponse({ success: false, error: "Theme ID required" });
const { themeId, token, action } = request;
if (!themeId || !token) {
sendResponse({ success: false, error: "Theme ID and token required" });
return false;
}
void (async () => {
const token = await getAccessTokenFromStorage();
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const isFavorite = action === "favorite";
fetch(`${apiBase()}/api/themes/${themeId}/favorite`, {
method: isFavorite ? "POST" : "DELETE",
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.error("[Background] cloudFavorite error:", err);
sendResponse({ success: false, error: err?.message });
});
})();
const isFavorite = action === "favorite";
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, {
method: isFavorite ? "POST" : "DELETE",
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.error("[Background] cloudFavorite error:", err);
sendResponse({ success: false, error: err?.message });
});
return true;
}
@@ -449,24 +310,8 @@ function isSeqtaOrigin(origin: string): boolean {
}
}
function handleSetDevApiBase(
request: any,
_sendResponse: MessageSender,
sender?: browser.Runtime.MessageSender,
): boolean {
if (!isTrustedSender(sender)) return false;
const url = typeof request?.url === "string" ? request.url.trim() : null;
if (url && /^https?:\/\//.test(url)) {
DEV_API_BASE = url.replace(/\/$/, "");
} else {
DEV_API_BASE = null;
}
return false;
}
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
reloadTabs: () => reloadSeqtaPages(),
setDevApiBase: handleSetDevApiBase,
extensionPages: (req) => {
browser.tabs.query({}).then((tabs) => {
for (const tab of tabs) {
@@ -484,20 +329,7 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
},
setDefaultStorage: () => SetStorageValue(getDefaultValues()),
ensureStorageDefaults: (_req, sendResponse) => {
void ensureSyncableStorageDefaults()
.then(() => sendResponse({ ok: true }))
.catch((e) => {
console.warn("[BetterSEQTA+] ensureStorageDefaults failed:", e);
sendResponse({ ok: false });
});
return true;
},
sendNews: (req, sendResponse, sender) => {
if (!isTrustedSender(sender)) {
sendResponse({ error: "Unauthorized sender" });
return false;
}
sendNews: (req, sendResponse) => {
fetchNews(req.source ?? "australia", sendResponse);
return true;
},
@@ -570,127 +402,80 @@ browser.runtime.onMessage.addListener(
},
);
function detectLowEndDevice(): boolean {
// Check for low-end hardware indicators
const lowCoreCount = navigator.hardwareConcurrency && navigator.hardwareConcurrency < 4;
const lowMemory = (navigator as any).deviceMemory && (navigator as any).deviceMemory <= 2;
return lowCoreCount || lowMemory;
}
function getDefaultValues(): SettingsState {
return getDefaultSettingsState();
}
const isLowEndDevice = detectLowEndDevice();
function SetStorageValue(object: SettingsState) {
void withSuppressedCloudAutoUpload(() =>
browser.storage.local.set(object as Record<string, unknown>),
);
}
/** One-time migration for 3.6.5: opt upgraders into Global Search + indexing + transparency defaults. */
const GLOBAL_SEARCH_PLUGIN_SETTINGS_KEY = "plugin.global-search.settings";
const GLOBAL_SEARCH_MIGRATION_VERSION = "3.6.5";
async function migrateGlobalSearchDefaultsFor365Upgrade(
previousVersion: string,
): Promise<void> {
try {
const currRaw = browser.runtime.getManifest().version;
const prev = semver.coerce(previousVersion);
const curr = semver.coerce(currRaw);
if (
prev == null ||
curr == null ||
semver.lt(curr, GLOBAL_SEARCH_MIGRATION_VERSION) ||
!semver.lt(prev, GLOBAL_SEARCH_MIGRATION_VERSION)
) {
return;
}
const got = await browser.storage.local.get(GLOBAL_SEARCH_PLUGIN_SETTINGS_KEY);
const existing = (got[GLOBAL_SEARCH_PLUGIN_SETTINGS_KEY] ?? {}) as Record<
string,
unknown
>;
await browser.storage.local.set({
[GLOBAL_SEARCH_PLUGIN_SETTINGS_KEY]: {
...existing,
return {
onoff: true,
animatedbk: true,
bksliderinput: "50",
transparencyEffects: false,
lessonalert: true,
defaultmenuorder: [],
menuitems: {
assessments: { toggle: true },
courses: { toggle: true },
dashboard: { toggle: true },
documents: { toggle: true },
forums: { toggle: true },
goals: { toggle: true },
home: { toggle: true },
messages: { toggle: true },
myed: { toggle: true },
news: { toggle: true },
notices: { toggle: true },
portals: { toggle: true },
reports: { toggle: true },
settings: { toggle: true },
timetable: { toggle: true },
welcome: { toggle: true },
},
menuorder: [],
subjectfilters: {},
selectedTheme: "",
selectedColor:
"linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)",
originalSelectedColor: "",
DarkMode: true,
animations: !isLowEndDevice,
assessmentsAverage: false,
defaultPage: "home",
shortcuts: [
{
name: "Outlook",
enabled: true,
transparencyEffects: true,
runIndexingOnLoad: true,
passiveIndexing: true,
},
});
console.info(
`[BetterSEQTA+] Migration ${GLOBAL_SEARCH_MIGRATION_VERSION}: Global Search and related settings enabled (from ${previousVersion}).`,
);
} catch (e) {
console.warn("[BetterSEQTA+] Global Search 3.6.5 settings migration failed:", e);
}
{
name: "Office",
enabled: true,
},
{
name: "Google",
enabled: true,
},
],
customshortcuts: [],
lettergrade: false,
newsSource: "australia",
iconOnlySidebar: false,
adaptiveThemeColour: false,
adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true,
autoCloudSettingsSync: true,
};
}
/** One-time reset for 3.6.6: re-enable Theme of the Month for existing users. */
const THEME_OF_THE_MONTH_RESET_VERSION = "3.6.6";
async function resetThemeOfTheMonthDisabledFor366Upgrade(
previousVersion: string,
): Promise<void> {
try {
const currRaw = browser.runtime.getManifest().version;
const prev = semver.coerce(previousVersion);
const curr = semver.coerce(currRaw);
if (
prev == null ||
curr == null ||
semver.lt(curr, THEME_OF_THE_MONTH_RESET_VERSION) ||
!semver.lt(prev, THEME_OF_THE_MONTH_RESET_VERSION)
) {
return;
}
await browser.storage.local.set({
themeOfTheMonthDisabled: false,
themeOfTheMonthLastSeenId: undefined,
});
console.info(
`[BetterSEQTA+] Migration ${THEME_OF_THE_MONTH_RESET_VERSION}: Theme of the Month re-enabled (from ${previousVersion}).`,
);
} catch (e) {
console.warn(
"[BetterSEQTA+] Theme of the Month 3.6.6 reset migration failed:",
e,
);
}
}
/** 3.7.0: Close no longer marks entries seen — clear legacy dismissal keys. */
const THEME_OF_THE_MONTH_RELOAD_VERSION = "3.7.0";
async function resetThemeOfTheMonthDismissalFor370Upgrade(
previousVersion: string,
): Promise<void> {
try {
const currRaw = browser.runtime.getManifest().version;
const prev = semver.coerce(previousVersion);
const curr = semver.coerce(currRaw);
if (
prev == null ||
curr == null ||
semver.lt(curr, THEME_OF_THE_MONTH_RELOAD_VERSION) ||
!semver.lt(prev, THEME_OF_THE_MONTH_RELOAD_VERSION)
) {
return;
}
await browser.storage.local.set({
themeOfTheMonthLastSeenId: undefined,
themeOfTheMonthDismissedMonth: undefined,
});
console.info(
`[BetterSEQTA+] Migration ${THEME_OF_THE_MONTH_RELOAD_VERSION}: Theme of the Month shows again until dismissed for the month (from ${previousVersion}).`,
);
} catch (e) {
console.warn(
"[BetterSEQTA+] Theme of the Month 3.7.0 dismissal migration failed:",
e,
);
function SetStorageValue(object: any) {
for (var i in object) {
browser.storage.local.set({ [i]: object[i] });
}
}
@@ -698,22 +483,9 @@ browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.remove(["justupdated"]);
browser.storage.local.remove(["data"]);
void ensureSyncableStorageDefaults();
if (event.reason == "install" || event.reason == "update") {
browser.storage.local.set({ justupdated: true });
}
if (event.reason === "update" && event.previousVersion) {
void migrateGlobalSearchDefaultsFor365Upgrade(event.previousVersion);
void resetThemeOfTheMonthDisabledFor366Upgrade(event.previousVersion);
void resetThemeOfTheMonthDismissalFor370Upgrade(event.previousVersion);
}
});
browser.runtime.onStartup.addListener(() => {
void ensureSyncableStorageDefaults();
});
initCloudSettingsAutoSync({ reloadSeqtaPages });
void ensureSyncableStorageDefaults();
+11 -68
View File
@@ -1,19 +1,12 @@
import browser from "webextension-polyfill";
import {
ensureSyncableStorageDefaults,
getSyncableStorageDefaults,
} from "@/seqta/utils/ensureSyncableStorageDefaults";
import {
applyDownloadedEnvelope,
buildUploadPayload,
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
buildUploadPatch,
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
isKeyIncludedInCloudUploadPayload,
normalizeStorageForSync,
resolveThemeIdForPostSyncDownload,
saveLastUploadedSnapshot,
getLastUploadedSnapshot,
setKnownRemoteUpdatedAt,
} from "@/seqta/utils/cloudSettingsSync";
@@ -25,11 +18,6 @@ const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`;
const UPLOAD_DEBOUNCE_MS = 2000;
const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000;
const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll";
const FETCH_TIMEOUT_MS = 30_000;
function fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> {
return fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
}
type CloudSummaryResponse = {
desqta?: unknown;
@@ -40,7 +28,6 @@ let reloadSeqtaPagesFn: (() => void) | null = null;
let suppressAutoUploadDuringRestore = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let pollInFlight: Promise<void> | null = null;
let autoSyncInitialized = false;
function isAutoCloudSyncEnabled(all: Record<string, unknown>): boolean {
return all.autoCloudSettingsSync !== false;
@@ -71,7 +58,7 @@ async function tryRefreshTokens(): Promise<boolean> {
if (!refresh_token || !client_id) return false;
try {
const r = await fetchWithTimeout(REFRESH_URL, {
const r = await fetch(REFRESH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token, client_id }),
@@ -106,7 +93,7 @@ async function fetchCloudSummaryOnce(
| { ok: false; unauthorized: boolean; error?: string }
> {
try {
const r = await fetchWithTimeout(CLOUD_SUMMARY_URL, {
const r = await fetch(CLOUD_SUMMARY_URL, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
@@ -151,39 +138,14 @@ async function fetchCloudSummaryWithAuthRetry(
}
type PutResult =
| { ok: true; updated_at?: string; skipped?: boolean }
| { ok: true; updated_at?: string }
| { ok: false; unauthorized: boolean; error?: string };
async function resolveUploadBaseline(
normalized: Record<string, unknown>,
watermark: string | undefined,
): Promise<Record<string, unknown>> {
const lastSnapshot = await getLastUploadedSnapshot();
if (lastSnapshot) return lastSnapshot;
if (watermark) {
await saveLastUploadedSnapshot(normalized);
return normalized;
}
return getSyncableStorageDefaults();
}
async function putSettingsOnce(token: string): Promise<PutResult> {
try {
await ensureSyncableStorageDefaults();
const all = (await browser.storage.local.get()) as Record<string, unknown>;
const normalized = normalizeStorageForSync(all);
const watermark = all[BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as string | undefined;
const baseline = await resolveUploadBaseline(normalized, watermark);
const payload = buildUploadPatch(all, baseline);
if (!payload) {
return { ok: true, skipped: true };
}
const r = await fetchWithTimeout(CLOUD_SETTINGS_SYNC_URL, {
const all = await browser.storage.local.get();
const payload = buildUploadPayload(all as Record<string, unknown>);
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
@@ -201,7 +163,6 @@ async function putSettingsOnce(token: string): Promise<PutResult> {
};
}
const updated_at = data?.updated_at as string | undefined;
await saveLastUploadedSnapshot(normalized);
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
@@ -215,13 +176,11 @@ async function putSettingsOnce(token: string): Promise<PutResult> {
export async function performCloudSettingsUploadWithRetry(
token: string,
): Promise<{ success: boolean; error?: string; updated_at?: string; skipped?: boolean }> {
): Promise<{ success: boolean; error?: string; updated_at?: string }> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await putSettingsOnce(t);
if (res.ok) {
return { success: true, updated_at: res.updated_at, skipped: res.skipped };
}
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
@@ -241,7 +200,7 @@ type GetResult =
async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
try {
const r = await fetchWithTimeout(CLOUD_SETTINGS_SYNC_URL, {
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
@@ -275,9 +234,6 @@ async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
reloadSeqtaPagesFn?.();
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
await saveLastUploadedSnapshot(
normalizeStorageForSync((await browser.storage.local.get()) as Record<string, unknown>),
);
return { ok: true, updated_at };
} catch (e) {
return {
@@ -379,8 +335,8 @@ export function runCloudSettingsPoll(): Promise<void> {
try {
const { [POLL_THROTTLE_KEY]: last } = await browser.storage.local.get(POLL_THROTTLE_KEY);
if (Date.now() - (Number(last) || 0) < POLL_THROTTLE_MS) return;
await runCloudSettingsPollInner();
await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() });
await runCloudSettingsPollInner();
} catch (e) {
console.error("[BS+ cloud sync] Poll error:", e);
} finally {
@@ -457,21 +413,8 @@ function onStorageChanged(
})();
}
export async function withSuppressedCloudAutoUpload<T>(
operation: () => T | Promise<T>,
): Promise<T> {
suppressAutoUploadDuringRestore = true;
try {
return await operation();
} finally {
suppressAutoUploadDuringRestore = false;
}
}
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
if (autoSyncInitialized) return;
autoSyncInitialized = true;
browser.storage.onChanged.addListener(onStorageChanged);
}
+6 -20
View File
@@ -1,7 +1,5 @@
import Parser from "rss-parser";
const MAX_RATE_LIMIT_RETRIES = 3;
/**
* Fetches news articles specifically for Australia from the NewsAPI.
*
@@ -15,23 +13,15 @@ const MAX_RATE_LIMIT_RETRIES = 3;
* to send the fetched news data back to the caller.
* It's called with an object like `{ news: responseData }`.
*/
const fetchAustraliaNews = async (
url: string,
sendResponse: any,
rateLimitRetryCount = 0,
) => {
const fetchAustraliaNews = async (url: string, sendResponse: any) => {
fetch(url)
.then((result) => result.json())
.then((response) => {
if (response.code == "rateLimited" && rateLimitRetryCount < MAX_RATE_LIMIT_RETRIES) {
fetchAustraliaNews(`${url}%00`, sendResponse, rateLimitRetryCount + 1);
if (response.code == "rateLimited") {
fetchAustraliaNews((url += "%00"), sendResponse);
} else {
sendResponse({ news: response });
}
})
.catch((error) => {
console.error("[BetterSEQTA+] Failed to fetch Australia news", error);
sendResponse({ news: { articles: [] } });
});
};
@@ -109,14 +99,13 @@ export async function fetchNews(source: string | undefined, sendResponse: any) {
if (normalizedSource === "australia") {
const date = new Date();
date.setDate(date.getDate() - 5);
const from =
date.getFullYear() +
"-" +
String(date.getMonth() + 1).padStart(2, "0") +
(date.getMonth() + 1) +
"-" +
String(date.getDate()).padStart(2, "0");
(date.getDate() - 5);
const url = `https://newsapi.org/v2/everything?domains=abc.net.au&from=${from}&apiKey=17c0da766ba347c89d094449504e3080`;
fetchAustraliaNews(url, sendResponse);
@@ -126,6 +115,7 @@ export async function fetchNews(source: string | undefined, sendResponse: any) {
const parser = new Parser();
let feeds: string[];
console.log("fetchNews", normalizedSource);
if (rssFeedsByCountry[normalizedSource.toLowerCase()]) {
feeds = rssFeedsByCountry[normalizedSource.toLowerCase()];
@@ -139,10 +129,6 @@ export async function fetchNews(source: string | undefined, sendResponse: any) {
const articlesPromises = feeds.map(async (feedUrl) => {
try {
const response = await fetch(feedUrl);
if (!response.ok) {
console.error(`Failed to fetch RSS feed: ${feedUrl} (${response.status})`);
return [];
}
const feedString = await response.text();
const feed = await parser.parseString(feedString);
+2 -3
View File
@@ -37,9 +37,8 @@
@layer base, override;
@layer override {
.legacy-root,
.legacy-root * {
font-family: var(--betterseqta-font-family, Rubik), sans-serif !important;
* {
font-family: Rubik, sans-serif !important;
}
.iconFamily,
+78 -643
View File
@@ -119,8 +119,7 @@ select option {
#container {
background: var(--auto-background) !important;
}
.legacy-root,
.legacy-root * {
:root * {
font-family: Rubik, sans-serif !important;
--theme-fg-parts: white;
}
@@ -456,58 +455,6 @@ ul.magicDelete > li.deleting {
top: 71.5px;
margin-top: -2px;
}
/* Drill-in stack: only the current list + folder header stay clickable.
Class is toggled by updateSidebarAccessibility (never touches aria-hidden). */
#menu .bsplus-sidebar-offscreen,
#menu .bsplus-sidebar-offscreen * {
pointer-events: none !important;
user-select: none !important;
}
#menu > ul > .bsplus-sidebar-offscreen:not(.hasChildren.active) {
position: absolute !important;
left: -10000px !important;
width: 1px !important;
height: 1px !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
opacity: 0 !important;
}
#menu .sub .bsplus-sidebar-offscreen:not(.hasChildren.active) {
visibility: hidden !important;
position: absolute !important;
left: -10000px !important;
width: 1px !important;
height: 1px !important;
margin: 0 !important;
padding: 0 !important;
opacity: 0 !important;
}
/* Only the frontmost open .sub panel receives pointer events */
#menu .sub {
pointer-events: none;
}
#menu li.hasChildren.active > .sub {
pointer-events: auto;
}
#menu li.hasChildren.active > .sub:has(.hasChildren.active) {
pointer-events: none !important;
}
#menu li.hasChildren.active .hasChildren.active > .sub {
pointer-events: auto !important;
}
#menu:has(> ul > li.hasChildren.active) > ul > li:not(.hasChildren.active) {
pointer-events: none !important;
}
#menu section > label {
align-items: center;
box-sizing: border-box;
@@ -2379,10 +2326,6 @@ blurred {
height: 64px;
cursor: pointer;
}
/* While a drill-in submenu is open, don't steal clicks meant for folder rows. */
#menu:has(li.hasChildren.active) > .icon-cover {
pointer-events: none;
}
.uiSlidePane > .pane > .header button {
color: var(--text-color) !important;
}
@@ -3559,32 +3502,6 @@ div.day-empty {
color: var(--text-primary);
transform-origin: center center;
}
/* Text-only popups (privacy notices): body fills remaining height, scrolls inside */
.whatsnewContainer.whatsnewContainer--scrollBody {
.whatsnewHeader {
flex-shrink: 0;
height: auto;
min-height: 3em;
}
> .whatsnewTextContainer {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
max-height: none;
width: 90%;
margin: 0 auto 0.75rem;
padding-bottom: 0.5rem;
box-sizing: border-box;
}
> .whatsnewTextContainer.privacyStatement {
font-size: 1.1rem;
line-height: 1.6;
}
}
.whatsnewTextContainer.privacyStatement p {
margin-bottom: 1.5ex;
@@ -3615,6 +3532,26 @@ div.day-empty {
font-size: 1em;
color: var(--text-primary);
}
.whatsnewHeader.engageParentsAnnouncementHeader {
height: auto;
min-height: unset;
}
.whatsnewHeader.engageParentsAnnouncementHeader h1 {
line-height: 1.2;
}
.whatsnewHeader.engageParentsAnnouncementHeader .engageParentsSubheading {
margin-top: 0.35rem;
font-size: 1.05rem;
font-weight: 600;
opacity: 0.92;
}
.seqtaEngageAccent {
color: #ea580c;
font-weight: 700;
}
.dark .seqtaEngageAccent {
color: #fb923c;
}
.whatsnewBackground {
width: 100%;
height: 100%;
@@ -3743,6 +3680,25 @@ div.day-empty {
object-fit: cover;
margin-bottom: 12px;
}
.whatsnewTextContainer .engageParentsPromoWrap {
width: 100%;
margin-bottom: 12px;
border-radius: 16px;
overflow: hidden;
aspect-ratio: 16 / 9;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.28);
background: color-mix(in srgb, var(--background-secondary) 88%, var(--text-primary) 12%);
}
.whatsnewTextContainer .engageParentsPromoWrap .engageParentsPromoImg {
display: block;
width: 100%;
height: 100%;
margin: 0;
border-radius: 0;
aspect-ratio: unset;
object-fit: contain;
object-position: center;
}
.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader {
height: auto;
@@ -3770,565 +3726,6 @@ div.day-empty {
color: var(--text-primary);
}
.themeOfTheMonthBackdrop {
position: fixed;
inset: 0;
z-index: 47;
background: color-mix(in srgb, #000 52%, transparent);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
opacity: 0;
pointer-events: none;
transition: opacity 0.55s cubic-bezier(0.76, 0, 0.24, 1);
}
.themeOfTheMonthBackdropVisible {
opacity: 1;
pointer-events: auto;
}
.themeOfTheMonthCard {
position: fixed;
top: 0;
left: 0;
right: auto;
bottom: auto;
z-index: 48;
margin: 0;
width: min(360px, calc(100vw - 36px));
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
border-radius: 20px;
background: var(--background-primary);
color: var(--text-primary);
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
transform-origin: bottom right;
transition: none;
animation: themeOfTheMonthCardIn 0.28s ease-out;
}
.themeOfTheMonthCardExpanded {
transform-origin: center center;
}
/* translate(x,y) is set inline; transition enabled after mount */
.themeOfTheMonthCardMorphReady:not(.themeOfTheMonthCardReducedMotion) {
transition:
transform 0.55s cubic-bezier(0.76, 0, 0.24, 1),
width 0.55s cubic-bezier(0.76, 0, 0.24, 1),
height 0.55s cubic-bezier(0.76, 0, 0.24, 1),
max-height 0.55s cubic-bezier(0.76, 0, 0.24, 1),
border-radius 0.55s cubic-bezier(0.76, 0, 0.24, 1);
}
.themeOfTheMonthCardAnchoredBottom,
.themeOfTheMonthCardCollapsing {
transform-origin: 100% 100% !important;
}
/* Expanded: fixed shell; copy scrolls; actions pinned to the bottom. */
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardBody {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
padding: 14px 16px 16px;
}
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardDescription {
flex: 1 1 auto;
min-height: 0;
margin: 8px 0 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardActions {
flex-shrink: 0;
margin-top: auto;
padding-top: 14px;
}
.themeOfTheMonthCardReducedMotion {
transition: none !important;
}
.themeOfTheMonthCardMedia {
position: relative;
flex-shrink: 0;
}
#theme-of-the-month-card .themeOfTheMonthCardPopout {
position: absolute;
top: 12px;
left: 12px;
z-index: 6;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
padding: 0;
appearance: none;
border: none;
border-radius: 50% !important;
aspect-ratio: 1;
cursor: pointer;
color: var(--text-primary);
background: color-mix(in srgb, var(--background-primary) 80%, transparent);
box-shadow:
0 2px 10px rgba(0, 0, 0, 0.24),
inset 0 0 0 1px color-mix(in srgb, var(--text-primary) 14%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: background 0.15s ease, transform 0.15s ease;
}
.themeOfTheMonthCardPopoutIcon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
pointer-events: none;
}
.themeOfTheMonthCardPopout:hover {
background: color-mix(in srgb, var(--background-primary) 95%, transparent);
transform: scale(1.05);
}
.themeOfTheMonthCardPopout:active {
transform: scale(0.96);
}
.themeOfTheMonthCardPopout[hidden] {
display: none;
}
.themeOfTheMonthCardCompactMedia {
position: relative;
display: block;
overflow: hidden;
border-radius: 20px 20px 0 0;
line-height: 0;
}
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery .themeOfTheMonthCardCompactMedia {
display: none;
}
.themeOfTheMonthCardExpandedPanel {
display: none;
}
.themeOfTheMonthCardExpandedPanel[hidden] {
display: none !important;
}
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery
.themeOfTheMonthCardExpandedPanel:not([hidden]) {
display: block;
}
.themeOfTheMonthCardGallery {
position: relative;
}
.themeOfTheMonthCardHeroEmboss {
display: none;
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 4;
flex-direction: column;
justify-content: flex-end;
pointer-events: none;
}
.themeOfTheMonthCardHeroEmbossScrim {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
transparent 0%,
color-mix(in srgb, #000 8%, transparent) 40%,
color-mix(in srgb, #000 55%, transparent) 72%,
color-mix(in srgb, #000 82%, transparent) 100%
);
}
.themeOfTheMonthCardHeroEmbossContent {
position: relative;
z-index: 1;
}
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmboss {
display: flex;
padding: 14px 16px 16px;
}
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossTitle {
margin: 0;
font-size: 1.45rem;
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.02em;
color: #fff;
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.55),
0 2px 14px rgba(0, 0, 0, 0.35);
}
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossAuthor {
margin: 4px 0 0;
font-size: 0.8rem;
font-weight: 600;
line-height: 1.25;
color: color-mix(in srgb, #fff 88%, transparent);
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
}
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossDescription {
margin: 8px 0 0;
font-size: 0.84rem;
line-height: 1.4;
color: color-mix(in srgb, #fff 92%, transparent);
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5);
}
.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossVariants {
margin: 6px 0 0;
font-size: 0.74rem;
font-weight: 600;
line-height: 1.2;
color: color-mix(in srgb, #fff 72%, transparent);
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.4);
}
.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery
.themeOfTheMonthCardGallerySlide
figcaption {
display: none;
}
.themeOfTheMonthCardGalleryTrack {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}
.themeOfTheMonthCardGalleryTrack::-webkit-scrollbar {
display: none;
}
.themeOfTheMonthCardGallerySlide {
flex: 0 0 100%;
margin: 0;
scroll-snap-align: start;
}
.themeOfTheMonthCardGallerySlide img {
display: block;
width: 100%;
height: min(42vh, 280px);
object-fit: cover;
}
.themeOfTheMonthCardGallerySlide figcaption {
padding: 8px 14px 0;
font-size: 0.78rem;
line-height: 1.3;
color: color-mix(in srgb, var(--text-primary) 68%, transparent);
}
.themeOfTheMonthCardGalleryPrev,
.themeOfTheMonthCardGalleryNext {
position: absolute;
top: 50%;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
margin-top: -17px;
padding: 0;
appearance: none;
border: none;
border-radius: 9999px;
cursor: pointer;
font-size: 1.35rem;
line-height: 1;
color: var(--text-primary);
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
.themeOfTheMonthCardGalleryPrev {
left: 10px;
}
.themeOfTheMonthCardGalleryNext {
right: 10px;
}
.themeOfTheMonthCardGalleryDots {
display: flex;
justify-content: center;
gap: 6px;
padding: 8px 14px 0;
}
.themeOfTheMonthCardGalleryDot {
width: 7px;
height: 7px;
padding: 0;
appearance: none;
border: none;
border-radius: 9999px;
cursor: pointer;
background: color-mix(in srgb, var(--text-primary) 28%, transparent);
transition: background 0.15s ease, transform 0.15s ease;
}
.themeOfTheMonthCardGalleryDotActive {
background: var(--better-pri, #6366f1);
transform: scale(1.15);
}
.themeOfTheMonthCardDescriptionTyping {
display: block;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: unset;
}
.themeOfTheMonthCard::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
overflow: hidden;
border-radius: inherit;
background: inherit;
}
.themeOfTheMonthCardClosing {
pointer-events: none;
animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
}
.themeOfTheMonthCardConfirm {
position: absolute;
inset: 0;
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
border-radius: inherit;
background: color-mix(in srgb, var(--background-primary) 88%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
opacity: 0;
pointer-events: none;
transition: opacity 0.16s ease;
}
.themeOfTheMonthCardConfirm[hidden] {
display: none;
}
.themeOfTheMonthCardConfirmVisible {
opacity: 1;
pointer-events: auto;
}
.themeOfTheMonthCardConfirmInner {
width: 100%;
text-align: center;
}
.themeOfTheMonthCardConfirmInner h3 {
margin: 0 0 6px;
font-size: 1rem;
line-height: 1.2;
}
.themeOfTheMonthCardConfirmInner p {
margin: 0 0 14px;
font-size: 0.86rem;
line-height: 1.4;
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
}
.themeOfTheMonthCardConfirmActions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.themeOfTheMonthCardConfirmCancel,
.themeOfTheMonthCardConfirmAccept {
appearance: none;
border: none;
cursor: pointer;
border-radius: 9999px;
padding: 0.5rem 0.85rem;
font-size: 0.84rem;
font-weight: 700;
transition: transform 0.15s ease, filter 0.15s ease, background 0.15s ease;
}
.themeOfTheMonthCardConfirmCancel {
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
color: var(--text-primary);
}
.themeOfTheMonthCardConfirmAccept {
background: var(--better-pri, #6366f1);
color: white;
}
.themeOfTheMonthCardConfirmCancel:hover,
.themeOfTheMonthCardConfirmAccept:hover {
filter: brightness(1.08);
transform: translateY(-1px);
}
.themeOfTheMonthCardConfirmCancel:active,
.themeOfTheMonthCardConfirmAccept:active {
transform: translateY(0);
}
#theme-of-the-month-card .themeOfTheMonthCardImage {
display: block;
width: 100% !important;
min-width: 100%;
height: 150px !important;
max-width: none !important;
max-height: none !important;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
object-fit: cover;
object-position: center center;
}
.themeOfTheMonthCardExpanded .themeOfTheMonthCardGallerySlide img {
border-radius: 22px 22px 0 0;
}
.themeOfTheMonthCardBody {
padding: 14px 16px 12px;
}
.themeOfTheMonthCardEyebrow {
margin: 0 0 6px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--better-pri, #6366f1) 82%, var(--text-primary) 18%);
}
.themeOfTheMonthCard h2 {
margin: 0;
font-size: 1.2rem;
line-height: 1.2;
}
.themeOfTheMonthCardDescription {
margin: 8px 0 10px;
font-size: 0.92rem;
line-height: 1.45;
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
overflow-wrap: anywhere;
word-wrap: break-word;
}
.themeOfTheMonthCardDescriptionClipped {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.themeOfTheMonthCardDescriptionExpanded {
display: block;
-webkit-line-clamp: unset;
}
.themeOfTheMonthCardExpanding:not(.themeOfTheMonthCardExpandedShell)
.themeOfTheMonthCardDescriptionExpanded {
overflow: hidden;
}
.themeOfTheMonthCardActions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.themeOfTheMonthCardActionsStart {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.themeOfTheMonthCardActionsEnd {
display: inline-flex;
flex-wrap: nowrap;
align-items: stretch;
margin-left: auto;
padding: 3px;
gap: 0;
overflow: hidden;
border-radius: 9999px;
background: color-mix(
in srgb,
var(--background-secondary, var(--text-primary)) 28%,
var(--background-primary)
);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary) 8%, transparent);
}
.themeOfTheMonthCardPrimary,
.themeOfTheMonthCardSecondary,
.themeOfTheMonthCardDontShow {
appearance: none;
border: none;
cursor: pointer;
border-radius: 9999px;
padding: 0.58rem 0.9rem;
font-size: 0.86rem;
font-weight: 700;
transition: background 0.15s ease, color 0.15s ease;
}
.themeOfTheMonthCardPrimary {
background: var(--better-pri, #6366f1);
color: white;
}
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary,
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow {
padding: 0.5rem 0.8rem;
font-size: 0.8rem;
font-weight: 600;
border: none !important;
border-radius: 9999px !important;
background: transparent !important;
box-shadow: none !important;
filter: none !important;
transform: none !important;
}
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary {
color: var(--text-primary);
}
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow {
color: color-mix(in srgb, var(--text-primary) 58%, transparent);
}
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary:hover,
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary:focus-visible,
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow:hover,
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow:focus-visible {
background: color-mix(in srgb, var(--text-primary) 10%, transparent) !important;
border-radius: 9999px !important;
filter: none !important;
transform: none !important;
}
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardSecondary:active,
#theme-of-the-month-card .themeOfTheMonthCardActionsEnd .themeOfTheMonthCardDontShow:active {
background: color-mix(in srgb, var(--text-primary) 14%, transparent) !important;
border-radius: 9999px !important;
}
.themeOfTheMonthCardPrimary:hover {
filter: brightness(1.08);
transform: translateY(-1px);
}
.themeOfTheMonthCardPrimary:active {
transform: translateY(0);
}
@keyframes themeOfTheMonthCardIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes themeOfTheMonthCardOut {
to {
opacity: 0;
}
}
.themeOfTheMonthCardExpanded.themeOfTheMonthCardClosing {
animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
}
@media (max-width: 900px) {
.themeOfTheMonthCard,
.themeOfTheMonthBackdrop {
z-index: 2147483645;
}
}
.bsplus-theme-highlight {
animation: bsplusThemeHighlightPulse 1.4s ease-in-out 2;
}
@keyframes bsplusThemeHighlightPulse {
0%, 100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--better-pri, #6366f1) 0%, transparent);
}
50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--better-pri, #6366f1) 60%, transparent);
}
}
.popup-media-fullscreenable {
cursor: pointer;
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
@@ -4975,3 +4372,41 @@ h2.home-subtitle {
font-size: 20px;
font-weight: 400;
}
.bsplus-toast {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 10000;
display: flex;
align-items: flex-start;
gap: 12px;
max-width: 380px;
padding: 16px 18px;
border-radius: 12px;
background: var(--background-secondary, #fff);
color: var(--text-primary, #1a1a1a);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
font-size: 0.9rem;
line-height: 1.5;
}
.bsplus-toast-content p {
margin: 6px 0 0;
opacity: 0.8;
font-size: 0.85rem;
}
.bsplus-toast-close {
flex-shrink: 0;
background: none;
border: none;
color: var(--text-primary, #1a1a1a);
font-size: 1.3rem;
cursor: pointer;
padding: 0 2px;
line-height: 1;
opacity: 0.5;
transition: opacity 0.15s;
}
.bsplus-toast-close:hover {
opacity: 1;
}
+1 -1
View File
@@ -18,7 +18,7 @@
right: 10px;
top: 80px;
height: 590px;
z-index: 100;
z-index: 20;
transition-duration: 100ms;
}
-4
View File
@@ -1,4 +0,0 @@
declare const __ENABLE_GH_RELEASE_UPDATE_CHECK__: boolean;
declare const __GH_RELEASE_REPO__: string;
declare const __UPDATE_CHANNEL__: "stable" | "nightly";
declare const __BUILD_LABEL__: string;
+5 -6
View File
@@ -3,7 +3,6 @@
import { animate } from "motion";
import { delay } from "@/seqta/utils/delay.ts";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
const { hidePanel } = $props<{
hidePanel: () => void;
@@ -92,7 +91,7 @@
bind:this={background}
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50"
onclick={handleBackgroundClick}
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick(e as unknown as MouseEvent) }}
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }}
>
<div
bind:this={content}
@@ -106,12 +105,12 @@
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl}
<CloudPfpAvatar
user={cloudState.user}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{/if}
{#if !cloudState.user?.pfpUrl}
{:else}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()}
</div>
@@ -1,44 +0,0 @@
<script lang="ts">
import { resolveCloudPfp } from "@/seqta/utils/cloudPfpCache";
import type { CloudUser } from "@/seqta/utils/CloudAuth";
const { user, class: className = "" } = $props<{
user: CloudUser | null | undefined;
class?: string;
}>();
let avatarSrc = $state<string | undefined>(undefined);
let revokeUrl: string | undefined;
$effect(() => {
const u = user;
if (revokeUrl) {
URL.revokeObjectURL(revokeUrl);
revokeUrl = undefined;
}
avatarSrc = undefined;
if (!u?.pfpUrl || !u.id) return;
let cancelled = false;
void resolveCloudPfp(u.id, u.pfpUrl).then((resolved) => {
if (cancelled || !resolved) return;
if (resolved.fromCache) {
revokeUrl = resolved.src;
}
avatarSrc = resolved.src;
});
return () => {
cancelled = true;
if (revokeUrl) {
URL.revokeObjectURL(revokeUrl);
revokeUrl = undefined;
}
};
});
</script>
{#if avatarSrc}
<img src={avatarSrc} alt="" class={className} />
{/if}
@@ -22,13 +22,15 @@
});
async function upload() {
if (!cloudState.isLoggedIn) return;
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsUpload",
token,
})) as { success?: boolean; error?: string };
if (res?.success) {
statusMessage = "Settings uploaded.";
@@ -47,13 +49,15 @@
}
async function confirmDownload() {
if (!cloudState.isLoggedIn) return;
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsDownload",
token,
})) as { success?: boolean; error?: string; notFound?: boolean };
if (res?.success) {
statusMessage = "Settings restored.";
+1 -3
View File
@@ -19,7 +19,6 @@
let editor = $state<HTMLDivElement | null>(null)
let view: EditorView | null = null;
let unsubSettings: (() => void) | undefined;
let editorTheme = new Compartment();
let { value, onChange, className } = $props<{value: string, onChange: (value: string) => void, className?: string}>()
@@ -74,7 +73,7 @@
view = createEditorView(state, editor as HTMLElement);
}
unsubSettings = settingsState.subscribe((settings) => {
settingsState.subscribe((settings) => {
if (view) {
view.dispatch({
effects: editorTheme.reconfigure(
@@ -86,7 +85,6 @@
});
onDestroy(() => {
unsubSettings?.();
if (view) {
view.destroy();
}
+22 -40
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte'
import type { Component } from 'svelte'
import ColourPicker from './ColourPicker.tsx';
import ReactAdapter from './utils/ReactAdapter.svelte';
import { animate } from 'motion';
import { delay } from '@/seqta/utils/delay.ts'
@@ -14,19 +15,6 @@
let background = $state<HTMLDivElement | null>(null);
let content = $state<HTMLDivElement | null>(null);
let ReactAdapter = $state<Component | null>(null);
let ColourPickerEl = $state<unknown>(null);
let pickerReady = $state(false);
const loadPicker = async () => {
const [adapterMod, pickerMod] = await Promise.all([
import('./utils/ReactAdapter.svelte'),
import('./ColourPicker.tsx'),
]);
ReactAdapter = adapterMod.default;
ColourPickerEl = pickerMod.default;
pickerReady = true;
};
const closePicker = async () => {
if (standalone) return;
@@ -49,30 +37,28 @@
);
await delay(400);
hidePicker?.();
hidePicker();
}
onMount(() => {
void loadPicker().then(() => {
if (standalone) return;
if (!background || !content) return;
if (standalone) return;
if (!background || !content) return;
animate(
background,
{ opacity: [0, 1] },
{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }
);
animate(
background,
{ opacity: [0, 1] },
{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }
);
animate(
content,
{ scale: [0.4, 1], opacity: [0, 1] },
{
type: 'spring',
stiffness: 400,
damping: 30
}
);
});
animate(
content,
{ scale: [0.4, 1], opacity: [0, 1] },
{
type: 'spring',
stiffness: 400,
damping: 30
}
);
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
@@ -96,9 +82,7 @@
{#if standalone}
<div class="h-auto overflow-clip rounded-xl">
{#if pickerReady && ReactAdapter && ColourPickerEl}
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPickerEl} />
{/if}
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -106,15 +90,13 @@
bind:this={background}
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full shadow-2xl cursor-pointer bg-black/20 border border-[#DDDDDD]/30 dark:border-[#38373D]/30"
onclick={handleBackgroundClick}
onkeydown={(e) => { if (e.key === 'Enter') handleBackgroundClick(e as unknown as MouseEvent) }}
onkeydown={(e) => { e.key === 'Enter' && handleBackgroundClick }}
>
<div
bind:this={content}
class="p-4 h-auto bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
>
{#if pickerReady && ReactAdapter && ColourPickerEl}
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPickerEl} />
{/if}
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
</div>
</div>
{/if}
@@ -1,141 +0,0 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { onMount } from "svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { FONT_PRESETS, DEFAULT_FONT_ID, getFontPreset } from "@/seqta/ui/fonts/presets";
import {
applySelectedFont,
buildFontPreviewCss,
ensureFontPickerFontsLoaded,
} from "@/seqta/ui/fonts/Manager";
import { portal } from "@/interface/utils/portal";
import { syncPageThemeToElement } from "@/interface/utils/syncPageTheme";
import fontPickerStyles from "./fontPickerModal.css?inline";
let { hidePicker } = $props<{ hidePicker: () => void }>();
let rootEl = $state<HTMLElement | null>(null);
let selectedId = $state(getFontPreset($settingsState.selectedFont).id);
let styleEl: HTMLStyleElement | null = null;
function selectFont(id: string) {
selectedId = id;
settingsState.selectedFont = id;
applySelectedFont(id);
}
function resetToDefault() {
selectFont(DEFAULT_FONT_ID);
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) hidePicker();
}
function syncTheme() {
if (rootEl) syncPageThemeToElement(rootEl);
}
onMount(() => {
void ensureFontPickerFontsLoaded();
styleEl = document.getElementById(
"betterseqta-font-picker-styles",
) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = "betterseqta-font-picker-styles";
document.head.appendChild(styleEl);
}
styleEl.textContent = `${fontPickerStyles}\n${buildFontPreviewCss()}`;
syncTheme();
const themeObserver = new MutationObserver(() => syncTheme());
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style", "class"],
});
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === "Escape") hidePicker();
};
document.addEventListener("keydown", handleEscapeKey);
return () => {
themeObserver.disconnect();
document.removeEventListener("keydown", handleEscapeKey);
};
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={rootEl}
use:portal={document.body}
class="bsplus-font-picker-overlay bsplus-font-picker-root"
onclick={handleBackdropClick}
onkeydown={(event) => {
if (event.key === "Enter" || event.key === " ") handleBackdropClick(event as unknown as MouseEvent);
}}
role="presentation"
transition:fade={{ duration: 200 }}
>
<div
class="bsplus-font-picker-dialog"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="font-picker-title"
>
<header class="bsplus-font-picker-header">
<div class="bsplus-font-picker-header-actions">
<button
type="button"
onclick={resetToDefault}
disabled={selectedId === DEFAULT_FONT_ID}
class="bsplus-font-picker-reset"
aria-label="Reset font to default"
>
Reset to default
</button>
<button
type="button"
onclick={hidePicker}
class="bsplus-font-picker-done"
aria-label="Close font picker"
>
Done
</button>
</div>
<div class="bsplus-font-picker-header-text">
<h2 id="font-picker-title" class="bsplus-font-picker-title">
Choose a font
</h2>
<p class="bsplus-font-picker-desc">
Choose a typeface for SEQTA Learn.
</p>
</div>
</header>
<div class="bsplus-font-picker-list">
{#each FONT_PRESETS as preset (preset.id)}
<button
type="button"
onclick={() => selectFont(preset.id)}
class="bsplus-font-picker-option {selectedId === preset.id ? 'is-selected' : ''}"
data-font-id={preset.id}
>
<div class="bsplus-font-picker-option-head">
<span class="bsplus-font-picker-option-name">{preset.name}</span>
{#if selectedId === preset.id}
<span class="bsplus-font-picker-badge">Selected</span>
{/if}
</div>
</button>
{/each}
</div>
</div>
</div>
+8 -14
View File
@@ -78,7 +78,7 @@
const startRecording = () => {
isRecording = true;
recordedKeys = new Set();
recordedKeys.clear();
inputElement?.focus();
};
@@ -87,7 +87,7 @@
if (recordedKeys.has('esc')) {
onChange('');
isRecording = false;
recordedKeys = new Set();
recordedKeys.clear();
inputElement?.blur();
return;
}
@@ -113,16 +113,10 @@
}
isRecording = false;
recordedKeys = new Set();
recordedKeys.clear();
inputElement?.blur();
};
const addRecordedKey = (key: string) => {
const next = new Set(recordedKeys);
next.add(key);
recordedKeys = next;
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isRecording) return;
@@ -132,14 +126,14 @@
const key = formatKeyForHotkey(e.key);
// Add modifiers
if (e.ctrlKey) addRecordedKey('ctrl');
if (e.metaKey) addRecordedKey('cmd');
if (e.altKey) addRecordedKey('alt');
if (e.shiftKey) addRecordedKey('shift');
if (e.ctrlKey) recordedKeys.add('ctrl');
if (e.metaKey) recordedKeys.add('cmd');
if (e.altKey) recordedKeys.add('alt');
if (e.shiftKey) recordedKeys.add('shift');
// Add the main key (ignore modifier keys themselves)
if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
addRecordedKey(key);
recordedKeys.add(key);
}
// Auto-stop recording if we have a main key
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
let { state = $bindable(), onChange, min = 0, max = 100, step = 1 } = $props<{
let { state, onChange, min = 0, max = 100, step = 1 } = $props<{
state: number,
onChange: (value: number) => void,
min?: number,
+16 -14
View File
@@ -39,7 +39,7 @@
</script>
<div class="flex flex-col h-full">
<div class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container" role="tablist">
<div class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container">
<div bind:this={containerRef} class="flex relative">
<MotionDiv
class="absolute top-0 left-0 z-0 h-full bg-gradient-to-tr dark:from-[#38373D]/80 dark:to-[#38373D] from-[#DDDDDD]/80 to-[#DDDDDD] rounded-full opacity-40 tab-width"
@@ -48,8 +48,6 @@
/>
{#each tabs as { title }, index}
<button
role="tab"
aria-selected={activeTab === index}
class="relative z-10 flex-1 px-4 py-2 focus-visible:outline-none"
onclick={() => activeTab = index}
>
@@ -59,16 +57,20 @@
</div>
</div>
<div class="overflow-hidden px-4 h-full">
{#each tabs as { Content, props }, index (index)}
{#if activeTab === index}
<div
role="tabpanel"
class="focus:outline-none w-full pt-2 overflow-y-scroll no-scrollbar pb-2 h-full tab active"
>
<div class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
<Content {...props} />
</div>
{/if}
{/each}
<MotionDiv
class="h-full"
animate={{ x: `${-activeTab * 100}%` }}
transition={springTransition}
>
<div class="flex">
{#each tabs as { Content, props }, index}
<div class="absolute focus:outline-none w-full pt-2 transition-opacity duration-300 overflow-y-scroll no-scrollbar pb-2 h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}"
style="left: {index * 100}%;">
<div style="left: {index * 100}%;" class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
<Content {...props} />
</div>
{/each}
</div>
</MotionDiv>
</div>
</div>
@@ -1,311 +0,0 @@
/* Font picker — analytics design tokens & components */
.bsplus-font-picker-overlay {
position: fixed;
inset: 0;
z-index: 50000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.25rem;
cursor: pointer;
background: color-mix(in srgb, #000 52%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.bsplus-font-picker-root {
--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-text: var(--text-primary, #1a1a1a);
--bsplus-analytics-muted: color-mix(
in srgb,
var(--bsplus-analytics-text) 55%,
transparent
);
--bsplus-analytics-border: color-mix(
in srgb,
var(--theme-offset-bg, var(--background-secondary, #e2e8f0)) 78%,
transparent
);
--bsplus-analytics-shadow: 0 5px 16px 6px rgba(0, 0, 0, 0.12);
--bsplus-analytics-shadow-hover: 0 8px 24px 8px rgba(0, 0, 0, 0.16);
--bsplus-analytics-accent: var(--better-main, #007bff);
font-family: Rubik, system-ui, sans-serif;
font-size: 0.875rem;
color: var(--bsplus-analytics-text);
}
.bsplus-font-picker-root.dark {
--bsplus-analytics-shadow: 0 5px 20px 6px rgba(0, 0, 0, 0.45);
--bsplus-analytics-shadow-hover: 0 10px 28px 10px rgba(0, 0, 0, 0.55);
}
@keyframes bsplus-font-picker-in {
from {
opacity: 0;
transform: translateY(18px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.bsplus-font-picker-dialog {
width: min(100%, 22rem);
max-height: min(88vh, 820px);
display: flex;
flex-direction: column;
overflow: hidden;
cursor: auto;
border-radius: var(--bsplus-analytics-radius);
background: var(--bsplus-analytics-surface);
border: 1px solid var(--bsplus-analytics-border);
box-shadow: var(--bsplus-analytics-shadow-hover);
animation: bsplus-font-picker-in 0.45s var(--bsplus-analytics-ease) forwards;
}
@media (min-width: 640px) {
.bsplus-font-picker-dialog {
width: min(92vw, 22rem);
}
}
@media (prefers-reduced-motion: reduce) {
.bsplus-font-picker-dialog {
animation: none;
}
}
.bsplus-font-picker-header {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.85rem;
flex-shrink: 0;
padding: 1.15rem 1.25rem;
border-bottom: 1px solid var(--bsplus-analytics-border);
}
.bsplus-font-picker-header-actions {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
flex-wrap: wrap;
}
.bsplus-font-picker-header-text {
min-width: 0;
}
.bsplus-font-picker-title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
color: var(--bsplus-analytics-text);
}
.bsplus-font-picker-desc {
margin: 0.35rem 0 0;
font-size: 0.8125rem;
color: var(--bsplus-analytics-muted);
line-height: 1.5;
}
.bsplus-font-picker-reset {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.65rem 1rem;
border-radius: var(--bsplus-analytics-radius-sm);
border: 2px solid var(--bsplus-analytics-border);
background: transparent;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
color: var(--bsplus-analytics-text);
transition:
transform 0.2s var(--bsplus-analytics-ease),
background 0.2s var(--bsplus-analytics-ease),
border-color 0.2s var(--bsplus-analytics-ease),
opacity 0.2s ease;
}
.bsplus-font-picker-reset:hover:not(:disabled) {
transform: scale(1.02);
background: color-mix(
in srgb,
var(--bsplus-analytics-surface-2) 80%,
transparent
);
}
.bsplus-font-picker-reset:active:not(:disabled) {
transform: scale(0.98);
}
.bsplus-font-picker-reset:focus-visible {
outline: none;
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent);
}
.bsplus-font-picker-reset:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.bsplus-font-picker-done {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0.65rem 1.25rem;
border-radius: var(--bsplus-analytics-radius-sm);
border: none;
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
background: var(--bsplus-analytics-accent);
color: var(--text-color, #ffffff);
box-shadow: 0 2px 8px
color-mix(in srgb, var(--bsplus-analytics-accent) 40%, transparent);
transition:
transform 0.2s var(--bsplus-analytics-ease),
box-shadow 0.2s var(--bsplus-analytics-ease);
}
.bsplus-font-picker-done:hover {
transform: scale(1.03);
box-shadow: 0 4px 14px
color-mix(in srgb, var(--bsplus-analytics-accent) 45%, transparent);
}
.bsplus-font-picker-done:active {
transform: scale(0.97);
}
.bsplus-font-picker-done:focus-visible {
outline: none;
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent);
}
.bsplus-font-picker-list {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 1rem 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent)
transparent;
}
.bsplus-font-picker-list::-webkit-scrollbar {
width: 8px;
}
.bsplus-font-picker-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: color-mix(in srgb, var(--bsplus-analytics-accent) 35%, transparent);
}
.bsplus-font-picker-option {
width: 100%;
padding: 0.9rem 1rem;
text-align: left;
border-radius: var(--bsplus-analytics-radius-sm);
border: 1px solid var(--bsplus-analytics-border);
background: var(--bsplus-analytics-surface);
box-shadow: var(--bsplus-analytics-shadow);
cursor: pointer;
font-family: Rubik, system-ui, sans-serif;
flex-shrink: 0;
transition:
transform 0.25s var(--bsplus-analytics-ease),
box-shadow 0.25s var(--bsplus-analytics-ease),
border-color 0.2s ease,
background 0.2s ease;
}
.bsplus-font-picker-option:hover {
transform: translateY(-2px);
box-shadow: var(--bsplus-analytics-shadow-hover);
background: color-mix(
in srgb,
var(--bsplus-analytics-surface-2) 55%,
var(--bsplus-analytics-surface)
);
}
.bsplus-font-picker-option:focus-visible {
outline: none;
box-shadow:
var(--bsplus-analytics-shadow-hover),
0 0 0 3px color-mix(in srgb, var(--bsplus-analytics-accent) 30%, transparent);
}
.bsplus-font-picker-option.is-selected {
border-color: color-mix(
in srgb,
var(--bsplus-analytics-accent) 45%,
var(--bsplus-analytics-border)
);
background: color-mix(
in srgb,
var(--bsplus-analytics-accent) 10%,
var(--bsplus-analytics-surface)
);
box-shadow: 0 4px 16px
color-mix(in srgb, var(--bsplus-analytics-accent) 22%, transparent);
}
.bsplus-font-picker-option-head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0;
}
.bsplus-font-picker-root .bsplus-font-picker-option-name {
font-size: 1rem;
font-weight: 700;
color: var(--bsplus-analytics-text);
text-align: left;
min-width: 0;
}
.bsplus-font-picker-badge {
display: inline-flex;
align-items: center;
flex-shrink: 0;
margin-left: auto;
padding: 0.2rem 0.65rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
background: color-mix(
in srgb,
var(--bsplus-analytics-accent) 18%,
transparent
);
color: var(--bsplus-analytics-accent);
}
@@ -1,7 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
let { alwaysShowUserName = false, onClick = undefined } = $props<{
alwaysShowUserName?: boolean;
@@ -73,12 +72,12 @@
>
{#if cloudState.isLoggedIn}
{#if cloudState.user?.pfpUrl}
<CloudPfpAvatar
user={cloudState.user}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
/>
{/if}
{#if !cloudState.user?.pfpUrl}
{:else}
<div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
{getInitials()}
</div>
@@ -112,12 +111,12 @@
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl}
<CloudPfpAvatar
user={cloudState.user}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{/if}
{#if !cloudState.user?.pfpUrl}
{:else}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()}
</div>
@@ -10,18 +10,14 @@
}>();
let emblaApi = $state();
const options = $derived({ loop: slides.length > 1 });
const plugins = $derived(
slides.length > 1
? [
Autoplay({
delay: 5000,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
]
: [],
);
const options = { loop: true };
const plugins = [
Autoplay({
delay: 5000,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
];
function onInit(event: CustomEvent) {
emblaApi = event.detail;
+2 -5
View File
@@ -1,13 +1,10 @@
<script lang="ts">
import logo from '@/resources/icons/betterseqta-dark-full.png';
import logoDark from '@/resources/icons/betterseqta-light-full.png';
import { closeStore } from '@/seqta/ui/renderStore'
import browser from 'webextension-polyfill';
import CloudHeader from './CloudHeader.svelte';
const handleCloseStore = () => {
void import('@/seqta/ui/renderStore').then((module) => module.closeStore());
};
// Props
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
searchTerm: string,
@@ -67,7 +64,7 @@
<!-- Close Button -->
<button
onclick={handleCloseStore}
onclick={closeStore}
class="p-1 px-3"
>
<span class="text-2xl font-IconFamily">&#xed8a;</span>
@@ -21,12 +21,9 @@
allStoreThemeRows?: Theme[];
}>();
let filteredThemes = $derived(themes.filter((theme: Theme) => {
const q = searchTerm.toLowerCase();
const name = (theme.name ?? '').toLowerCase();
const description = (theme.description ?? '').toLowerCase();
return name.includes(q) || description.includes(q);
}));
let filteredThemes = $derived(themes.filter((theme: Theme) =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
));
</script>
<div class="relative" >
@@ -234,7 +234,7 @@
$effect(() => {
if (displayTheme && modalElement) {
if (displayTheme) {
animate(
@@ -12,18 +12,18 @@
<div
onclick={onClick}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClick() }}
tabindex="0"
onkeydown={onClick}
tabindex="-1"
role="button"
class="relative w-16 h-16 cursor-pointer rounded-xl transition ring-3 dark:ring-zinc-500/50 ring-zinc-300 {isEditMode ? 'animate-shake' : ''} {isSelected ? 'dark:ring-4 ring-4' : 'ring-0'}"
>
{#if isEditMode}
<div
tabindex="0"
tabindex="-1"
role="button"
class="absolute top-0 right-0 z-10 flex w-6 h-6 p-2 text-white translate-x-1/2 -translate-y-1/2 bg-red-600 rounded-full place-items-center"
onclick={(e) => { e.stopPropagation(); onDelete() }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); onDelete() } }}
onclick={onDelete}
onkeydown={onDelete}
>
<div class="w-4 h-0.5 bg-white"></div>
</div>
@@ -174,19 +174,18 @@
if (parentElement) {
observer = new MutationObserver(checkActiveClass);
observer.observe(parentElement, { attributes: true, attributeFilter: ['class'] });
}
return () => {
observer?.disconnect();
backgroundUpdates.removeListener(syncBackgrounds);
};
return () => {
observer.disconnect();
backgroundUpdates.removeListener(syncBackgrounds);
};
}
});
onDestroy(() => {
observer?.disconnect();
backgrounds.forEach((bg) => {
if (bg.url) URL.revokeObjectURL(bg.url);
});
if (observer) {
observer.disconnect();
}
});
</script>
@@ -2,6 +2,8 @@
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
import { onDestroy, onMount } from 'svelte'
import browser from 'webextension-polyfill'
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { OpenStorePage } from '@/seqta/ui/renderStore'
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
@@ -19,14 +21,11 @@
let prevLoggedIn = $state(false);
let showSignInModal = $state(false);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => {
const now = s.isLoggedIn;
if (now && !prevLoggedIn && themes) void fetchThemes();
prevLoggedIn = now;
cloudLoggedIn = now;
});
return unsub;
cloudAuth.subscribe((s) => {
const now = s.isLoggedIn;
if (now && !prevLoggedIn && themes) void fetchThemes();
prevLoggedIn = now;
cloudLoggedIn = now;
});
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
@@ -103,14 +102,17 @@
selectedTheme: themeManager.getSelectedThemeId() || '',
}
if (themes && cloudLoggedIn) {
const status: Record<string, boolean> = {};
await Promise.all(
themes.themes.map(async (t) => {
try {
const res = (await browser.runtime.sendMessage({
type: 'fetchThemeDetails',
themeId: t.id,
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
const token = await cloudAuth.getStoredToken();
if (token) {
const status: Record<string, boolean> = {};
await Promise.all(
themes.themes.map(async (t) => {
try {
const res = (await browser.runtime.sendMessage({
type: 'fetchThemeDetails',
themeId: t.id,
token,
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
if (res?.success && res?.data?.theme) {
status[t.id] = !!res.data.theme.is_favorited;
}
@@ -120,32 +122,25 @@
})
);
favoriteStatus = status;
}
} else {
favoriteStatus = {};
}
}
const openStorePage = async () => {
const { OpenStorePage } = await import('@/seqta/ui/renderStore')
OpenStorePage()
}
const openThemeCreator = async (themeId?: string) => {
const { OpenThemeCreator } = await import('@/plugins/built-in/themes/ThemeCreator')
OpenThemeCreator(themeId)
closeExtensionPopup()
}
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
e.stopPropagation();
if (!cloudLoggedIn) {
showSignInModal = true;
return;
}
const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !favoriteStatus[theme.id];
const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite',
themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean };
if (result?.success) {
@@ -221,8 +216,8 @@
</div>
<div
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
onclick={(event) => { event.stopPropagation(); void openThemeCreator(theme.id) }}
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') void openThemeCreator(theme.id) }}
onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') OpenThemeCreator(theme.id); closeExtensionPopup() }}
role="button"
tabindex="-1"
>
@@ -270,7 +265,7 @@
{/if}
<button
onclick={() => void openStorePage()}
onclick={() => OpenStorePage()}
class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white"
>
<span class="text-xl font-IconFamily">&#xecc5;</span>
@@ -278,7 +273,7 @@
</button>
<button
onclick={() => void openThemeCreator()}
onclick={() => { OpenThemeCreator(); closeExtensionPopup() }}
class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white"
>
<span class="text-xl font-IconFamily">&#xec60;</span>
@@ -1,25 +1,21 @@
<script lang="ts">
import React from "react";
import ReactDOM from "react-dom";
import { onDestroy } from "svelte";
import { onDestroy, onMount } from "svelte";
const e = React.createElement;
let adapterProps = $props();
let container = $state<HTMLDivElement | null>(null);
let container: HTMLDivElement;
$effect(() => {
if (!container) return;
const { el, children, class: className, ...rest } = adapterProps;
onMount(() => {
const { el, children, class: _, ...props } = $$props;
try {
ReactDOM.render(e(el, rest, children), container);
ReactDOM.render(e(el, props, children), container);
} catch (err) {
console.warn(`react-adapter failed to mount.`, { err });
}
});
onDestroy(() => {
if (!container) return;
try {
ReactDOM.unmountComponentAtNode(container);
} catch (err) {
@@ -28,4 +24,4 @@
});
</script>
<div bind:this={container} class={adapterProps.class}></div>
<div bind:this={container} class={$$props.class}></div>
+53 -1
View File
@@ -1 +1,53 @@
export { settingsPopup } from "@/seqta/utils/settingsPopup";
type SettingsPopupCallback = () => void;
/**
* This is a singleton that triggers an update when the settings popup is closed.
* This is used to close the colour picker.
* Usage:
* settingsPopup.addListener(() => {
* console.log('Settings popup closed');
* });
*/
class SettingsPopup {
private static instance: SettingsPopup;
private listeners: Set<SettingsPopupCallback> = new Set();
private constructor() {}
public static getInstance(): SettingsPopup {
if (!SettingsPopup.instance) {
SettingsPopup.instance = new SettingsPopup();
}
return SettingsPopup.instance;
}
/**
* Registers a callback function to be invoked when the settings popup is closed.
*
* @param {SettingsPopupCallback} callback The function to call when the settings popup closes.
* This callback takes no arguments and returns void.
*/
public addListener(callback: SettingsPopupCallback): void {
this.listeners.add(callback);
}
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when the settings popup closes.
*
* @param {SettingsPopupCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: SettingsPopupCallback): void {
this.listeners.delete(callback);
}
/**
* Invokes all registered listener callbacks.
* This method should be called when the settings popup is closed to notify all subscribed components or services.
*/
public triggerClose(): void {
this.listeners.forEach((callback) => callback());
}
}
export const settingsPopup = SettingsPopup.getInstance();
+13 -88
View File
@@ -6,7 +6,7 @@
import browser from "webextension-polyfill";
import { standalone as StandaloneStore } from "../utils/standalone.svelte";
import { onMount, onDestroy } from "svelte";
import { onMount } from "svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
@@ -14,18 +14,10 @@
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
import type { Component } from "svelte";
import FontPickerModal from "../components/FontPickerModal.svelte";
import ColourPicker from "../components/ColourPicker.svelte";
import CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "@/seqta/utils/settingsPopup";
import {
checkGithubReleaseUpdate,
dismissNightlyUpdate,
getInstalledGhReleaseChannelLabel,
isGhReleaseUpdateCheckEnabled,
type GhReleaseUpdateInfo,
} from "@/utils/githubReleaseUpdate";
import { settingsPopup } from "../hooks/SettingsPopup";
let devModeSequence = "";
let settingsActiveTab = $state(0);
@@ -33,19 +25,6 @@
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
let disclaimerTitle = $state("Confirm");
let disclaimerMessage = $state("");
const ghReleaseUpdateEnabled = isGhReleaseUpdateCheckEnabled();
const ghReleaseChannelLabel = getInstalledGhReleaseChannelLabel();
let ghReleaseUpdate = $state<GhReleaseUpdateInfo | null>(null);
const openGhRelease = () => {
const url = ghReleaseUpdate?.url
?? "https://github.com/BetterSEQTA/BetterSEQTA-Plus/releases";
if (ghReleaseUpdate?.available) {
dismissNightlyUpdate();
}
window.open(url, "_blank");
closeExtensionPopup();
};
const handleDevModeToggle = () => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -64,19 +43,10 @@
}, 10000);
};
let ColourPickerComponent = $state<Component | null>(null);
const openColourPicker = async () => {
if (!ColourPickerComponent) {
ColourPickerComponent = (await import("../components/ColourPicker.svelte")).default;
}
const openColourPicker = () => {
showColourPicker = true;
};
const openFontPicker = () => {
showFontPicker = true;
};
const openChangelog = () => {
OpenWhatsNewPopup();
closeExtensionPopup();
@@ -99,7 +69,6 @@
let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state<boolean>(false);
let showFontPicker = $state<boolean>(false);
let showCloudPanel = $state<boolean>(false);
const openCloudPanel = () => {
@@ -113,33 +82,20 @@
showDisclaimerModal = true;
};
const closePopupsOnSettingsClose = () => {
showColourPicker = false;
showFontPicker = false;
showCloudPanel = false;
};
onMount(() => {
settingsPopup.addListener(closePopupsOnSettingsClose);
settingsPopup.addListener(() => {
showColourPicker = false;
showCloudPanel = false;
});
if (standalone) {
StandaloneStore.setStandalone(true);
}
if (ghReleaseUpdateEnabled) {
void checkGithubReleaseUpdate().then((info) => {
ghReleaseUpdate = info;
});
}
});
onDestroy(() => {
settingsPopup.removeListener(closePopupsOnSettingsClose);
});
</script>
<div
class="relative w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode
class="w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode
? 'dark'
: ''} {standalone ? 'h-[600px]' : 'h-full rounded-xl'} overflow-clip"
>
@@ -171,29 +127,7 @@
/>
{#if !standalone}
<div class="flex absolute top-1 right-1 gap-1 items-start">
{#if ghReleaseUpdateEnabled}
<div class="flex flex-col items-end gap-0.5 max-w-[9rem] mr-0.5">
{#if ghReleaseUpdate?.available}
<button
type="button"
onclick={openGhRelease}
class="px-1.5 py-0.5 text-[10px] font-semibold leading-tight text-white rounded-full bg-amber-500 hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-500"
title="Open GitHub release"
>
Update available — {ghReleaseUpdate.label}
</button>
{/if}
<p class="text-[9px] leading-tight text-right text-zinc-500 dark:text-zinc-400">
{#if ghReleaseChannelLabel}
{ghReleaseChannelLabel} — do not upload to extension stores.
{:else}
GitHub release build — do not upload to extension stores.
{/if}
</p>
</div>
{/if}
<div class="flex gap-1 items-center">
<div class="flex absolute top-1 right-1 gap-1 items-center">
<button
onclick={openAbout}
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
@@ -215,7 +149,6 @@
>
{"\uecba"}
</button>
</div>
<!-- <button
onclick={openMinecraftServer}
@@ -360,7 +293,7 @@
{
title: "Settings",
Content: Settings,
props: { showColourPicker: openColourPicker, showFontPicker: openFontPicker, showDisclaimer, showCloudPanel: openCloudPanel },
props: { showColourPicker: openColourPicker, showDisclaimer, showCloudPanel: openCloudPanel },
},
{ title: "Shortcuts", Content: Shortcuts },
{ title: "Themes", Content: Theme },
@@ -368,8 +301,8 @@
/>
</div>
{#if showColourPicker && ColourPickerComponent}
<ColourPickerComponent
{#if showColourPicker}
<ColourPicker
hidePicker={() => {
showColourPicker = false;
}}
@@ -385,14 +318,6 @@
{/if}
</div>
{#if showFontPicker}
<FontPickerModal
hidePicker={() => {
showFontPicker = false;
}}
/>
{/if}
{#if showDisclaimerModal && disclaimerCallbacks}
<DisclaimerModal
title={disclaimerTitle}
+5 -114
View File
@@ -15,38 +15,10 @@
import CloudHeader from "@/interface/components/store/CloudHeader.svelte"
import { cloudAuth } from "@/seqta/utils/CloudAuth"
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
import { showThemeOfTheMonthPopupNow } from "@/seqta/utils/Openers/OpenThemeOfTheMonthPopup"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase"
import { onMount } from "svelte"
let devApiBaseInput = $state<string>(getStoredOverride() ?? "")
let devApiBaseActive = $state<string | null>(getStoredOverride())
function applyDevApiBase() {
const trimmed = devApiBaseInput.trim()
if (trimmed === "") {
setApiBase(null)
devApiBaseActive = null
return
}
if (!/^https?:\/\//.test(trimmed)) {
alert("Please enter a full URL starting with http:// or https://")
return
}
setApiBase(trimmed)
devApiBaseActive = trimmed.replace(/\/$/, "")
}
function clearDevApiBase() {
devApiBaseInput = ""
setApiBase(null)
devApiBaseActive = null
}
import { getAllPluginSettings } from "@/plugins"
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
// Union type representing all possible settings
@@ -81,9 +53,7 @@
settings: Record<string, SettingType>;
}
const pluginSettings = getAllPluginSettings().filter(
(plugin) => !(isSeqtaEngageExperience() && plugin.pluginId === "global-search"),
) as Plugin[];
const pluginSettings = getAllPluginSettings() as Plugin[];
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({});
let cloudState = $state(cloudAuth.state);
@@ -129,13 +99,12 @@
await browser.storage.local.set({ [storageKey]: currentSettings });
}
onMount(() => {
void loadPluginSettings();
});
$effect(() => {
loadPluginSettings();
})
const { showColourPicker, showFontPicker, showDisclaimer, showCloudPanel } = $props<{
const { showColourPicker, showDisclaimer, showCloudPanel } = $props<{
showColourPicker: () => void;
showFontPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void;
showCloudPanel: () => void;
}>();
@@ -194,16 +163,6 @@
onClick: showColourPicker
}
},
{
title: "Interface Font",
description: "Choose the typeface used across SEQTA Learn",
id: 16,
Component: Button,
props: {
onClick: showFontPicker,
text: "Change"
}
},
{
title: "Icon Only Sidebar",
description: "Show only icons in the sidebar for a compact layout",
@@ -435,18 +394,6 @@
{/each}
{/if}
</div>
{#if plugin.pluginId === 'global-search'}
{@render Setting({
title: "Theme of the Month",
description: "Show the monthly featured theme popup when a new entry is available",
id: 15,
Component: Switch,
props: {
state: !($settingsState.themeOfTheMonthDisabled ?? false),
onChange: (isOn: boolean) => settingsState.themeOfTheMonthDisabled = !isOn
}
})}
{/if}
</div>
{/each}
@@ -536,22 +483,6 @@
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Show Theme of the Month</h2>
<p class="text-xs">Fetch and show the current month's popup now (ignores dismissed state)</p>
</div>
<div>
<Button
onClick={async () => {
closeExtensionPopup();
await new Promise((resolve) => setTimeout(resolve, 100));
await showThemeOfTheMonthPopupNow();
}}
text="Show Now"
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
@@ -561,46 +492,6 @@
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
</div>
</div>
<div class="flex flex-col gap-2 px-4 py-3">
<div class="flex justify-between items-start gap-3">
<div class="pr-4">
<h2 class="text-sm font-bold">API Base URL (session only)</h2>
<p class="text-xs">Override the content API host for this browser session. Cleared on restart. Affects themes, theme of the month, and other server-driven content.</p>
{#if devApiBaseActive}
<p class="text-xs mt-1 text-amber-600 dark:text-amber-400">
Override active: <span class="font-mono">{devApiBaseActive}</span>
</p>
{/if}
</div>
</div>
<div class="flex gap-2 items-center">
<input
type="text"
placeholder="https://betterseqta.org"
bind:value={devApiBaseInput}
class="flex-1 px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
/>
<Button onClick={applyDevApiBase} text="Apply" />
{#if devApiBaseActive}
<Button onClick={clearDevApiBase} text="Clear" />
{/if}
</div>
</div>
<div class="flex flex-col gap-2 px-4 py-3">
<div>
<h2 class="text-sm font-bold">GitHub latest version override</h2>
<p class="text-xs">Pretend a newer GitHub release exists to test the update badge. Only applies when dev mode is on.</p>
</div>
<input
type="text"
placeholder="e.g. 9.9.9"
value={$settingsState.devGhReleaseVersionOverride ?? ""}
oninput={(e) => {
settingsState.devGhReleaseVersionOverride = e.currentTarget.value;
}}
class="px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
/>
</div>
</div>
{/if}
</div>
+11 -17
View File
@@ -24,18 +24,18 @@
});
const switchChange = (shortcut: any) => {
const current = settingsState.shortcuts ?? [];
const idx = current.findIndex(s => s.name === shortcut);
const idx = $settingsState.shortcuts.findIndex(s => s.name === shortcut);
if (idx !== -1) {
const updated = current.map(s =>
// Create a new array with the toggled value to ensure reactivity
const updated = settingsState.shortcuts.map(s =>
s.name === shortcut ? { ...s, enabled: !s.enabled } : s
);
settingsState.setKey("shortcuts", updated);
settingsState.shortcuts = updated;
} else {
settingsState.setKey("shortcuts", [
...current,
{ name: shortcut, enabled: true },
]);
settingsState.shortcuts = [
...settingsState.shortcuts,
{ name: shortcut, enabled: true }
];
}
}
@@ -82,10 +82,7 @@
if (isValidTitle(newTitle) && isValidURL(newURL)) {
const icon = newIcon || newTitle[0];
const newShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon };
settingsState.setKey("customshortcuts", [
...(settingsState.customshortcuts ?? []),
newShortcut,
]);
settingsState.customshortcuts = [...settingsState.customshortcuts, newShortcut];
newTitle = "";
newURL = "";
@@ -97,10 +94,7 @@
};
const deleteCustomShortcut = (index: number) => {
settingsState.setKey(
"customshortcuts",
(settingsState.customshortcuts ?? []).filter((_, i) => i !== index),
);
settingsState.customshortcuts = settingsState.customshortcuts.filter((_, i) => i !== index);
};
</script>
@@ -209,7 +203,7 @@
</div>
<!-- Custom Shortcuts Section -->
{#each ($settingsState.customshortcuts ?? []) as shortcut, index}
{#each $settingsState.customshortcuts as shortcut, index}
<div class="flex justify-between items-center px-4 py-3">
{shortcut.name}
<button aria-label="Delete Shortcut" onclick={() => deleteCustomShortcut(index)}>
+1 -1
View File
@@ -36,4 +36,4 @@
</div>
</div>
{/if}
</div>
</div>
+24 -101
View File
@@ -7,7 +7,7 @@
import SkeletonLoader from '../components/SkeletonLoader.svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import type { Theme } from '../types/Theme'
import { visibleStoreThemes, buildCoverSlidesForThemes, normalizeStoreTheme } from '@/interface/utils/themeStoreFlavours'
import { visibleStoreThemes, buildCoverSlidesForThemes } from '@/interface/utils/themeStoreFlavours'
import browser from 'webextension-polyfill'
import ThemeModal from '../components/store/ThemeModal.svelte'
import Header from '../components/store/Header.svelte'
@@ -18,15 +18,11 @@
import Backgrounds from '../components/store/Backgrounds.svelte'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
import { consumePendingHighlightThemeId } from '@/seqta/utils/openThemeStoreWithHighlight'
const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
return unsub;
});
cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
// State variables
let searchTerm = $state('');
@@ -44,22 +40,9 @@
let activeTab = $state('themes');
let error = $state<string | null>(null);
let fetchAttempt = $state(0);
let selectedBackground = $state<string | null>(null);
let showSignInOverlay = $state(false);
const MAX_FETCH_ATTEMPTS = 3;
const FETCH_MESSAGE_TIMEOUT_MS = 25_000;
function sendMessageWithTimeout<T>(message: object): Promise<T> {
return Promise.race([
browser.runtime.sendMessage(message) as Promise<T>,
new Promise<T>((_, reject) => {
setTimeout(() => reject(new Error('Theme store request timed out — reload the SEQTA page after updating the extension.')), FETCH_MESSAGE_TIMEOUT_MS);
}),
]);
}
const fetchCurrentThemes = async () => {
const themes = await themeManager.getAvailableThemes();
currentThemes = themes.filter(theme => theme !== null).map(theme => theme.id);
@@ -89,11 +72,13 @@
}
const toggleFavorite = async (theme: Theme) => {
if (!cloudLoggedIn) return;
const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !theme.is_favorited;
const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite',
themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean };
if (result?.success) {
@@ -114,86 +99,45 @@
};
// Fetch themes via background script (avoids CORS when store runs inside SEQTA page)
const fetchThemes = async (isRetry = false) => {
if (!isRetry) {
fetchAttempt = 0;
error = null;
}
const fetchThemes = async () => {
try {
const data = await sendMessageWithTimeout<{
success?: boolean;
data?: { themes: unknown[] };
error?: string;
}>({
const token = await cloudAuth.getStoredToken();
const data = (await browser.runtime.sendMessage({
type: 'fetchThemes',
});
if (!data?.success || !Array.isArray(data?.data?.themes)) {
token: token ?? undefined,
})) as {
success?: boolean;
data?: { themes: Theme[] };
error?: string;
};
if (!data?.success || !data?.data?.themes) {
throw new Error(data?.error || 'Failed to fetch themes');
}
themes = data.data.themes
.map((row) => normalizeStoreTheme(row as Record<string, unknown>))
.filter((t) => t.id.length > 0)
.sort(compareStoreThemes);
error = null;
themes = [...data.data.themes].sort(compareStoreThemes);
loading = false;
} catch (err) {
console.error('Failed to fetch themes', err);
fetchAttempt += 1;
if (fetchAttempt >= MAX_FETCH_ATTEMPTS) {
error =
err instanceof Error
? err.message
: 'Could not load themes. Reload the SEQTA page, then open the store again.';
loading = false;
return;
}
setTimeout(() => fetchThemes(true), 5000);
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs
}
};
function focusThemeById(themeId: string) {
const match = themes.find((t) => t.id === themeId)
?? themes.find((t) => t.flavours?.some((f) => f.id === themeId));
if (match) {
activeTab = 'themes';
searchTerm = '';
displayTheme = match;
}
}
function onHighlightThemeEvent(e: Event) {
const detail = (e as CustomEvent).detail;
if (detail?.themeId && typeof detail.themeId === 'string') {
focusThemeById(detail.themeId);
}
}
// On mount
onMount(async () => {
window.addEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
await fetchThemes();
await fetchCurrentThemes();
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
darkMode = $settingsState.DarkMode;
const pending = consumePendingHighlightThemeId();
if (pending) focusThemeById(pending);
return () => {
window.removeEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
};
});
// Filter themes (list is already featured-first, then newest; filter preserves order)
let filteredThemes = $derived(
listThemes.filter((theme) => {
const q = searchTerm.toLowerCase();
const name = (theme.name ?? '').toLowerCase();
const description = (theme.description ?? '').toLowerCase();
return name.includes(q) || description.includes(q);
}),
listThemes.filter(
(theme) =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
theme.description.toLowerCase().includes(searchTerm.toLowerCase()),
),
);
async function installThemeFromStore(themeId: string, meta: Theme) {
@@ -250,28 +194,7 @@
<!-- Loading State -->
{#if loading}
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
{#each Array(6) as _, i (i)}
<SkeletonLoader width="100%" height="200px" />
{/each}
</div>
{:else if error}
<div class="flex flex-col items-center justify-center py-24 text-center max-w-lg mx-auto">
<h2 class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">Couldn&apos;t load themes</h2>
<p class="mt-3 text-zinc-600 dark:text-zinc-300">{error}</p>
<p class="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
After an extension update, reload your SEQTA tab so the new version can talk to the browser.
</p>
<button
type="button"
class="mt-6 px-4 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700"
onclick={() => {
loading = true;
error = null;
void fetchThemes();
}}
>
Try again
</button>
<SkeletonLoader width="100%" height="200px" />
</div>
{:else}
<!-- Themes Tab Content -->
+3 -8
View File
@@ -4,21 +4,16 @@ import type { Action } from "svelte/action";
* Svelte action that moves the element to a different DOM target.
* Defaults to the nearest ShadowRoot so styles remain intact when the app
* is rendered inside a shadow DOM. Falls back to document.body otherwise.
* Pass `document.body` to escape transformed/contained settings popups entirely.
* Keeps all Svelte reactivity/events intact while escaping ancestor stacking contexts.
*/
export const portal: Action<HTMLElement, HTMLElement | ShadowRoot | undefined> = (
node,
target,
) => {
export const portal: Action<HTMLElement, HTMLElement | ShadowRoot | undefined> = (node, target) => {
const root = node.getRootNode();
const dest = target ?? (root instanceof ShadowRoot ? root : document.body);
dest.appendChild(node);
return {
update(newTarget) {
const nextDest =
newTarget ?? (root instanceof ShadowRoot ? root : document.body);
nextDest.appendChild(node);
(newTarget ?? dest).appendChild(node);
},
destroy() {
node.remove();
-74
View File
@@ -1,74 +0,0 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
const THEME_CSS_VARS = [
"--better-main",
"--better-pale",
"--better-light",
"--text-color",
"--background-primary",
"--background-secondary",
"--text-primary",
"--theme-offset-bg",
"--better-sub",
] as const;
const ACCENT_CSS_VARS = [
"--better-main",
"--accent-color-value",
"--accentColor",
"--colour-betterseqta-blue",
] as const;
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;
}
function resolvePageAccentColor(): string {
const computed = getComputedStyle(document.documentElement);
for (const name of ACCENT_CSS_VARS) {
const solid = extractSolidColor(computed.getPropertyValue(name));
if (solid) return solid;
}
const fromSettings = settingsState.selectedColor?.trim();
if (fromSettings) {
const solid = extractSolidColor(fromSettings);
if (solid) return solid;
}
return "#007bff";
}
/** Copy SEQTA page theme tokens onto a portaled UI root (matches analytics sync). */
export function syncPageThemeToElement(target: HTMLElement): void {
const computed = getComputedStyle(document.documentElement);
for (const name of THEME_CSS_VARS) {
const value = computed.getPropertyValue(name).trim();
if (value) {
target.style.setProperty(name, value);
}
}
const accent = resolvePageAccentColor();
target.style.setProperty("--bsplus-analytics-accent", accent);
target.style.setProperty("--better-main", accent);
target.classList.toggle(
"dark",
document.documentElement.classList.contains("dark"),
);
}
+1 -82
View File
@@ -4,90 +4,9 @@ export function isHiddenStoreTheme(theme: Theme): boolean {
return theme.theme_role === "slave";
}
/** Coerce API / fallback rows into the store `Theme` shape (camelCase images, safe strings). */
export function normalizeStoreTheme(raw: Record<string, unknown>): Theme {
const flavours = Array.isArray(raw.flavours)
? (raw.flavours as Record<string, unknown>[]).map(
(f): ThemeFlavour => ({
id: String(f.id ?? ""),
name: String(f.name ?? ""),
accent_color: String(f.accent_color ?? f.accentColor ?? ""),
cover_image: String(f.cover_image ?? f.coverImage ?? ""),
marquee_image:
typeof (f.marquee_image ?? f.marqueeImage) === "string"
? String(f.marquee_image ?? f.marqueeImage)
: undefined,
download_count:
typeof f.download_count === "number"
? f.download_count
: typeof f.downloadCount === "number"
? f.downloadCount
: undefined,
}),
)
: undefined;
return {
id: String(raw.id ?? ""),
name: String(raw.name ?? "Untitled"),
description: String(raw.description ?? ""),
coverImage: String(raw.coverImage ?? raw.cover_image ?? ""),
marqueeImage:
typeof (raw.marqueeImage ?? raw.marquee_image) === "string"
? String(raw.marqueeImage ?? raw.marquee_image)
: undefined,
theme_json_url:
typeof (raw.theme_json_url ?? raw.themeJsonUrl) === "string"
? String(raw.theme_json_url ?? raw.themeJsonUrl)
: undefined,
is_favorited: raw.is_favorited === true || raw.isFavorited === true,
favorite_count:
typeof raw.favorite_count === "number"
? raw.favorite_count
: typeof raw.favoriteCount === "number"
? raw.favoriteCount
: undefined,
download_count:
typeof raw.download_count === "number"
? raw.download_count
: typeof raw.downloadCount === "number"
? raw.downloadCount
: undefined,
author: typeof raw.author === "string" ? raw.author : undefined,
featured: raw.featured === true,
tags: Array.isArray(raw.tags) ? (raw.tags as string[]) : undefined,
created_at:
typeof raw.created_at === "number"
? raw.created_at
: typeof raw.createdAt === "number"
? raw.createdAt
: undefined,
updated_at:
typeof raw.updated_at === "number"
? raw.updated_at
: typeof raw.updatedAt === "number"
? raw.updatedAt
: undefined,
theme_role:
raw.theme_role === "master" || raw.theme_role === "slave" || raw.theme_role === "standard"
? raw.theme_role
: undefined,
master_id:
typeof (raw.master_id ?? raw.masterId) === "string"
? String(raw.master_id ?? raw.masterId)
: undefined,
flavours,
};
}
/** Grid and search: omit slave rows (when API sends a flattened list). */
export function visibleStoreThemes(themes: Theme[]): Theme[] {
const visible = themes.filter((t) => !isHiddenStoreTheme(t));
// If every row is a slave (bad/migration payload), avoid an empty grid.
if (visible.length === 0 && themes.length > 0) {
return themes;
}
return visible;
return themes.filter((t) => !isHiddenStoreTheme(t));
}
function marqueeOrCoverUrl(t: { marqueeImage?: string; coverImage: string }): string {
+6 -14
View File
@@ -509,13 +509,7 @@ function deepFunctionCheck(obj, path = "") {
}
}
function isTrustedMessage(event) {
return event.source === window && event.origin === window.location.origin;
}
window.addEventListener("message", (event) => {
if (!isTrustedMessage(event)) return;
if (event.data.type === "reactFiberRequest") {
const { selector, action, payload, debug, messageId } = event.data;
const fiberInstance = ReactFiber.find(selector, {
@@ -528,14 +522,12 @@ window.addEventListener("message", (event) => {
response = fiberInstance.getState(payload.key);
break;
case "setState":
if (
payload.updateObject &&
typeof payload.updateObject === "object" &&
!Array.isArray(payload.updateObject)
) {
fiberInstance.setState(payload.updateObject);
// Handle both function and object updates
if (payload.updateFn) {
const updateFn = new Function('return ' + payload.updateFn)();
fiberInstance.setState(updateFn);
} else {
console.warn("[pageState] setState rejected: only plain objects are allowed");
fiberInstance.setState(payload.updateObject);
}
response = {};
break;
@@ -588,7 +580,7 @@ window.addEventListener("message", (event) => {
response,
messageId,
},
window.location.origin,
"*",
);
} else if (event.data.type === "triggerKeyboardEvent") {
// Handle keyboard event triggering from content script
@@ -1,46 +0,0 @@
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
function randomEngagePdfFileName(): string {
const token = Math.random().toString(36).slice(2, 10);
return `${token}.pdf`;
}
export async function requestEngageAssessmentPdf(params: {
assessmentID: string | number;
metaclassID: string | number;
studentID: string | number;
}): Promise<string> {
const fileName = randomEngagePdfFileName();
const cacheBuster = Math.random().toString(36).slice(2, 10);
const response = await fetch(
`${location.origin}/seqta/parent/print/assessment?${cacheBuster}`,
{
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({
id: params.assessmentID,
metaclass: params.metaclassID,
student: Number(params.studentID),
fileName,
}),
},
);
if (!response.ok) {
throw new Error(
`Failed to generate PDF: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as {
payload?: { file?: string };
};
return data.payload?.file ?? fileName;
}
export function getEngageAssessmentReportUrl(fileName: string): string {
return `${location.origin}/seqta/parent/report/get?file=${encodeURIComponent(fileName)}`;
}
@@ -15,12 +15,10 @@ import {
letterToNumber,
parseAssessments,
processAssessments,
type WeightingEntry,
} from "./utils.ts";
import { injectRubricCopyButtons, teardownRubricCopyButtons } from "./rubricCopy.ts";
interface weightingsStorage {
weightings: Record<string, WeightingEntry>;
weightings: Record<string, string>;
assessments: Record<string, string>;
weightingOverrides: Record<string, string>;
}
@@ -41,8 +39,6 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
const instance = new AssessmentsAveragePluginClass();
let overrideListenerController: AbortController | null = null;
let wrapperColourObserver: MutationObserver | null = null;
let wrapperColourObserverTimeout: ReturnType<typeof setTimeout> | null = null;
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
id: "assessments-average",
@@ -56,9 +52,7 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
await initStorage(api);
clearStuck(api);
const { unregister: unregisterWrapperMount } = api.seqta.onMount(
".assessmentsWrapper",
async () => {
api.seqta.onMount(".assessmentsWrapper", async () => {
await waitForElm(
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
true,
@@ -66,8 +60,8 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
1000,
);
// Wire listeners first so the very first re-render triggered by a
// background handleWeightings completion can find them.
await parseAssessments(api);
await renderSubjectAverage(api);
overrideListenerController?.abort();
overrideListenerController = new AbortController();
document.addEventListener(
@@ -75,73 +69,24 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
() => renderSubjectAverage(api),
{ signal: overrideListenerController.signal },
);
document.addEventListener(
"betterseqta:weightingsChanged",
() => renderSubjectAverage(api),
{ signal: overrideListenerController.signal },
);
// Render immediately with whatever is already cached. Fresh entries
// and stale-with-previous-value entries both contribute their numeric
// weights, so the subject average appears without waiting on any
// background PDF refetches.
await renderSubjectAverage(api);
// Kick off indexing in the background. Each completion dispatches
// betterseqta:weightingsChanged, which triggers a fresh render.
void parseAssessments(api);
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
wrapperColourObserver?.disconnect();
if (wrapperColourObserverTimeout) {
clearTimeout(wrapperColourObserverTimeout);
}
wrapperColourObserver = new MutationObserver(() => {
const observer = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
wrapperColourObserver.observe(wrapper, { childList: true, subtree: true });
wrapperColourObserverTimeout = setTimeout(() => {
wrapperColourObserver?.disconnect();
wrapperColourObserver = null;
wrapperColourObserverTimeout = null;
}, 10000);
observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
}
},
);
const { unregister: unregisterSelectedMount } = api.seqta.onMount(
"[class*='SelectedAssessment__']",
() => {
});
api.seqta.onMount("[class*='SelectedAssessment__']", () => {
injectWeightingsTab(api);
injectRubricCopyButtons();
},
);
return () => {
overrideListenerController?.abort();
overrideListenerController = null;
wrapperColourObserver?.disconnect();
wrapperColourObserver = null;
if (wrapperColourObserverTimeout) {
clearTimeout(wrapperColourObserverTimeout);
wrapperColourObserverTimeout = null;
}
teardownRubricCopyButtons();
unregisterWrapperMount();
unregisterSelectedMount();
};
});
},
};
let renderInFlight = false;
let renderQueued = false;
async function renderSubjectAverage(api: any) {
if (renderInFlight) {
// Coalesce: remember that fresh data arrived during this render and
// re-run once the current pass finishes, so the UI catches up to the
// latest storage state instead of silently dropping the event.
renderQueued = true;
return;
}
if (renderInFlight) return;
renderInFlight = true;
try {
@@ -194,13 +139,8 @@ async function renderSubjectAverage(api: any) {
?.textContent?.includes("Subject Average"),
);
const {
weightedTotal,
totalWeight,
hasInaccurateWeighting,
hasRefreshingWeighting,
count,
} = await processAssessments(api, assessmentItems);
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
await processAssessments(api, assessmentItems);
if (!count || totalWeight === 0) return;
const thermoscoreElement = document.querySelector(
@@ -234,22 +174,11 @@ async function renderSubjectAverage(api: any) {
let warningHTML = "";
if (hasInaccurateWeighting) {
warningHTML = /* html */ `
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3; white-space: nowrap;">
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
Some weightings unavailable
</div>
`;
} else if (hasRefreshingWeighting) {
warningHTML = /* html */ `
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.55); opacity: 0.8; line-height: 1.3; white-space: nowrap;" title="Some weightings are being re-checked; the average may change shortly">
Refreshing weightings
</div>
`;
}
const thermoscoreTitle = hasInaccurateWeighting
? `${display} (some weightings unavailable)`
: hasRefreshingWeighting
? `${display} (re-checking weightings)`
: display;
assessmentsList.insertBefore(
stringToHTML(/* html */ `
<div class="${assessmentItemClass}">
@@ -263,7 +192,7 @@ async function renderSubjectAverage(api: any) {
</div>
<div class="${thermoscoreClass}">
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
<div class="${textClass}" title="${thermoscoreTitle}">${display}</div>
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
</div>
</div>
</div>
@@ -273,10 +202,6 @@ async function renderSubjectAverage(api: any) {
applySubjectColourToOverallResult();
} finally {
renderInFlight = false;
if (renderQueued) {
renderQueued = false;
void renderSubjectAverage(api);
}
}
}
function applySubjectColourToOverallResult() {
@@ -1,388 +0,0 @@
const RUBRIC_SELECTOR =
"[class*='AssessableCriterion__rubric___'][class*='Rubric__Rubric___'], [class*='Rubric__Rubric___'][class*='AssessableCriterion__rubric___']";
const ENHANCED_ATTR = "data-betterseqta-rubric-copy";
const STYLE_ID = "betterseqta-rubric-copy-styles-v2";
let observer: MutationObserver | null = null;
function ensureStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.betterseqta-rubric-copy-host {
position: relative;
}
.betterseqta-rubric-copy-overlay {
position: absolute;
inset: auto 0 0 0;
display: flex;
justify-content: flex-end;
align-items: flex-end;
padding: 0.75rem 0.85rem;
pointer-events: none;
opacity: 0;
transform: translateY(10px);
transition:
opacity 0.35s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.72) 0%,
rgba(0, 0, 0, 0.42) 42%,
rgba(0, 0, 0, 0.08) 72%,
transparent 100%
);
border-radius: 0 0 8px 8px;
z-index: 5;
}
.betterseqta-rubric-copy-host:hover .betterseqta-rubric-copy-overlay,
.betterseqta-rubric-copy-host:focus-within .betterseqta-rubric-copy-overlay {
opacity: 1;
transform: translateY(0);
}
.betterseqta-rubric-copy-btn {
pointer-events: auto;
display: inline-flex !important;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.75rem !important;
margin: 0 !important;
border: 1px solid rgba(15, 23, 42, 0.12) !important;
border-radius: 8px !important;
background: rgba(255, 255, 255, 0.96) !important;
color: #0f172a !important;
font-family: Rubik, system-ui, sans-serif !important;
font-size: 0.8125rem !important;
font-weight: 600 !important;
line-height: 1 !important;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.28);
transform: translateY(0) scale(1);
transition:
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
background 0.28s ease,
color 0.28s ease,
box-shadow 0.28s ease,
border-color 0.28s ease;
}
.betterseqta-rubric-copy-btn:hover {
transform: translateY(-1px) scale(1.04) !important;
background: #f1f5f9 !important;
color: #0f172a !important;
border-color: rgba(15, 23, 42, 0.18) !important;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.32);
}
.betterseqta-rubric-copy-btn:active {
transform: translateY(0) scale(0.98) !important;
background: #e2e8f0 !important;
color: #0f172a !important;
}
.betterseqta-rubric-copy-btn:focus-visible {
outline: none !important;
box-shadow:
0 0 0 2px rgba(255, 255, 255, 0.95),
0 0 0 4px rgba(59, 130, 246, 0.85) !important;
}
.betterseqta-rubric-copy-btn svg {
width: 1rem !important;
height: 1rem !important;
flex-shrink: 0;
stroke: currentColor !important;
fill: none !important;
}
.betterseqta-rubric-copy-btn.is-copied {
background: #ecfdf5 !important;
color: #047857 !important;
border-color: rgba(4, 120, 87, 0.25) !important;
}
.betterseqta-rubric-copy-btn.is-copied:hover {
background: #d1fae5 !important;
color: #065f46 !important;
}
@media (prefers-reduced-motion: reduce) {
.betterseqta-rubric-copy-overlay,
.betterseqta-rubric-copy-btn {
transition: none;
}
}
`;
document.head.appendChild(style);
}
function cellText(element: Element | null | undefined): string {
return element?.textContent?.replace(/\s+/g, " ").trim() ?? "";
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
interface RubricCell {
text: string;
selected: boolean;
}
interface RubricTableData {
header: string[];
rows: RubricCell[][];
}
function parseRubricTable(rubric: Element): RubricTableData | null {
const lines = rubric.querySelectorAll("[class*='Rubric__line___']");
const rows: RubricCell[][] = [];
lines.forEach((line) => {
const meta = line.querySelector("[class*='Rubric__meta___']");
const label = cellText(meta?.querySelector("[class*='Rubric__label___']"));
const criterion = cellText(
meta?.querySelector("[class*='Rubric__description___']"),
);
const row: RubricCell[] = [
{ text: label, selected: false },
{ text: criterion, selected: false },
];
line.querySelectorAll("[class*='Rubric__descriptor___']").forEach((descriptor) => {
const text = cellText(
descriptor.querySelector("[class*='Rubric__description___']"),
);
const selected = Array.from(descriptor.classList).some((cls) =>
cls.startsWith("Rubric__selected___"),
);
row.push({ text, selected });
});
if (row.some((cell) => cell.text)) rows.push(row);
});
if (!rows.length) return null;
const maxCols = Math.max(...rows.map((row) => row.length));
const normalized = rows.map((row) => {
const copy = [...row];
while (copy.length < maxCols) {
copy.push({ text: "", selected: false });
}
return copy;
});
const header = [
"Category",
"Criterion",
...Array.from({ length: maxCols - 2 }, (_, i) => `Band ${i + 1}`),
].slice(0, maxCols);
return { header, rows: normalized };
}
function rubricToPlainText(table: RubricTableData): string {
const formatCell = (cell: RubricCell) =>
cell.selected && cell.text ? `${cell.text} (selected)` : cell.text;
return [
table.header.join("\t"),
...table.rows.map((row) => row.map(formatCell).join("\t")),
].join("\n");
}
const RUBRIC_PASTE_FONT_PT = 7;
function rubricPasteFontStyle(): string {
return [
`font-size:${RUBRIC_PASTE_FONT_PT}pt`,
"mso-ansi-font-size:8.0pt",
"mso-bidi-font-size:8.0pt",
"font-family:Calibri,Arial,sans-serif",
"line-height:1.2",
].join(";");
}
function rubricPasteCellContent(text: string): string {
return `<span style="${rubricPasteFontStyle()}">${escapeHtml(text)}</span>`;
}
function rubricToHtmlTable(table: RubricTableData): string {
const baseFont = rubricPasteFontStyle();
const cellStyle =
`border:1px solid #000000;border-collapse:collapse;padding:4px;vertical-align:top;${baseFont}`;
const headerStyle = `${cellStyle}background:#f3f4f6;font-weight:700;`;
const selectedStyle = `${cellStyle}background:#dbeafe;font-weight:600;`;
const headerRow = table.header
.map(
(heading) =>
`<th style="${headerStyle}">${rubricPasteCellContent(heading)}</th>`,
)
.join("");
const bodyRows = table.rows
.map((row) => {
const cells = row
.map((cell) => {
const style = cell.selected ? selectedStyle : cellStyle;
return `<td style="${style}">${rubricPasteCellContent(cell.text)}</td>`;
})
.join("");
return `<tr>${cells}</tr>`;
})
.join("");
return [
`<table border="1" cellpadding="0" cellspacing="0" style="border-collapse:collapse;width:100%;${baseFont}">`,
`<thead><tr>${headerRow}</tr></thead>`,
`<tbody>${bodyRows}</tbody>`,
"</table>",
].join("");
}
function rubricToHtmlDocument(table: RubricTableData): string {
return [
"<!DOCTYPE html>",
"<html>",
"<head><meta charset=\"utf-8\"></head>",
`<body style="${rubricPasteFontStyle()}">`,
rubricToHtmlTable(table),
"</body>",
"</html>",
].join("");
}
async function copyRubricTable(rubric: Element, button: HTMLButtonElement) {
const table = parseRubricTable(rubric);
if (!table) return;
const plain = rubricToPlainText(table);
const htmlTable = rubricToHtmlTable(table);
const htmlDocument = rubricToHtmlDocument(table);
let copied = false;
if (navigator.clipboard?.write && typeof ClipboardItem !== "undefined") {
try {
await navigator.clipboard.write([
new ClipboardItem({
"text/plain": new Blob([plain], { type: "text/plain" }),
"text/html": new Blob([htmlDocument], { type: "text/html" }),
}),
]);
copied = true;
} catch {
// Fall through to legacy rich-text copy.
}
}
if (!copied) {
const host = document.createElement("div");
host.contentEditable = "true";
host.innerHTML = htmlTable;
host.style.position = "fixed";
host.style.left = "-9999px";
host.style.top = "0";
document.body.appendChild(host);
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(host);
selection?.removeAllRanges();
selection?.addRange(range);
copied = document.execCommand("copy");
selection?.removeAllRanges();
host.remove();
if (!copied) {
await navigator.clipboard.writeText(plain);
}
}
const label = button.querySelector(".betterseqta-rubric-copy-label");
const original = label?.textContent ?? "Copy rubric";
button.classList.add("is-copied");
if (label) label.textContent = "Copied!";
window.setTimeout(() => {
button.classList.remove("is-copied");
if (label) label.textContent = original;
}, 1800);
}
function createCopyButton(rubric: Element): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "betterseqta-rubric-copy-btn";
button.setAttribute("aria-label", "Copy rubric");
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
<span class="betterseqta-rubric-copy-label">Copy rubric</span>
`;
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
void copyRubricTable(rubric, button);
});
return button;
}
function enhanceRubric(rubric: HTMLElement) {
if (rubric.hasAttribute(ENHANCED_ATTR)) return;
const host = document.createElement("div");
host.className = "betterseqta-rubric-copy-host";
rubric.parentElement?.insertBefore(host, rubric);
host.appendChild(rubric);
const overlay = document.createElement("div");
overlay.className = "betterseqta-rubric-copy-overlay";
overlay.appendChild(createCopyButton(rubric));
host.appendChild(overlay);
rubric.setAttribute(ENHANCED_ATTR, "true");
}
function enhanceRubrics(root: ParentNode = document) {
ensureStyles();
root.querySelectorAll<HTMLElement>(RUBRIC_SELECTOR).forEach(enhanceRubric);
}
function watchRubrics(root: ParentNode) {
observer?.disconnect();
enhanceRubrics(root);
observer = new MutationObserver(() => {
enhanceRubrics(root);
});
observer.observe(root, { childList: true, subtree: true });
}
export function injectRubricCopyButtons() {
const root =
document.querySelector("[class*='SelectedAssessment__']") ?? document;
watchRubrics(root);
}
export function teardownRubricCopyButtons() {
observer?.disconnect();
observer = null;
}
+116 -467
View File
@@ -1,11 +1,5 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
import {
getEngageAssessmentReportUrl,
requestEngageAssessmentPdf,
} from "./engage.ts";
import {
ensurePdfjsWorker,
getPdfjsPageContextUrls,
@@ -14,144 +8,6 @@ import * as pdfjs from "pdfjs-dist";
ensurePdfjsWorker();
export const WEIGHTING_SCHEMA_VERSION = 1;
export interface WeightingEntry {
weight: string;
fingerprint: string;
pluginVersion: number;
refreshing?: boolean;
}
export type WeightingsMap = Record<string, WeightingEntry>;
/** Primary storage key for weightings / overrides. */
export function assessmentIdKey(mark: { id: string | number }): string {
return String(mark.id);
}
/** Composite lookup key when the same title appears in multiple metaclasses. */
export function assessmentTitleLookupKey(mark: {
metaclassID?: string | number;
title?: string;
}): string | null {
const title = mark.title?.trim();
if (!title) return null;
const metaclassID = mark.metaclassID;
if (metaclassID != null && metaclassID !== "") {
return `${metaclassID}:${title}`;
}
return title;
}
function registerAssessmentLookup(api: any, mark: any) {
const assessmentID = assessmentIdKey(mark);
const next: Record<string, string> = {
...api.storage.assessments,
[assessmentID]: assessmentID,
};
const compositeKey = assessmentTitleLookupKey(mark);
if (compositeKey) next[compositeKey] = assessmentID;
api.storage.assessments = next;
}
type MarkLike = {
id: string | number;
title?: string;
metaclassID?: string | number;
};
function collectMarksFromFiberState(state: Record<string, unknown>): MarkLike[] {
return [
...(Array.isArray(state.marks) ? state.marks : []),
...(Array.isArray(state.upcoming) ? state.upcoming : []),
...(Array.isArray(state.pending) ? state.pending : []),
] as MarkLike[];
}
async function resolveAssessmentId(
api: any,
title: string,
marks?: MarkLike[],
): Promise<string | undefined> {
const assessments = (api.storage.assessments ?? {}) as Record<string, string>;
let resolvedMarks = marks;
if (!resolvedMarks) {
try {
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
resolvedMarks = collectMarksFromFiberState(state);
} catch {
resolvedMarks = [];
}
}
const matching = resolvedMarks.filter((mark) => mark.title?.trim() === title);
if (matching.length === 1) {
return assessmentIdKey(matching[0]);
}
for (const mark of matching) {
const compositeKey = assessmentTitleLookupKey(mark);
if (compositeKey && assessments[compositeKey]) {
return assessments[compositeKey];
}
}
if (assessments[title]) return assessments[title];
const suffix = `:${title}`;
for (const [key, id] of Object.entries(assessments)) {
if (key.endsWith(suffix)) return id;
}
return undefined;
}
export function computeFingerprint(mark: any): string {
const score =
mark?.results?.percentage ?? mark?.results?.score ?? null;
return JSON.stringify([
mark?.status ?? "",
Boolean(mark?.graded),
mark?.availability ?? "",
score,
mark?.due ?? "",
mark?.title ?? "",
]);
}
function migrateWeightings(api: any) {
const w = api.storage.weightings ?? {};
let dirty = false;
const out: WeightingsMap = {};
for (const [id, v] of Object.entries(w)) {
if (typeof v === "string") {
out[id] = { weight: v, fingerprint: "", pluginVersion: 0 };
dirty = true;
} else if (v && typeof v === "object") {
const entry = v as Partial<WeightingEntry>;
if (
typeof entry.weight === "string" &&
typeof entry.fingerprint === "string" &&
typeof entry.pluginVersion === "number"
) {
out[id] = entry as WeightingEntry;
} else {
out[id] = {
weight: String(entry.weight ?? "N/A"),
fingerprint: "",
pluginVersion: 0,
};
dirty = true;
}
}
}
if (dirty) api.storage.weightings = out;
}
export async function initStorage(api: any) {
await api.storage.loaded;
@@ -164,34 +20,19 @@ export async function initStorage(api: any) {
if (!api.storage.weightingOverrides) {
api.storage.weightingOverrides = {};
}
migrateWeightings(api);
}
export function clearStuck(api: any) {
const map = (api.storage.weightings ?? {}) as WeightingsMap;
let dirty = false;
const out: WeightingsMap = {};
for (const [key, entry] of Object.entries(map)) {
if (!entry || typeof entry !== "object") {
dirty = true;
continue;
let hasStuckProcessing = false;
for (const key in api.storage.weightings) {
if (api.storage.weightings[key] === "processing") {
delete api.storage.weightings[key];
hasStuckProcessing = true;
}
if (entry.weight === "processing") {
// Stuck mid-fetch from a previous session: drop it so the next
// page load can re-run handleWeightings from scratch.
dirty = true;
continue;
}
if (entry.refreshing) {
const { refreshing: _ignored, ...rest } = entry;
out[key] = rest;
dirty = true;
continue;
}
out[key] = entry;
}
if (dirty) api.storage.weightings = out;
if (hasStuckProcessing) {
api.storage.weightings = { ...api.storage.weightings };
}
}
// Helper function to find actual class names by their base pattern
@@ -237,119 +78,9 @@ function parseGrade(text: string): number {
return letterToNumber[str] ?? 0;
}
function formatWeightDisplay(weighting: string): string {
return `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`;
}
function saveWeightingOverride(
api: any,
assessmentID: string,
raw: string,
): { ok: boolean; error?: string } {
const trimmed = raw.trim();
if (trimmed === "") {
const { [assessmentID]: _, ...rest } = api.storage.weightingOverrides;
api.storage.weightingOverrides = rest;
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
return { ok: true };
}
const val = parseFloat(trimmed);
if (isNaN(val) || val < 0) {
return { ok: false, error: "Invalid. Must be 0 or greater" };
}
api.storage.weightingOverrides = {
...api.storage.weightingOverrides,
[assessmentID]: String(val),
};
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
return { ok: true };
}
function attachWeightInputListeners(
input: HTMLInputElement,
api: any,
assessmentID: string,
) {
const save = () => {
const result = saveWeightingOverride(api, assessmentID, input.value);
input.style.borderColor = result.ok
? "rgba(128,128,128,0.35)"
: "rgba(255,80,80,0.6)";
};
input.addEventListener("blur", save);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") input.blur();
});
}
function updateWeightLabelContent(
weightLabel: HTMLElement,
weighting: string | undefined,
assessmentID: string | undefined,
api: any,
refreshing = false,
) {
const existingInput = weightLabel.querySelector(
".betterseqta-weight-input",
) as HTMLInputElement | null;
if (existingInput && document.activeElement === existingInput) return;
weightLabel.querySelector(".betterseqta-weight-value")?.remove();
weightLabel.querySelector(".betterseqta-weight-input")?.remove();
Array.from(weightLabel.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim())
.forEach((node) => node.remove());
weightLabel.title = "";
if (weighting === "processing") {
const span = document.createElement("span");
span.className = "betterseqta-weight-value";
span.textContent = "...";
span.style.opacity = "0.5";
weightLabel.appendChild(span);
return;
}
if (weighting === "N/A" && assessmentID) {
const input = document.createElement("input");
input.type = "number";
input.min = "0";
input.step = "5";
input.className = "betterseqta-weight-input";
input.placeholder = "Set %";
input.setAttribute("aria-label", "Assessment weighting percentage");
input.style.cssText =
"width:52px;padding:1px 4px;border-radius:4px;border:1px solid rgba(128,128,128,0.35);background:rgba(128,128,128,0.08);color:inherit;font-size:inherit;outline:none;";
attachWeightInputListeners(input, api, assessmentID);
weightLabel.appendChild(input);
weightLabel.title = "Enter assessment weighting %";
return;
}
const span = document.createElement("span");
span.className = "betterseqta-weight-value";
const baseText =
weighting && weighting !== "N/A"
? formatWeightDisplay(weighting)
: "N/A";
span.textContent = refreshing ? `${baseText}` : baseText;
if (refreshing) {
span.style.opacity = "0.7";
weightLabel.title = "Re-checking weighting…";
}
weightLabel.appendChild(span);
}
function createWeightLabel(
assessmentItem: Element,
weighting: string | undefined,
api: any,
refreshing = false,
assessmentID?: string,
) {
let statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
@@ -375,21 +106,20 @@ function createWeightLabel(
? "space-between"
: "flex-end";
const resolvedAssessmentId =
assessmentID ?? assessmentItem.dataset.betterseqtaAssessmentId;
const displayText =
weighting && weighting !== "processing" && weighting !== "N/A"
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
: "N/A";
const existingLabel = statsContainer.querySelector(
".betterseqta-weight-label",
) as HTMLElement | null;
if (existingLabel) {
updateWeightLabelContent(
existingLabel,
weighting,
resolvedAssessmentId,
api,
refreshing,
const textNodes = Array.from(existingLabel.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE,
);
if (textNodes.length) textNodes[0].textContent = displayText;
return;
}
@@ -421,13 +151,15 @@ function createWeightLabel(
const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`);
if (innerTextDiv) innerTextDiv.textContent = "Weight";
updateWeightLabelContent(
weightLabel,
weighting,
resolvedAssessmentId,
api,
refreshing,
const textNodes = Array.from(weightLabel.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE,
);
if (textNodes.length) {
textNodes[0].textContent = displayText;
} else {
weightLabel.appendChild(document.createTextNode(displayText));
}
statsContainer.appendChild(weightLabel);
}
@@ -436,24 +168,14 @@ export const isFirefox =
!navigator.userAgent.toLowerCase().includes("seamonkey") &&
!navigator.userAgent.toLowerCase().includes("waterfox");
function trustedPageOrigin(): string {
return window.location.origin;
}
function escJsSingleQuoted(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
const isBlobUrl = url.startsWith("blob:");
const pageOrigin = trustedPageOrigin();
if (isBlobUrl || isFirefox) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`;
const escapedUrl = escJsSingleQuoted(url);
const escapedOrigin = escJsSingleQuoted(pageOrigin);
const escapedUrl = url.replace(/'/g, "\\'");
script.textContent = `
(function() {
@@ -469,20 +191,19 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
type: '${requestId}',
success: true,
data: Array.from(new Uint8Array(arrayBuffer))
}, '${escapedOrigin}');
}, '*');
})
.catch(error => {
window.postMessage({
type: '${requestId}',
success: false,
error: error.message || String(error)
}, '${escapedOrigin}');
}, '*');
});
})();
`;
const messageHandler = (event: MessageEvent) => {
if (event.origin !== pageOrigin || event.source !== window) return;
if (event.data?.type === requestId) {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
@@ -544,22 +265,23 @@ export async function extractPDFText(url: string): Promise<string> {
if (isFirefox) {
const { lib: pdfLibUrl, worker: pdfWorkerUrl } =
getPdfjsPageContextUrls();
const escJsSingleQuoted = (s: string) =>
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
const pageOrigin = trustedPageOrigin();
const escapedOrigin = escJsSingleQuoted(pageOrigin);
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
const escapedUrl = escJsSingleQuoted(url);
const escapedUrl = url
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/"/g, '\\"');
script.textContent = `
(function() {
const requestId = '${requestId}';
const pageOrigin = '${escapedOrigin}';
const url = '${escapedUrl}';
const pdfLibSrc = '${pdfLibInj}';
const pdfWorkerSrc = '${pdfWorkerInj}';
@@ -579,7 +301,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId,
success: false,
error: 'Failed to load pdfjs library'
}, pageOrigin);
}, '*');
};
document.head.appendChild(pdfjsScript);
@@ -600,7 +322,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId,
success: false,
error: 'HTTP ' + xhr.status + ': ' + xhr.statusText
}, pageOrigin);
}, '*');
return;
}
@@ -636,21 +358,21 @@ export async function extractPDFText(url: string): Promise<string> {
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);
}, '*');
}
};
@@ -659,7 +381,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId,
success: false,
error: 'Network error fetching PDF'
}, pageOrigin);
}, '*');
};
xhr.ontimeout = function() {
@@ -667,7 +389,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId,
success: false,
error: 'Timeout fetching PDF'
}, pageOrigin);
}, '*');
};
xhr.timeout = 30000;
@@ -677,14 +399,13 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId,
success: false,
error: 'Setup error: ' + (error.message || String(error))
}, pageOrigin);
}, '*');
}
}
})();
`;
const messageHandler = (event: MessageEvent) => {
if (event.origin !== pageOrigin || event.source !== window) return;
if (event.data?.type === requestId) {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
@@ -741,102 +462,59 @@ export async function extractPDFText(url: string): Promise<string> {
}
async function handleWeightings(mark: any, api: any) {
const assessmentID = assessmentIdKey(mark);
const assessmentID = mark.id;
const metaclassID = mark.metaclassID;
const userInfo = await getUserInfo();
const userID = userInfo.id;
const title = mark.title;
const fingerprint = computeFingerprint(mark);
const existing = api.storage.weightings[assessmentID] as
| WeightingEntry
| undefined;
const isFresh =
existing &&
existing.weight !== "processing" &&
existing.fingerprint === fingerprint &&
existing.pluginVersion === WEIGHTING_SCHEMA_VERSION;
if (isFresh) 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
// new fingerprint + version on the placeholder so a second parseAssessments
// 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,
fingerprint,
pluginVersion: WEIGHTING_SCHEMA_VERSION,
refreshing: true,
}
: {
weight: "processing",
fingerprint,
pluginVersion: WEIGHTING_SCHEMA_VERSION,
};
if (
api.storage.weightings[assessmentID] != undefined &&
api.storage.weightings[assessmentID] !== "processing"
) {
return;
}
api.storage.weightings = {
...api.storage.weightings,
[assessmentID]: placeholder,
[assessmentID]: "processing",
};
registerAssessmentLookup(api, mark);
// Surface the refreshing indicator on the affected row immediately,
// without waiting for the PDF fetch to finish.
document.dispatchEvent(new CustomEvent("betterseqta:weightingsChanged"));
api.storage.assessments = {
...api.storage.assessments,
[title.trim()]: assessmentID,
};
try {
let pdfUrl: string;
const filename =
"BetterSEQTA-" +
String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
if (isSeqtaEngageExperience()) {
const studentID = getEngageAssessmentStudentId();
if (!studentID) {
throw new Error("Could not resolve Engage student ID from URL or storage");
}
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,
}),
},
);
const reportFile = await requestEngageAssessmentPdf({
assessmentID,
metaclassID,
studentID,
});
await new Promise((resolve) => setTimeout(resolve, 1000));
pdfUrl = getEngageAssessmentReportUrl(reportFile);
} else {
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}`,
);
if (!printResponse.ok) {
throw new Error(
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
);
}
await new Promise((resolve) => setTimeout(resolve, 1000));
pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
if (pdfUrl.startsWith("blob:")) {
throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`);
}
@@ -862,24 +540,14 @@ async function handleWeightings(mark: any, api: any) {
api.storage.weightings = {
...api.storage.weightings,
[assessmentID]: {
weight: match ? match[1] : "N/A",
fingerprint,
pluginVersion: WEIGHTING_SCHEMA_VERSION,
},
[assessmentID]: match ? match[1] : "N/A",
};
} catch (error: any) {
api.storage.weightings = {
...api.storage.weightings,
[assessmentID]: {
weight: "N/A",
fingerprint,
pluginVersion: WEIGHTING_SCHEMA_VERSION,
},
[assessmentID]: "N/A",
};
}
document.dispatchEvent(new CustomEvent("betterseqta:weightingsChanged"));
}
export async function parseAssessments(api: any) {
@@ -901,19 +569,8 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
let weightedTotal = 0;
let totalWeight = 0;
let hasInaccurateWeighting = false;
let hasRefreshingWeighting = false;
let count = 0;
let fiberMarks: MarkLike[] = [];
try {
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
fiberMarks = collectMarksFromFiberState(state);
} catch {
fiberMarks = [];
}
for (const assessmentItem of assessmentItems) {
const titleEl = assessmentItem.querySelector(
`[class*='AssessmentItem__title___']`,
@@ -923,22 +580,16 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
const title = titleEl.textContent?.trim();
if (!title) continue;
const assessmentID = await resolveAssessmentId(api, title, fiberMarks);
if (assessmentID) {
(assessmentItem as HTMLElement).dataset.betterseqtaAssessmentId =
assessmentID;
}
const entry = assessmentID
? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
const assessmentID = api.storage.assessments?.[title];
const autoWeighting = assessmentID
? api.storage.weightings?.[assessmentID]
: undefined;
const autoWeighting = entry?.weight;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
const weighting = override ?? autoWeighting;
const refreshing = !override && Boolean(entry?.refreshing);
createWeightLabel(assessmentItem, weighting, api, refreshing, assessmentID);
createWeightLabel(assessmentItem, weighting);
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
@@ -961,7 +612,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
if (!isNaN(weight) && weight >= 0) {
weightedTotal += grade * weight;
totalWeight += weight;
if (refreshing) hasRefreshingWeighting = true;
} else {
weightedTotal += grade;
totalWeight += 1;
@@ -975,7 +625,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
weightedTotal,
totalWeight,
hasInaccurateWeighting,
hasRefreshingWeighting,
count,
};
}
@@ -1040,22 +689,16 @@ function resolveTabSetClasses(): Record<string, string> {
return resolved;
}
async function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
const selectedItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
) as HTMLElement | null;
const titleEl = selectedItem?.querySelector(
"[class*='AssessmentItem__title___']",
function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
const titleEl = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']",
);
const title = titleEl?.textContent?.trim();
const assessmentID =
selectedItem?.dataset.betterseqtaAssessmentId ??
(title ? await resolveAssessmentId(api, title) : undefined);
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
const entry = assessmentID
? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
const rawWeight = assessmentID
? api.storage.weightings?.[assessmentID]
: undefined;
const rawWeight = entry?.weight;
const weightingUnavailable = rawWeight === "N/A";
@@ -1093,13 +736,13 @@ async function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
<span style="font-size:13px;opacity:${autoWeight != null ? "1" : "0.4"}">${autoWeight != null ? `${autoWeight}%` : "none"}</span>
</div>
<div style="display:flex;align-items:center;gap:12px">
<label for="betterseqta-weight-override" style="font-size:13px;opacity:0.7;flex-shrink:0">${weightingUnavailable ? "Weight %" : "Override %"}</label>
<label for="betterseqta-weight-override" style="font-size:13px;opacity:0.7;flex-shrink:0">Override %</label>
<input
id="betterseqta-weight-override"
type="number"
min="0"
step="5"
placeholder="${weightingUnavailable ? "Enter weight" : autoWeight ?? ""}"
placeholder="${autoWeight ?? ""}"
value="${override ?? ""}"
${!assessmentID ? "disabled" : ""}
style="
@@ -1133,22 +776,26 @@ async function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
const save = () => {
const raw = input.value.trim();
if (raw === "") {
const result = saveWeightingOverride(api, assessmentID, "");
if (!result.ok) return;
input.style.borderColor = "rgba(128,128,128,0.3)";
const { [assessmentID]: _, ...rest } = api.storage.weightingOverrides;
api.storage.weightingOverrides = rest;
} else {
const result = saveWeightingOverride(api, assessmentID, raw);
if (!result.ok) {
const val = parseFloat(raw);
if (isNaN(val) || val < 0) {
input.style.borderColor = "rgba(255,80,80,0.6)";
statusEl.textContent = result.error ?? "Invalid. Must be 0 or greater";
statusEl.textContent = "Invalid. Must be 0 or greater";
statusEl.style.color = "rgba(255,80,80,0.8)";
return;
}
input.style.borderColor = "rgba(128,128,128,0.3)";
api.storage.weightingOverrides = {
...api.storage.weightingOverrides,
[assessmentID]: String(val),
};
}
statusEl.textContent = "Saved";
statusEl.style.color = "";
setTimeout(() => (statusEl.textContent = ""), 2000);
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
};
input.addEventListener("blur", save);
@@ -1202,8 +849,12 @@ export function injectWeightingsTab(api: any) {
].join(" ");
container.appendChild(newSheet);
let populated = false;
newTab.addEventListener("click", () => {
void buildWeightingsTabContent(api, newSheet);
if (!populated) {
buildWeightingsTabContent(api, newSheet);
populated = true;
}
});
const allTabs = Array.from(tabList.querySelectorAll("li"));
@@ -1217,22 +868,20 @@ export function injectWeightingsTab(api: any) {
t.className.includes("TabSet__selected___"),
);
if (i === currentIndex) return;
const goingRight = currentIndex < 0 ? true : i > currentIndex;
const goingRight = i > currentIndex;
allTabs.forEach((t) => {
t.className = "";
t.setAttribute("aria-selected", "false");
});
if (currentIndex >= 0) {
allSheets[currentIndex].className = [
cls["TabSet__tabsheet___"],
cls["TabSet__hidden___"],
goingRight
? cls["TabSet__disappearToLeft___"]
: cls["TabSet__disappearToRight___"],
].join(" ");
}
allSheets[currentIndex].className = [
cls["TabSet__tabsheet___"],
cls["TabSet__hidden___"],
goingRight
? cls["TabSet__disappearToLeft___"]
: cls["TabSet__disappearToRight___"],
].join(" ");
allSheets[i].className = [
cls["TabSet__tabsheet___"],
@@ -1,21 +1,12 @@
<script lang="ts">
import { determineStatus, formatDate, getGradeValue } from "./utils";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { buildEngageAssessmentPagePath } from "@/seqta/utils/engageAssessmentStudent";
import OverviewIcon from "./OverviewIcon.svelte";
import {
GROUP_SORT_ICONS,
STATUS_COLUMN_ICONS,
type OverviewIconName,
} from "./icons";
import confetti from "canvas-confetti";
export let data: any;
interface FilterOptions {
subject: string;
student: string;
sortBy: "due" | "grade" | "subject" | "title" | "year";
}
@@ -47,21 +38,12 @@
let currentFilters: FilterOptions = {
subject: "all",
student: "all",
sortBy: "due",
};
const isEngage = isSeqtaEngageExperience();
$: showStudentFilter = isEngage && (data?.students?.length ?? 0) > 1;
let filteredAssessments: any[] = [];
let statusGroups: Record<string, any[]> = {};
let columns: {
key: string;
title: string;
className: string;
icon: OverviewIconName;
}[] = [];
let columns: { key: string; title: string; className: string; icon: string }[] = [];
function getAssessmentYear(a: any): number {
const dateStr = a.due || a.date || a.dueDate || a.created;
@@ -100,23 +82,14 @@
return new Date(a.due || a.date || 0).getTime() - new Date(b.due || b.date || 0).getTime();
}
const STATUS_COLUMNS: {
key: string;
title: string;
className: string;
icon: OverviewIconName;
}[] = [
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "calendar-days" },
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "clock" },
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "exclamation-triangle" },
{ key: "SUBMITTED", title: "Submitted", className: "column-submitted", icon: "document-check" },
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "check-circle" },
const STATUS_COLUMNS = [
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "📅" },
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "⏰" },
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "🚨" },
{ key: "SUBMITTED", title: "Submitted", className: "column-submitted", icon: "📝" },
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "✅" },
];
function groupSortIcon(): OverviewIconName {
return GROUP_SORT_ICONS[currentFilters.sortBy] ?? "queue-list";
}
function buildGroupsAndColumns() {
if (!data?.assessments) return { filteredAssessments: [], statusGroups: {}, columns: [] };
const subjectFilters = settingsState.subjectfilters || {};
@@ -127,17 +100,7 @@
const filtered = data.assessments.filter((a: any) => {
if (hiddenAssessmentIds.has(String(a.id))) return false;
if (subjectFilters[a.code] === false) return false;
if (currentFilters.subject !== "all" && a.code !== currentFilters.subject) {
return false;
}
if (
isEngage &&
currentFilters.student !== "all" &&
String(a.studentId) !== currentFilters.student
) {
return false;
}
return true;
return currentFilters.subject === "all" || a.code === currentFilters.subject;
});
const groups: Record<string, any[]> = {};
@@ -151,19 +114,18 @@
groups[key].sort(sortCompare);
});
let cols: { key: string; title: string; className: string; icon: OverviewIconName }[];
let cols: { key: string; title: string; className: string; icon: string }[];
if (currentFilters.sortBy === "due") {
cols = STATUS_COLUMNS;
} else {
const keys = Object.keys(groups).filter((k) => groups[k]?.length > 0);
const sortIcon = groupSortIcon();
if (currentFilters.sortBy === "year") {
cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: sortIcon }));
cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: "📆" }));
} else if (currentFilters.sortBy === "subject") {
const subjectTitles = new Map(data?.subjects?.map((s: any) => [s.code, `${s.code} - ${s.title}`]) || []);
cols = keys.sort().map((k) => ({ key: k, title: subjectTitles.get(k) || k, className: "column-custom", icon: sortIcon }));
cols = keys.sort().map((k) => ({ key: k, title: subjectTitles.get(k) || k, className: "column-custom", icon: "📚" }));
} else {
cols = keys.sort().map((k) => ({ key: k, title: k, className: "column-custom", icon: sortIcon }));
cols = keys.sort().map((k) => ({ key: k, title: k, className: "column-custom", icon: "📋" }));
}
}
@@ -347,19 +309,6 @@
if ((event.target as HTMLElement).closest(".card-menu")) {
return;
}
if (isSeqtaEngageExperience()) {
const studentId = assessment.studentId ?? data?.studentId;
if (!studentId) return;
window.location.hash = buildEngageAssessmentPagePath(
studentId,
assessment.programmeID,
assessment.metaclassID,
assessment.id,
);
return;
}
window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`;
}
@@ -393,28 +342,16 @@
updateAssessments();
void currentFilters.sortBy;
void currentFilters.subject;
void currentFilters.student;
}
</script>
<svelte:window on:click={closeAllMenus} />
<div class="bsplus-overview-page">
<header class="grid-view-header bsplus-overview-animate">
<div class="grid-view-header-text">
<h1 class="grid-view-title">Assessments</h1>
<p class="grid-view-subtitle">Track upcoming tasks, submissions, and released marks</p>
</div>
<div class="grid-view-filters bsplus-overview-toolbar">
{#if showStudentFilter}
<select class="filter-select" bind:value={currentFilters.student}>
<option value="all">All Students</option>
{#each data.students as student}
<option value={String(student.id)}>{student.name}</option>
{/each}
</select>
{/if}
<div id="grid-view-container">
<div class="grid-view-header">
<h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters">
<select class="filter-select" bind:value={currentFilters.subject}>
<option value="all">All Subjects</option>
{#each data.subjects as subject}
@@ -435,15 +372,14 @@
on:click={() => (showVisibilityPanel = !showVisibilityPanel)}
title="Manage hidden subjects and assessments"
>
<OverviewIcon name="eye" size={18} />
<span>Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})</span>
👁 Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})
</button>
{/if}
</div>
</header>
</div>
{#if showVisibilityPanel && hasHiddenItems}
<div class="visibility-panel bsplus-overview-animate">
<div class="visibility-panel">
<h4 class="visibility-panel-title">Hidden items</h4>
{#if hiddenSubjects.length > 0}
<div class="visibility-section">
@@ -474,10 +410,10 @@
</div>
{/if}
<div id="main-grid-content" class="bsplus-overview-animate bsplus-overview-delay-1">
<div id="main-grid-content">
{#if filteredAssessments.length === 0}
<div class="empty-state">
<OverviewIcon name="clipboard-document-list" size={40} class="empty-icon" />
<div class="empty-icon">📋</div>
<p>No assessments found matching your filters</p>
</div>
{:else}
@@ -488,15 +424,9 @@
<div class="kanban-column {column.className}">
<div class="column-header">
<div class="column-title">
<span class="column-title-main">
<OverviewIcon
name={column.icon ?? STATUS_COLUMN_ICONS[column.key] ?? "queue-list"}
size={18}
/>
{column.title}
</span>
<span class="column-count">{statusGroups[column.key].length}</span>
</div>
{column.icon} {column.title}
<span class="column-count">{statusGroups[column.key].length}</span>
</div>
</div>
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
{#each statusGroups[column.key] as assessment}
@@ -515,9 +445,6 @@
on:keydown={(e) => e.key === 'Enter' && handleCardClick(assessment, e)}
>
<div class="card-labels">
{#if isEngage && assessment.studentName}
<span class="card-label label-student">{assessment.studentName}</span>
{/if}
<span class="card-label label-subject">{assessment.code}</span>
{#if assessment.submitted}
<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>
@@ -535,7 +462,11 @@
on:click={(e) => toggleMenu(assessment.id, e)}
aria-label="Open menu"
>
<OverviewIcon name="ellipsis-vertical" size={16} />
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/>
</svg>
</button>
<div class="menu-dropdown" style="display: {openMenuId === assessment.id ? 'block' : 'none'};">
{#if status !== "MARKS_RELEASED"}
@@ -562,8 +493,7 @@
{#if !assessment.results && !isCompleted}
<div class="assessment-meta">
<div class="due-date {dueDateClass}">
<OverviewIcon name="calendar-days" size={14} />
{formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)}
📅 {formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)}
</div>
</div>
{/if}
@@ -1,11 +1,8 @@
<script lang="ts">
import OverviewIcon from "./OverviewIcon.svelte";
export let error: string;
</script>
<div class="error-container bsplus-overview-animate">
<OverviewIcon name="exclamation-circle" size={40} class="error-icon" />
<div class="error-container">
<p class="error-text">Failed to load assessments</p>
<p class="error-detail">{error}</p>
</div>
<p style="color: #94a3b8; font-size: 0.875rem;">{error}</p>
</div>
@@ -1,32 +0,0 @@
<script lang="ts">
import { OVERVIEW_ICON_PATHS, type OverviewIconName } from "./icons";
interface Props {
name: OverviewIconName;
class?: string;
size?: number;
}
let { name, class: className = "", size = 20 }: Props = $props();
const paths = $derived.by(() => {
const raw = OVERVIEW_ICON_PATHS[name];
return Array.isArray(raw) ? raw : [raw];
});
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
width={size}
height={size}
class="bsplus-overview-icon {className}"
aria-hidden="true"
>
{#each paths as path, index (index)}
<path stroke-linecap="round" stroke-linejoin="round" d={path} />
{/each}
</svg>
@@ -1,28 +1,7 @@
<script lang="ts">
import OverviewIcon from "./OverviewIcon.svelte";
import type { OverviewIconName } from "./icons";
const columns: {
key: string;
title: string;
className: string;
icon: OverviewIconName;
skeletonCount: number;
}[] = [
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "calendar-days", skeletonCount: 3 },
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "clock", skeletonCount: 2 },
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "exclamation-triangle", skeletonCount: 1 },
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "check-circle", skeletonCount: 4 },
];
</script>
<div class="bsplus-overview-page">
<header class="grid-view-header bsplus-overview-animate">
<div class="grid-view-header-text">
<h1 class="grid-view-title">Assessments</h1>
<p class="grid-view-subtitle">Loading your assessment overview…</p>
</div>
<div class="grid-view-filters bsplus-overview-toolbar">
<div id="grid-view-container">
<div class="grid-view-header">
<h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters">
<select class="filter-select" disabled>
<option value="all">Loading subjects...</option>
</select>
@@ -30,20 +9,17 @@
<option value="due">Sort by Due Date</option>
</select>
</div>
</header>
</div>
<div id="main-grid-content" class="bsplus-overview-animate bsplus-overview-delay-1">
<div id="main-grid-content">
<div class="kanban-board">
{#each columns as column}
<div class="kanban-column-parent">
<div class="kanban-column {column.className}">
<div class="column-header">
<div class="column-title">
<span class="column-title-main">
<OverviewIcon name={column.icon} size={18} />
{column.title}
</span>
<span class="column-count"></span>
{column.icon} {column.title}
<span class="column-count">...</span>
</div>
</div>
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
@@ -67,3 +43,36 @@
</div>
</div>
</div>
<script lang="ts">
const columns = [
{
key: "UPCOMING",
title: "Upcoming",
className: "column-upcoming",
icon: "📅",
skeletonCount: 3,
},
{
key: "DUE_SOON",
title: "Due Soon",
className: "column-due-soon",
icon: "⏰",
skeletonCount: 2,
},
{
key: "OVERDUE",
title: "Overdue",
className: "column-overdue",
icon: "🚨",
skeletonCount: 1,
},
{
key: "MARKS_RELEASED",
title: "Marked",
className: "column-marked",
icon: "✅",
skeletonCount: 4,
},
];
</script>
+29 -65
View File
@@ -1,26 +1,20 @@
import {
activeSubjectsFromLearnPayload,
assessmentBelongsToActiveSubjects,
filterAssessmentsForActiveSubjects,
type OverviewSubject,
} from "./utils";
interface Subject {
code: string;
programme: number;
metaclass: number;
title: string;
}
interface PrefItem {
name: string;
value: string;
}
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { getMockAssessmentsData } from "@/seqta/ui/dev/hideSensitiveContent";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import {
getEngageAssessmentsData,
} from "./engageApi";
let cache: { time: number; engageAll?: boolean; studentId: number; data: any } | null =
null;
let cache: { time: number; data: any } | null = null;
const CACHE_MS = 10 * 60 * 1000;
const student = 69;
async function fetchJSON(url: string, body: any) {
const res = await fetch(`${location.origin}${url}`, {
@@ -29,15 +23,14 @@ async function fetchJSON(url: string, body: any) {
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
return res.json();
}
async function loadSubjects(): Promise<OverviewSubject[]> {
async function loadSubjects() {
const res = await fetchJSON("/seqta/student/load/subjects?", {});
return activeSubjectsFromLearnPayload(res.payload);
return res.payload
.filter((s: any) => s.active === 1)
.flatMap((s: any) => s.subjects);
}
async function loadPrefs(student: number) {
@@ -63,8 +56,9 @@ async function loadUpcoming(student: number) {
return res.payload;
}
function normalizeAssessmentDates(t: any, subject: OverviewSubject): any {
function normalizeAssessmentDates(t: any, subject: Subject): any {
const normalized = { ...t };
// Past API may use different date fields - ensure we have 'due' for year filter & display
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
}
@@ -74,7 +68,7 @@ function normalizeAssessmentDates(t: any, subject: OverviewSubject): any {
return normalized;
}
async function loadPast(student: number, subjects: OverviewSubject[]) {
async function loadPast(student: number, subjects: Subject[]) {
const map: Record<number, any> = {};
await Promise.all(
subjects.map(async (s) => {
@@ -134,65 +128,35 @@ async function loadSubmissions(student: number, assessments: any[]) {
return submissionMap;
}
async function getLearnAssessmentsData(studentId: number) {
export async function getAssessmentsData() {
if (settingsState.mockNotices) {
return getMockAssessmentsData();
}
if (cache && Date.now() - cache.time < CACHE_MS) return cache.data;
const [subjects, colors, upcoming] = await Promise.all([
loadSubjects(),
loadPrefs(studentId),
loadUpcoming(studentId),
loadPrefs(student),
loadUpcoming(student),
]);
const pastMap = await loadPast(studentId, subjects);
const pastMap = await loadPast(student, subjects);
const map: Record<number, any> = {};
upcoming.forEach((a: any) => {
if (assessmentBelongsToActiveSubjects(a, subjects)) {
map[a.id] = { ...a };
}
map[a.id] = { ...a };
});
Object.values(pastMap).forEach((t: any) => {
if (!assessmentBelongsToActiveSubjects(t, subjects)) return;
if (map[t.id]) Object.assign(map[t.id], t);
else map[t.id] = t;
});
const allAssessments = filterAssessmentsForActiveSubjects(
Object.values(map),
subjects,
);
const submissions = await loadSubmissions(studentId, allAssessments);
const allAssessments = Object.values(map);
const submissions = await loadSubmissions(student, allAssessments);
allAssessments.forEach((assessment: any) => {
assessment.submitted = submissions[assessment.id] || false;
});
return { assessments: allAssessments, subjects, colors, studentId };
}
export async function getAssessmentsData() {
if (settingsState.hideSensitiveContent) {
return getMockAssessmentsData();
}
if (isSeqtaEngageExperience()) {
if (cache && Date.now() - cache.time < CACHE_MS && cache.engageAll) {
return cache.data;
}
const data = await getEngageAssessmentsData();
cache = { time: Date.now(), studentId: 0, engageAll: true, data };
return data;
}
const studentId = (await getUserInfo()).id;
if (
cache &&
Date.now() - cache.time < CACHE_MS &&
cache.studentId === studentId
) {
return cache.data;
}
const data = await getLearnAssessmentsData(studentId);
cache = { time: Date.now(), studentId, data };
const data = { assessments: allAssessments, subjects, colors };
cache = { time: Date.now(), data };
return data;
}
@@ -1,238 +0,0 @@
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
import {
activeSubjectsFromEngageChild,
assessmentBelongsToActiveSubjects,
filterAssessmentsForActiveSubjects,
type OverviewSubject,
} from "./utils";
interface PrefItem {
name: string;
value: string;
}
export interface EngageStudent {
id: number;
name: string;
}
interface EngageChildPayload {
id?: number;
name?: string;
terms?: {
active?: number;
subjects?: {
code?: string;
programme?: number;
metaclass?: number;
title?: string;
description?: string;
}[];
}[];
}
async function fetchJSON(url: string, body: unknown) {
const res = await fetch(`${location.origin}${url}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
return res.json();
}
async function loadEngageChildrenPayload(): Promise<EngageChildPayload[]> {
const res = await fetchJSON("/seqta/parent/load/subjects", {});
return Array.isArray(res.payload) ? res.payload : [];
}
export async function resolveEngageStudentId(): Promise<number> {
const fromUrlOrStorage = getEngageAssessmentStudentId();
if (fromUrlOrStorage) return Number(fromUrlOrStorage);
const children = await loadEngageChildrenPayload();
const firstChild = children[0];
if (firstChild?.id != null) return Number(firstChild.id);
throw new Error("Could not resolve Engage student ID");
}
function subjectsFromChild(child: EngageChildPayload): OverviewSubject[] {
return activeSubjectsFromEngageChild(child);
}
async function loadEngagePrefs(): Promise<Record<string, string>> {
const res = await fetchJSON("/seqta/parent/load/prefs?", {
request: "userPrefs",
asArray: true,
});
const colors: Record<string, string> = {};
(res.payload ?? []).forEach((pref: PrefItem) => {
if (pref.name.startsWith("timetable.subject.colour.")) {
const code = pref.name.replace("timetable.subject.colour.", "");
colors[code] = pref.value;
}
});
return colors;
}
async function loadEngageUpcoming(studentId: number) {
const res = await fetchJSON("/seqta/parent/assessment/list/upcoming?", {
student: studentId,
});
return res.payload ?? [];
}
function normalizeAssessmentDates(t: any, subject: OverviewSubject): any {
const normalized = { ...t };
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
}
if (!normalized.programmeID) normalized.programmeID = subject.programme;
if (!normalized.metaclassID) normalized.metaclassID = subject.metaclass;
if (!normalized.code && t.subject) normalized.code = t.subject;
return normalized;
}
async function loadEngagePast(studentId: number, subjects: OverviewSubject[]) {
const map: Record<number, any> = {};
await Promise.all(
subjects.map(async (subject) => {
const res = await fetchJSON("/seqta/parent/assessment/list/past?", {
programme: subject.programme,
metaclass: subject.metaclass,
student: studentId,
});
const processAssessment = (task: any) => {
if (task?.id) {
const merged = {
...task,
programmeID: task.programmeID || task.programme || subject.programme,
metaclassID: task.metaclassID || task.metaclass || subject.metaclass,
code: task.code || task.subject || subject.code,
};
map[task.id] = normalizeAssessmentDates(merged, subject);
}
};
if (Array.isArray(res.payload?.pending)) {
res.payload.pending.forEach(processAssessment);
}
if (Array.isArray(res.payload?.tasks)) {
res.payload.tasks.forEach(processAssessment);
}
}),
);
return map;
}
async function loadEngageSubmissions(studentId: number, assessments: any[]) {
const submissionMap: Record<number, boolean> = {};
await Promise.all(
assessments.map(async (assessment) => {
try {
const res = await fetchJSON("/seqta/parent/assessment/submissions/get", {
assessment: assessment.id,
metaclass: assessment.metaclassID,
student: studentId,
});
submissionMap[assessment.id] =
Array.isArray(res.payload) && res.payload.length > 0;
} catch (error) {
console.warn(
`[BetterSEQTA+] Failed to fetch Engage submission for assessment ${assessment.id}:`,
error,
);
submissionMap[assessment.id] = false;
}
}),
);
return submissionMap;
}
async function loadEngageAssessmentsForStudent(
child: EngageChildPayload,
): Promise<any[]> {
const studentId = Number(child.id);
const studentName = child.name ?? "Student";
const subjects = subjectsFromChild(child);
const [upcoming, pastMap] = await Promise.all([
loadEngageUpcoming(studentId),
loadEngagePast(studentId, subjects),
]);
const map: Record<number, any> = {};
upcoming.forEach((assessment: any) => {
if (assessmentBelongsToActiveSubjects(assessment, subjects)) {
map[assessment.id] = { ...assessment };
}
});
Object.values(pastMap).forEach((task: any) => {
if (!assessmentBelongsToActiveSubjects(task, subjects)) return;
if (map[task.id]) Object.assign(map[task.id], task);
else map[task.id] = task;
});
const assessments = filterAssessmentsForActiveSubjects(
Object.values(map),
subjects,
).map((assessment) => ({
...assessment,
studentId,
studentName,
}));
const submissions = await loadEngageSubmissions(studentId, assessments);
assessments.forEach((assessment) => {
assessment.submitted = submissions[assessment.id] || false;
});
return assessments;
}
export async function getEngageAssessmentsData() {
const childrenPayload = await loadEngageChildrenPayload();
const students: EngageStudent[] = childrenPayload
.filter((child) => child.id != null)
.map((child) => ({
id: Number(child.id),
name: child.name ?? "Student",
}));
if (!students.length) {
throw new Error("No Engage students found");
}
const [colors, assessmentsByChild] = await Promise.all([
loadEngagePrefs(),
Promise.all(childrenPayload.map((child) => loadEngageAssessmentsForStudent(child))),
]);
const subjectsMap = new Map<string, OverviewSubject>();
childrenPayload.forEach((child) => {
subjectsFromChild(child).forEach((subject) => {
if (!subjectsMap.has(subject.code)) {
subjectsMap.set(subject.code, subject);
}
});
});
const defaultStudentId = await resolveEngageStudentId();
return {
assessments: assessmentsByChild.flat(),
subjects: Array.from(subjectsMap.values()),
colors,
students,
studentId: defaultStudentId,
};
}
@@ -1,65 +0,0 @@
/** Heroicons v2 outline paths (https://heroicons.com) */
export type OverviewIconName =
| "calendar-days"
| "clock"
| "exclamation-triangle"
| "document-check"
| "check-circle"
| "book-open"
| "calendar"
| "chart-bar"
| "queue-list"
| "eye"
| "clipboard-document-list"
| "ellipsis-vertical"
| "exclamation-circle";
export const OVERVIEW_ICON_PATHS: Record<
OverviewIconName,
string | string[]
> = {
"calendar-days":
"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5",
clock: "M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z",
"exclamation-triangle":
"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z",
"document-check":
"M10.125 2.25h-4.5c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 8.625a2.625 2.625 0 100-5.25 2.625 2.625 0 000 5.25zm0 0l-3 3m3-3l3 3",
"check-circle":
"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
"book-open":
"M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25",
calendar:
"M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5",
"chart-bar":
"M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z",
"queue-list":
"M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z",
eye: [
"M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z",
"M15 12a3 3 0 11-6 0 3 3 0 016 0z",
],
"clipboard-document-list": [
"M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 17.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z",
"M8.25 6.75V4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V6.75H8.25z",
],
"ellipsis-vertical":
"M12 6.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 12.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 18.75a.75.75 0 110-1.5.75.75 0 010 1.5z",
"exclamation-circle":
"M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z",
};
export const STATUS_COLUMN_ICONS: Record<string, OverviewIconName> = {
UPCOMING: "calendar-days",
DUE_SOON: "clock",
OVERDUE: "exclamation-triangle",
SUBMITTED: "document-check",
MARKS_RELEASED: "check-circle",
};
export const GROUP_SORT_ICONS: Record<string, OverviewIconName> = {
year: "calendar",
subject: "book-open",
grade: "chart-bar",
title: "queue-list",
};
@@ -1,50 +1,10 @@
import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api";
import { renderErrorState, renderGrid, renderSkeletonLoader, teardownOverviewUi } from "./ui";
import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import {
isEngageAssessmentOverviewRoute,
} from "@/seqta/utils/engageAssessmentStudent";
import { resolveEngageStudentId } from "./engageApi";
const OVERVIEW_MENU_CLASS = "betterseqta-assessments-overview-item";
function ensureOverviewMenuPosition(
menu: HTMLElement,
gridItem: HTMLElement,
) {
if (menu.firstElementChild !== gridItem) {
menu.insertBefore(gridItem, menu.firstChild);
}
}
function isOverviewRoute() {
if (isSeqtaEngageExperience()) {
return isEngageAssessmentOverviewRoute();
}
return window.location.hash.includes("/assessments/overview");
}
async function waitForAssessmentsSubmenu(): Promise<HTMLElement> {
if (!isSeqtaEngageExperience()) {
return (await waitForElm(
'[data-key="assessments"] > .sub > ul',
true,
100,
60,
)) as HTMLElement;
}
return (await waitForElm(
'[data-key="assessments"] .sub ul, [data-key="assessments"] ul',
true,
100,
350,
)) as HTMLElement;
}
const assessmentsOverviewPlugin: Plugin<{}> = {
id: "assessments-overview",
@@ -57,61 +17,37 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
styles,
run: async () => {
const menu = await waitForAssessmentsSubmenu();
if (isSeqtaEngageExperience()) return;
const menu = (await waitForElm(
'[data-key="assessments"] > .sub > ul',
true,
100,
60,
)) as HTMLElement;
const gridItem = document.createElement("li");
gridItem.className = "item";
gridItem.classList.add(OVERVIEW_MENU_CLASS);
const label = document.createElement("label");
label.textContent = "Overview";
gridItem.appendChild(label);
menu.insertBefore(gridItem, menu.firstChild);
menu.insertBefore(gridItem, menu.children[1] || null);
let loadRequestId = 0;
const menuObserver = new MutationObserver(() => {
ensureOverviewMenuPosition(menu, gridItem);
});
menuObserver.observe(menu, { childList: true });
if (isOverviewRoute()) {
void loadGridView();
if (window.location.hash.includes("/assessments/overview")) {
loadGridView();
}
const clickHandler = (e: Event) => {
e.preventDefault();
void loadGridView();
loadGridView();
};
gridItem.addEventListener("click", clickHandler);
const popstateHandler = () => {
if (isOverviewRoute()) {
void loadGridView();
} else {
loadRequestId += 1;
teardownOverviewUi();
}
};
window.addEventListener("popstate", popstateHandler);
async function loadGridView() {
const requestId = ++loadRequestId;
await delay(1);
if (isSeqtaEngageExperience()) {
const studentId = await resolveEngageStudentId();
window.history.pushState(
{},
"",
`/#?page=/assessments/${studentId}/overview`,
);
document.title = "Overview ― SEQTA Engage";
} else {
window.history.pushState({}, "", "/#?page=/assessments/overview");
document.title = "Overview ― SEQTA Learn";
}
window.history.pushState({}, "", "/#?page=/assessments/overview");
document.title = "Overview ― SEQTA Learn";
const main = document.getElementById("main");
if (!main || requestId !== loadRequestId) return;
if (!main) return;
document
.querySelectorAll('[data-key="assessments"] .item')
@@ -123,24 +59,19 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
.querySelector('[data-key="assessments"]')
?.classList.add("active");
main.innerHTML =
'<div id="grid-view-container" class="bsplus-overview-host"></div>';
main.innerHTML = '<div id="grid-view-container"></div>';
const container = document.getElementById(
"grid-view-container",
) as HTMLElement;
if (requestId !== loadRequestId) return;
void renderSkeletonLoader(container);
renderSkeletonLoader(container);
try {
const data = await getAssessmentsData();
if (requestId !== loadRequestId) return;
void renderGrid(container, data);
renderGrid(container, data);
} catch (err) {
if (requestId !== loadRequestId) return;
console.error("Failed to load assessments:", err);
void renderErrorState(
renderErrorState(
container,
err instanceof Error ? err.message : "Unknown error",
);
@@ -148,11 +79,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
}
return () => {
loadRequestId += 1;
window.removeEventListener("popstate", popstateHandler);
menuObserver.disconnect();
gridItem.removeEventListener("click", clickHandler);
teardownOverviewUi();
gridItem.remove();
};
},
File diff suppressed because it is too large Load Diff
+37 -159
View File
@@ -1,167 +1,45 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import renderSvelte from "@/interface/main";
import AssessmentsOverview from "./AssessmentsOverview.svelte";
import SkeletonLoader from "./SkeletonLoader.svelte";
import ErrorState from "./ErrorState.svelte";
import { unmount } from "svelte";
let currentApp: any = null;
let themeObserver: MutationObserver | null = null;
type ThemeSettingKey =
| "selectedColor"
| "DarkMode"
| "adaptiveThemeColour"
| "adaptiveThemeGradient"
| "selectedTheme";
let themeListeners: Array<{ key: ThemeSettingKey; listener: () => void }> = [];
const THEME_CSS_VARS = [
"--better-main",
"--better-pale",
"--better-light",
"--text-color",
"--background-primary",
"--background-secondary",
"--text-primary",
"--theme-offset-bg",
"--better-sub",
] as const;
const ACCENT_CSS_VARS = [
"--better-main",
"--accent-color-value",
"--accentColor",
"--colour-betterseqta-blue",
] as const;
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;
}
function resolvePageAccentColor(): string {
const computed = getComputedStyle(document.documentElement);
for (const name of ACCENT_CSS_VARS) {
const solid = extractSolidColor(computed.getPropertyValue(name));
if (solid) return solid;
}
const fromSettings = settingsState.selectedColor?.trim();
if (fromSettings) {
const solid = extractSolidColor(fromSettings);
if (solid) return solid;
}
return "#007bff";
}
function syncOverviewTheme(target: HTMLElement) {
const computed = getComputedStyle(document.documentElement);
for (const name of THEME_CSS_VARS) {
const value = document.documentElement.style.getPropertyValue(name).trim()
|| computed.getPropertyValue(name).trim();
if (value) target.style.setProperty(name, value);
}
const accent = resolvePageAccentColor();
target.style.setProperty("--bsplus-overview-accent", accent);
target.style.setProperty("--better-main", accent);
target.classList.toggle(
"dark",
document.documentElement.classList.contains("dark"),
);
}
function watchOverviewTheme(root: HTMLElement) {
for (const { key, listener } of themeListeners) {
settingsState.unregister(key, listener);
}
themeListeners = [];
const listener = () => syncOverviewTheme(root);
for (const key of [
"selectedColor",
"DarkMode",
"adaptiveThemeColour",
"adaptiveThemeGradient",
"selectedTheme",
] satisfies ThemeSettingKey[]) {
settingsState.register(key, listener);
themeListeners.push({ key, listener });
}
themeObserver?.disconnect();
themeObserver = new MutationObserver(() => syncOverviewTheme(root));
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style", "class"],
});
}
function prepareContainer(container: HTMLElement) {
container.innerHTML = "";
container.className = "bsplus-overview-host";
container.classList.add("bsplus-overview-root");
syncOverviewTheme(container);
watchOverviewTheme(container);
}
async function mountOverviewComponent(
container: HTMLElement,
loader: () => Promise<{ default: any }>,
props: Record<string, unknown> = {},
) {
const [{ default: renderSvelte }, { default: Component }] = await Promise.all([
import("@/interface/main"),
loader(),
]);
currentApp = renderSvelte(Component, container, props);
}
export async function renderGrid(container: HTMLElement, data: any) {
if (currentApp) unmount(currentApp);
prepareContainer(container);
await mountOverviewComponent(container, () => import("./AssessmentsOverview.svelte"), {
data,
});
}
export async function renderSkeletonLoader(container: HTMLElement) {
if (currentApp) unmount(currentApp);
prepareContainer(container);
await mountOverviewComponent(container, () => import("./SkeletonLoader.svelte"));
}
export async function renderLoadingState(container: HTMLElement) {
await renderSkeletonLoader(container);
}
export async function renderErrorState(container: HTMLElement, error: string) {
if (currentApp) unmount(currentApp);
prepareContainer(container);
await mountOverviewComponent(container, () => import("./ErrorState.svelte"), {
error,
});
}
export function teardownOverviewUi() {
for (const { key, listener } of themeListeners) {
settingsState.unregister(key, listener);
}
themeListeners = [];
themeObserver?.disconnect();
themeObserver = null;
export function renderGrid(container: HTMLElement, data: any) {
if (currentApp) {
unmount(currentApp);
currentApp = null;
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(AssessmentsOverview, container, { data });
}
export function renderSkeletonLoader(container: HTMLElement) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(SkeletonLoader, container);
}
export function renderLoadingState(container: HTMLElement) {
renderSkeletonLoader(container);
}
export function renderErrorState(container: HTMLElement, error: string) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(ErrorState, container, { error });
}
@@ -1,115 +1,3 @@
export interface OverviewSubject {
code: string;
programme: number;
metaclass: number;
title: string;
}
function isActiveTermFlag(active: unknown): boolean {
return active === 1 || active === true;
}
export function normalizeOverviewSubject(raw: unknown): OverviewSubject | null {
if (!raw || typeof raw !== "object") return null;
const subject = raw as Record<string, unknown>;
const programme = Number(subject.programme ?? subject.programmeID);
const metaclass = Number(subject.metaclass ?? subject.metaclassID);
if (!programme || !metaclass || Number.isNaN(programme) || Number.isNaN(metaclass)) {
return null;
}
const code = String(subject.code ?? subject.subject ?? "").trim();
if (!code) return null;
return {
code,
programme,
metaclass,
title: String(subject.title ?? subject.description ?? code),
};
}
/** Subjects from the active programme-year folder(s) in `/seqta/student/load/subjects`. */
export function activeSubjectsFromLearnPayload(payload: unknown): OverviewSubject[] {
if (!Array.isArray(payload)) return [];
const subjects: OverviewSubject[] = [];
const seen = new Set<string>();
for (const folder of payload) {
if (!folder || typeof folder !== "object") continue;
const term = folder as { active?: unknown; subjects?: unknown[] };
if (!isActiveTermFlag(term.active) || !Array.isArray(term.subjects)) continue;
for (const raw of term.subjects) {
const subject = normalizeOverviewSubject(raw);
if (!subject) continue;
const key = `${subject.programme}-${subject.metaclass}`;
if (seen.has(key)) continue;
seen.add(key);
subjects.push(subject);
}
}
return subjects;
}
export function activeSubjectsFromEngageChild(child: {
terms?: { active?: number; subjects?: unknown[] }[];
}): OverviewSubject[] {
const subjects: OverviewSubject[] = [];
const seen = new Set<string>();
for (const term of child.terms ?? []) {
if (!isActiveTermFlag(term.active)) continue;
for (const raw of term.subjects ?? []) {
const subject = normalizeOverviewSubject(raw);
if (!subject) continue;
const key = `${subject.programme}-${subject.metaclass}`;
if (seen.has(key)) continue;
seen.add(key);
subjects.push(subject);
}
}
return subjects;
}
export function assessmentBelongsToActiveSubjects(
assessment: Record<string, unknown>,
activeSubjects: OverviewSubject[],
): boolean {
if (!activeSubjects.length) return false;
const programme = Number(
assessment.programmeID ?? assessment.programme,
);
const metaclass = Number(
assessment.metaclassID ?? assessment.metaclass,
);
if (programme && metaclass && !Number.isNaN(programme) && !Number.isNaN(metaclass)) {
return activeSubjects.some(
(subject) =>
subject.programme === programme && subject.metaclass === metaclass,
);
}
const code = String(assessment.code ?? assessment.subject ?? "").trim();
if (!code) return false;
return activeSubjects.some((subject) => subject.code === code);
}
export function filterAssessmentsForActiveSubjects<T extends Record<string, unknown>>(
assessments: T[],
activeSubjects: OverviewSubject[],
): T[] {
return assessments.filter((assessment) =>
assessmentBelongsToActiveSubjects(assessment, activeSubjects),
);
}
export function formatDate(dateStr: string, submitted?: boolean): string {
const d = new Date(dateStr);
const now = new Date();
@@ -151,14 +39,7 @@ export function determineStatus(item: any): string {
}
const completedKey = "betterseqta-completed-assessments";
let completed: unknown[] = [];
try {
const raw = localStorage.getItem(completedKey);
const parsed = raw ? JSON.parse(raw) : [];
completed = Array.isArray(parsed) ? parsed : [];
} catch {
completed = [];
}
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
if (completed.includes(item.id)) {
return "MARKS_RELEASED";
}
+3 -11
View File
@@ -74,10 +74,7 @@ function ensureGestureStart(handler: () => void): () => void {
async function startPlayback(volume: number): Promise<void> {
const blob = await loadAudioBlob();
if (!blob) {
stopAndCleanupAudio();
return;
}
if (!blob) return;
stopAndCleanupAudio();
@@ -126,7 +123,7 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
}
});
// Note: Stop button dispatches betterseqta-background-music-stop on remove
// Note: Stop button/event removed by user; no stop handling needed
// Start if we have audio and autoplay is enabled
const tryStart = async () => {
@@ -163,21 +160,16 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
};
document.addEventListener("visibilitychange", visHandler);
// Allow uploads to trigger refresh; stop event clears playback on remove
// Allow uploads to trigger refresh
const uploadedHandler = () => {
const vol = (api.settings as any).volume ?? 0.5;
startPlayback(vol);
};
const stopHandler = () => {
stopAndCleanupAudio();
};
window.addEventListener("betterseqta-background-music-updated", uploadedHandler);
window.addEventListener("betterseqta-background-music-stop", stopHandler);
return () => {
document.removeEventListener("visibilitychange", visHandler);
window.removeEventListener("betterseqta-background-music-updated", uploadedHandler);
window.removeEventListener("betterseqta-background-music-stop", stopHandler);
if (cleanupRegistered && (window as any).__betterseqta_bg_music_cancel__) {
(window as any).__betterseqta_bg_music_cancel__();
(window as any).__betterseqta_bg_music_cancel__ = undefined;
@@ -0,0 +1,246 @@
/**
* BetterSEQTA Security core XSS-focused protections for HTML rendered from SEQTA APIs.
*
* Execution vs detection: SEQTA loads message bodies in same-origin `iframe.userHTML`.
* Scripts may run during parse before our scan completes. We set `sandbox="allow-same-origin"`
* (without `allow-scripts`) on those iframes so script execution is suppressed while we can
* still read `contentDocument` for scanning and existing theme/CSS injection.
*
* The warning UI is mounted on document.body (fixed layer aligned to the reading pane) so
* React replacing `.uiFrameWrapper` / iframe siblings does not destroy it.
*/
import type { Plugin } from "../../core/types";
import {
analyzeHtmlThreats,
type ThreatAnalysis,
} from "@/seqta/security/analyzeHtmlThreats";
import {
mountBlockedContentUi,
SECURITY_MESSAGE_OVERLAY_CLASS,
} from "@/seqta/security/blockedContentUi";
import { eventManager } from "@/seqta/utils/listeners/EventManager";
const USER_HTML_IFRAME_EVENT = "bssSecurityUserHtmlIframe";
const userHtmlIframeLoadHooked = new WeakSet<HTMLIFrameElement>();
/** Tear down body overlay + listeners for this iframe (safe navigation or cleanup). */
const messageOverlayCleanups = new WeakMap<HTMLIFrameElement, () => void>();
function teardownMessageSecurityOverlay(iframe: HTMLIFrameElement): void {
const fn = messageOverlayCleanups.get(iframe);
if (fn) {
fn();
messageOverlayCleanups.delete(iframe);
}
}
function applyMessageIframeSandbox(iframe: HTMLIFrameElement): void {
if (iframe.dataset.bssUserHtmlSandbox === "1") return;
iframe.dataset.bssUserHtmlSandbox = "1";
iframe.setAttribute("sandbox", "allow-same-origin");
}
function wipeIframeDocument(iframe: HTMLIFrameElement): void {
try {
const d = iframe.contentDocument;
if (!d) return;
d.open();
d.write(
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body></body></html>",
);
d.close();
} catch {
/* ignore */
}
}
/**
* After we replace a malicious document, the iframe fires `load` again with this blank shell.
* That pass must not tear down the blocker UI or the iframe would recover for one frame.
*/
function isPostWipeBlankDocument(doc: Document): boolean {
const body = doc.body;
if (!body || body.childElementCount > 0) return false;
if ((body.textContent ?? "").trim().length > 0) return false;
const meta = doc.head?.querySelector('meta[charset="utf-8"]');
if (!meta) return false;
return doc.documentElement.outerHTML.length < 800;
}
/**
* Full-screen body layer positioned over the reading pane so SEQTA/React can replace iframe
* markup without removing this node.
*/
function mountBodyAnchoredMessageOverlay(
iframe: HTMLIFrameElement,
anchor: HTMLElement,
opts: {
analysis: ThreatAnalysis;
rawSnippet: string;
contextTitle?: string;
},
): void {
teardownMessageSecurityOverlay(iframe);
const shell = document.createElement("div");
shell.className = SECURITY_MESSAGE_OVERLAY_CLASS;
Object.assign(shell.style, {
position: "fixed",
zIndex: "2147483646",
overflow: "hidden",
pointerEvents: "auto",
boxSizing: "border-box",
padding: "12px",
background: "rgba(24,24,27,0.35)",
});
const inner = document.createElement("div");
Object.assign(inner.style, {
width: "100%",
height: "100%",
boxSizing: "border-box",
});
shell.appendChild(inner);
let raf = 0;
const syncRect = (): void => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
if (!iframe.isConnected) {
teardownMessageSecurityOverlay(iframe);
return;
}
if (!anchor.isConnected) {
teardownMessageSecurityOverlay(iframe);
return;
}
const r = anchor.getBoundingClientRect();
const pad = 10;
const left = Math.max(8, r.left - pad);
const top = Math.max(8, r.top - pad);
const width = Math.min(window.innerWidth - left - 8, r.width + pad * 2);
const height = Math.min(window.innerHeight - top - 8, r.height + pad * 2);
shell.style.left = `${left}px`;
shell.style.top = `${top}px`;
shell.style.width = `${Math.max(0, width)}px`;
shell.style.height = `${Math.max(0, height)}px`;
});
};
syncRect();
document.body.appendChild(shell);
const ro = new ResizeObserver(syncRect);
ro.observe(anchor);
window.addEventListener("resize", syncRect);
window.addEventListener("scroll", syncRect, true);
const unmountPanel = mountBlockedContentUi(inner, {
surface: "message",
analysis: opts.analysis,
rawSnippet: opts.rawSnippet,
contextTitle: opts.contextTitle,
rootOverlay: true,
});
const cleanup = (): void => {
cancelAnimationFrame(raf);
ro.disconnect();
window.removeEventListener("resize", syncRect);
window.removeEventListener("scroll", syncRect, true);
unmountPanel();
shell.remove();
};
messageOverlayCleanups.set(iframe, cleanup);
}
function handleUserHtmlIframeLoaded(iframe: HTMLIFrameElement): void {
let idoc: Document | null = null;
try {
idoc = iframe.contentDocument;
} catch {
return;
}
if (!idoc?.documentElement) return;
const wrapper =
iframe.closest(".uiFrameWrapper") ??
iframe.closest(".iframeWrapper") ??
iframe.parentElement;
if (!wrapper) return;
if (
iframe.dataset.bssAwaitingWipeLoad === "1" &&
isPostWipeBlankDocument(idoc)
) {
iframe.dataset.bssAwaitingWipeLoad = "";
return;
}
iframe.dataset.bssAwaitingWipeLoad = "";
teardownMessageSecurityOverlay(iframe);
iframe.style.visibility = "";
iframe.style.height = "";
iframe.style.minHeight = "";
const html = idoc.documentElement.outerHTML;
const analysis = analyzeHtmlThreats(html);
if (!analysis.blocked) return;
const pane = iframe.closest('[class*="ReadingPane__ReadingPane"]');
const anchor = (pane ?? wrapper) as HTMLElement;
iframe.dataset.bssAwaitingWipeLoad = "1";
wipeIframeDocument(iframe);
iframe.style.visibility = "hidden";
iframe.style.height = "0";
iframe.style.minHeight = "0";
const subject = pane
?.querySelector('[class*="Message__subject___"]')
?.textContent?.trim();
mountBodyAnchoredMessageOverlay(iframe, anchor, {
analysis,
rawSnippet: html.slice(0, 50_000),
contextTitle: subject,
});
}
const betterSeqtaSecurityPlugin: Plugin = {
id: "better-seqta-security",
name: "BetterSEQTA Security",
description:
"Blocks risky HTML in messages and notices and surfaces administrator-ready incident reports.",
version: "1.0.0",
settings: {},
run: () => {
const { unregister } = eventManager.register(
USER_HTML_IFRAME_EVENT,
{
elementType: "iframe",
customCheck: (element) => element.classList.contains("userHTML"),
},
(element) => {
const iframe = element as HTMLIFrameElement;
if (userHtmlIframeLoadHooked.has(iframe)) return;
userHtmlIframeLoadHooked.add(iframe);
applyMessageIframeSandbox(iframe);
const onLoad = () => handleUserHtmlIframeLoaded(iframe);
iframe.addEventListener("load", onLoad);
queueMicrotask(onLoad);
},
);
return unregister;
},
};
export default betterSeqtaSecurityPlugin;
@@ -1,370 +0,0 @@
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
const settings = defineSettings({
autoScrollOnClick: booleanSetting({
default: false,
title: "Auto-scroll navigator on click",
description:
"When you click a lesson directly in the side panel, automatically scroll it to the centre. The prev/next arrows always centre the selected lesson regardless of this setting.",
}),
});
class EnhancedNavigationSettings extends BasePlugin<typeof settings> {
@Setting(settings.autoScrollOnClick)
autoScrollOnClick!: boolean;
}
const settingsInstance = new EnhancedNavigationSettings();
const ARROW_CONTAINER_ID = "betterseqta-en-arrows";
const STYLE_ID = "betterseqta-en-styles";
const injectStyles = () => {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
#${ARROW_CONTAINER_ID} {
position: fixed;
right: 24px;
display: flex;
gap: 6px;
z-index: 15;
pointer-events: none;
transition: opacity 0.15s ease;
}
body:has(.outside-container:not(.hide)) #${ARROW_CONTAINER_ID},
body:has(.bsplus-notifications-panel:not(.hide)) #${ARROW_CONTAINER_ID} {
opacity: 0;
pointer-events: none;
}
body:has(.outside-container:not(.hide)) #${ARROW_CONTAINER_ID} .en-arrow,
body:has(.bsplus-notifications-panel:not(.hide)) #${ARROW_CONTAINER_ID} .en-arrow {
pointer-events: none;
}
#${ARROW_CONTAINER_ID} .en-arrow {
pointer-events: auto;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: rgba(0, 0, 0, 0.08);
color: #000;
cursor: pointer;
transition: background 0.15s ease, transform 0.1s ease;
padding: 0;
}
#${ARROW_CONTAINER_ID} .en-arrow:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.18);
}
#${ARROW_CONTAINER_ID} .en-arrow:active:not(:disabled) {
transform: scale(0.92);
}
#${ARROW_CONTAINER_ID} .en-arrow:disabled {
opacity: 0.35;
cursor: not-allowed;
}
html.dark #${ARROW_CONTAINER_ID} .en-arrow {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
html.dark #${ARROW_CONTAINER_ID} .en-arrow:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.18);
}
#${ARROW_CONTAINER_ID} .en-arrow svg {
width: 18px;
height: 18px;
fill: currentColor;
display: block;
}
`;
document.head.appendChild(style);
};
const getOrderedItems = (navigator: Element): HTMLElement[] => {
const items: HTMLElement[] = [];
const cover = navigator.querySelector<HTMLElement>("li.cover");
if (cover) items.push(cover);
const lessons = Array.from(
navigator.querySelectorAll<HTMLElement>("li.lesson"),
);
lessons.sort((a, b) => {
const wa = parseInt(a.dataset.week ?? "0", 10);
const wb = parseInt(b.dataset.week ?? "0", 10);
if (wa !== wb) return wa - wb;
const na = parseInt(a.dataset.number ?? "0", 10);
const nb = parseInt(b.dataset.number ?? "0", 10);
return na - nb;
});
items.push(...lessons);
return items;
};
const getSelected = (navigator: Element): HTMLElement | null =>
navigator.querySelector<HTMLElement>("li.selected");
const findScrollableAncestor = (el: Element | null): HTMLElement | null => {
let cur: HTMLElement | null = el as HTMLElement | null;
while (cur && cur !== document.body) {
const style = getComputedStyle(cur);
const oy = style.overflowY;
if (
(oy === "auto" || oy === "scroll" || oy === "overlay") &&
cur.scrollHeight > cur.clientHeight + 1
) {
return cur;
}
cur = cur.parentElement;
}
return null;
};
const scrollSelectedIntoView = (navigator: Element) => {
const selected = getSelected(navigator);
if (!selected) return;
const scroller =
findScrollableAncestor(selected) ?? (navigator as HTMLElement);
if (!scroller) return;
const scrollerRect = scroller.getBoundingClientRect();
const selectedRect = selected.getBoundingClientRect();
const offset =
selectedRect.top -
scrollerRect.top -
scroller.clientHeight / 2 +
selectedRect.height / 2;
scroller.scrollTop = Math.max(
0,
Math.min(
scroller.scrollTop + offset,
scroller.scrollHeight - scroller.clientHeight,
),
);
};
const positionArrows = () => {
const container = document.getElementById(ARROW_CONTAINER_ID);
if (!container) return;
const ref =
document.getElementById("toolbar") ??
document.querySelector<HTMLElement>(".course");
if (!ref) return;
const rect = ref.getBoundingClientRect();
const arrowH = container.offsetHeight || 32;
const verticalOffset = 4;
container.style.top = `${Math.max(0, rect.top + (rect.height - arrowH) / 2 + verticalOffset)}px`;
};
let scrollOnNextSelect = false;
const navigate = (course: HTMLElement, direction: "prev" | "next") => {
const nav = course.querySelector(".navigator");
if (!nav) return;
const items = getOrderedItems(nav);
const selected = getSelected(nav);
const idx = selected ? items.indexOf(selected) : -1;
const target = idx + (direction === "next" ? 1 : -1);
if (target < 0 || target >= items.length) return;
scrollOnNextSelect = true;
items[target].click();
};
const updateArrowState = (course: HTMLElement) => {
const nav = course.querySelector(".navigator");
const container = document.getElementById(ARROW_CONTAINER_ID);
if (!nav || !container) return;
const items = getOrderedItems(nav);
const selected = getSelected(nav);
const idx = selected ? items.indexOf(selected) : -1;
const prev = container.querySelector<HTMLButtonElement>(
'button[data-en-action="prev"]',
);
const next = container.querySelector<HTMLButtonElement>(
'button[data-en-action="next"]',
);
if (prev) prev.disabled = idx <= 0;
if (next) next.disabled = idx === -1 || idx >= items.length - 1;
};
const ensureArrows = (course: HTMLElement) => {
if (!course.querySelector(".programmeNavigator")) return;
let container = document.getElementById(ARROW_CONTAINER_ID);
if (!container) {
container = document.createElement("div");
container.id = ARROW_CONTAINER_ID;
container.innerHTML = `
<button type="button" class="en-arrow" data-en-action="prev" title="Previous lesson" aria-label="Previous lesson">
<svg viewBox="0 0 24 24"><path d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<button type="button" class="en-arrow" data-en-action="next" title="Next lesson" aria-label="Next lesson">
<svg viewBox="0 0 24 24"><path d="M8.59 16.59 10 18l6-6-6-6-1.41 1.41L13.17 12z"/></svg>
</button>
`;
document.body.appendChild(container);
container.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const btn = (e.target as Element).closest<HTMLButtonElement>(
"button[data-en-action]",
);
if (!btn) return;
const liveCourse = document.querySelector<HTMLElement>(".course");
if (liveCourse)
navigate(liveCourse, btn.dataset.enAction as "prev" | "next");
});
}
positionArrows();
updateArrowState(course);
};
const watchNavigator = (navigator: Element, onChange: () => void) => {
onChange();
const observer = new MutationObserver((muts) => {
if (
muts.some(
(m) =>
(m.type === "attributes" && m.attributeName === "class") ||
m.type === "childList",
)
) {
onChange();
}
});
observer.observe(navigator, {
subtree: true,
attributes: true,
attributeFilter: ["class"],
childList: true,
});
return observer;
};
const handleSlidePane = (pane: Element): (() => void) => {
const navigator = pane.querySelector(".navigator");
if (!navigator) return () => {};
requestAnimationFrame(() => scrollSelectedIntoView(navigator));
setTimeout(() => scrollSelectedIntoView(navigator), 50);
const observer = new MutationObserver(() =>
scrollSelectedIntoView(navigator),
);
observer.observe(navigator, {
subtree: true,
attributes: true,
attributeFilter: ["class"],
childList: true,
});
const paneCleanup = new MutationObserver((muts) => {
muts.forEach((m) => {
m.removedNodes.forEach((n) => {
if (n === pane) {
observer.disconnect();
paneCleanup.disconnect();
}
});
});
});
paneCleanup.observe(document.body, { childList: true });
return () => {
observer.disconnect();
paneCleanup.disconnect();
};
};
const enhancedNavigationPlugin: Plugin<typeof settings> = {
id: "enhanced-navigation",
name: "Enhanced Navigation",
description:
"Keeps the course navigator focused on the current lesson and adds prev/next lesson arrows.",
version: "1.0.0",
disableToggle: true,
settings: settingsInstance.settings,
beta: false,
run: async (api) => {
injectStyles();
window.addEventListener("resize", positionArrows);
window.addEventListener("scroll", positionArrows, true);
const navObservers: MutationObserver[] = [];
const courseObservers: MutationObserver[] = [];
const slidePaneCleanups: Array<() => void> = [];
const courseMount = api.seqta.onMount(".course", async (element) => {
const course = element as HTMLElement;
let navObserver: MutationObserver | null = null;
const setup = () => {
const nav = course.querySelector(".navigator");
if (!nav) return false;
if (navObserver) return true;
ensureArrows(course);
navObserver = watchNavigator(nav, () => {
if (scrollOnNextSelect || api.settings.autoScrollOnClick) {
scrollSelectedIntoView(nav);
scrollOnNextSelect = false;
}
ensureArrows(course);
});
navObservers.push(navObserver);
return true;
};
if (!setup()) {
const courseObserver = new MutationObserver(() => {
if (setup()) courseObserver.disconnect();
});
courseObservers.push(courseObserver);
courseObserver.observe(course, { childList: true, subtree: true });
}
});
const bodyObserver = new MutationObserver((muts) => {
muts.forEach((m) => {
m.addedNodes.forEach((n) => {
if (n.nodeType !== 1) return;
const el = n as Element;
if (el.classList?.contains("uiSlidePane")) {
slidePaneCleanups.push(handleSlidePane(el));
}
});
});
});
bodyObserver.observe(document.body, { childList: true });
return () => {
window.removeEventListener("resize", positionArrows);
window.removeEventListener("scroll", positionArrows, true);
courseMount.unregister();
navObservers.forEach((observer) => observer.disconnect());
courseObservers.forEach((observer) => observer.disconnect());
slidePaneCleanups.forEach((cleanup) => cleanup());
bodyObserver.disconnect();
document.getElementById(ARROW_CONTAINER_ID)?.remove();
document.getElementById(STYLE_ID)?.remove();
};
},
};
export default enhancedNavigationPlugin;
+68 -39
View File
@@ -5,9 +5,7 @@ import {
defineSettings,
hotkeySetting,
} from "../../core/settingsHelpers";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import styles from "./src/core/styles.css?inline";
import { resetSearchIndexes } from "./src/indexing/resetIndexes";
// Platform-aware default hotkey
const getDefaultHotkey = () => {
@@ -36,42 +34,85 @@ const settings = defineSettings({
title: "Index on Page Load",
description: "Run content indexing when SEQTA loads",
}),
passiveIndexing: booleanSetting({
default: true,
title: "Index Browsed Content",
description:
"Capture safe text from SEQTA pages you visit so they're searchable. Sensitive routes (settings, files, login) are always excluded.",
}),
resetIndex: buttonSetting({
title: "Reset Index",
description: "Reset the search index and storage",
trigger: async () => {
const confirmed = confirm(
"Reset the search index and all stored Global Search data?\n\nAfter this, reload this SEQTA tab so indexing can run again and rebuild the index.",
);
if (!confirmed) return;
const confirmed = confirm("Are you sure you want to reset the search index and storage?");
try {
// `resetSearchIndexes` is a tiny statically-imported helper: no
// dynamic chunks to chase, so the button keeps working even when
// the settings page has been open across an extension update.
await resetSearchIndexes();
alert(
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
);
} catch (e) {
alert(
"Failed to reset index: " +
String(e) +
"\n\nTry closing other browser tabs and try again.",
);
if (confirmed) {
try {
// Dynamically import modules to avoid loading heavy dependencies
const { VectorWorkerManager } = await import("./src/indexing/worker/vectorWorkerManager");
const { resetDatabase } = await import("./src/indexing/db");
// Reset vector worker first
try {
const workerManager = VectorWorkerManager.getInstance();
await workerManager.resetWorker();
console.log("Vector worker reset successfully");
} catch (e) {
console.warn("Failed to reset vector worker:", e);
}
// Close all database connections properly before deletion
try {
await resetDatabase();
console.log("betterseqta-index database closed and reset");
} catch (e) {
console.warn("Failed to reset betterseqta-index database:", e);
}
// Wait a bit for connections to fully close
await new Promise(resolve => setTimeout(resolve, 100));
// Delete embeddiaDB (vector search database)
const deleteDb = (dbName: string) => {
return new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => {
console.log(`Successfully deleted database: ${dbName}`);
resolve();
};
req.onerror = () => {
console.error(`Error deleting database ${dbName}:`, req.error);
reject(req.error);
};
req.onblocked = () => {
console.warn(`Database ${dbName} deletion blocked - connections still open`);
// Wait and retry once
setTimeout(() => {
const retryReq = indexedDB.deleteDatabase(dbName);
retryReq.onsuccess = () => {
console.log(`Successfully deleted database on retry: ${dbName}`);
resolve();
};
retryReq.onerror = () => reject(retryReq.error);
retryReq.onblocked = () => {
reject(new Error(`One database is open, failed to remove: ${dbName}. Please close other tabs and try again.`));
};
}, 500);
};
});
};
try {
await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index");
alert("Search index and storage have been reset successfully.");
} catch (e) {
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
}
} catch (e) {
alert("Failed to reset index: " + String(e));
}
}
},
}),
});
// Create the lazy plugin definition - this loads immediately but doesn't import heavy dependencies
const globalSearchPlugin = defineLazyPlugin({
export default defineLazyPlugin({
id: "global-search",
name: "Global Search",
description: "Quick search for everything in SEQTA",
@@ -84,15 +125,3 @@ const globalSearchPlugin = defineLazyPlugin({
// Lazy loader - only imports the heavy plugin when actually needed
loader: () => import("./src/core/index")
});
const runGlobalSearch = globalSearchPlugin.run!;
globalSearchPlugin.run = async (api) => {
if (isSeqtaEngageExperience()) {
return () => {};
}
return runGlobalSearch(api);
};
export default globalSearchPlugin;
@@ -5,7 +5,7 @@
import { circOut, quintOut } from 'svelte/easing';
import { type StaticCommandItem } from '../core/commands';
import type { CombinedResult } from '../core/types';
import { createSearchIndexes, applyDynamicIndexDelta, performSearch as doSearch, type DynamicItemsUpdatedDetail } from '../search/searchUtils';
import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils';
import Fuse from 'fuse.js';
import Calculator from './Calculator.svelte';
import { actionMap } from '../indexing/actions';
@@ -48,13 +48,6 @@
let calculatorResult = $state<string | null>(null);
let resultsList = $state<HTMLUListElement>();
// Monotonic counter so a slow async search (vector reranking) cannot
// overwrite results from a newer keystroke. Without this guard, the user
// observes results "flickering" — e.g. typing `world w` finds the assessment
// but `world wa` triggers a new search whose vector pass returns later than
// the `world w` pass and clobbers the more relevant matches.
let searchRequestId = 0;
const updateCalculatorState = (hasResult: string | null) => {
calculatorResult = hasResult;
};
@@ -129,31 +122,7 @@
window.addEventListener('indexing-progress', progressHandler as EventListener);
const itemsUpdatedHandler = (event: Event) => {
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail;
if (
detail?.vectorUpdate &&
!detail.changedItems?.length &&
!detail.removedIds?.length
) {
performSearch();
return;
}
if (detail?.incremental && !detail.fullRebuild) {
const updatedFuse = applyDynamicIndexDelta(
dynamicContentFuse,
dynamicIdToItemMap,
detail,
);
if (updatedFuse) {
dynamicContentFuse = updatedFuse;
performSearch();
return;
}
}
const itemsUpdatedHandler = () => {
setupSearchIndexes();
performSearch();
};
@@ -197,37 +166,21 @@
});
const term = searchTerm.trim().toLowerCase();
const requestId = ++searchRequestId;
try {
if (commandsFuse && dynamicContentFuse) {
const results = await doSearch(
term,
commandsFuse,
commandIdToItemMap,
dynamicContentFuse,
dynamicIdToItemMap,
true, // sortByRecent
);
// Drop the result if the user has typed since this search started, or
// if the current term no longer matches what we searched for. This
// keeps the visible list anchored to the latest query.
if (requestId !== searchRequestId) return;
if (searchTerm.trim().toLowerCase() !== term) return;
combinedResults = results;
} else {
if (requestId !== searchRequestId) return;
combinedResults = [];
}
} finally {
// Only clear loading for the latest in-flight search — stale async
// passes must not leave the spinner stuck after fast typing.
if (requestId === searchRequestId) {
isLoading = false;
}
if (commandsFuse && dynamicContentFuse) {
combinedResults = await doSearch(
term,
commandsFuse,
commandIdToItemMap,
dynamicContentFuse,
dynamicIdToItemMap,
true, // sortByRecent
);
} else {
combinedResults = [];
}
isLoading = false;
};
// Optimized debounce: shorter delay for better responsiveness
@@ -1,89 +0,0 @@
<script lang="ts">
import HighlightedText from '../../utils/HighlightedText.svelte';
import type { DynamicContentItem } from '../../utils/dynamicItems';
import type { FuseResultMatch } from '../../core/types';
const { item, isSelected, searchTerm, matches, onclick } = $props<{
item: DynamicContentItem;
isSelected: boolean;
searchTerm: string;
matches?: readonly FuseResultMatch[];
onclick: () => void;
}>();
const categoryLabel = (category: string): string => {
if (!category) return '';
return category.charAt(0).toUpperCase() + category.slice(1);
};
const gradientForCategory = (category: string): string => {
switch (category) {
case 'courses':
return 'from-[#7c5fe0] to-[#4d2bb8]';
case 'notices':
return 'from-[#f6c453] to-[#d39007]';
case 'documents':
return 'from-[#4FBBFE] to-[#2090F3]';
case 'folio':
return 'from-[#22c55e] to-[#0f9b3a]';
case 'portals':
return 'from-[#22d3ee] to-[#0e7490]';
case 'reports':
return 'from-[#f97316] to-[#c2410c]';
case 'goals':
return 'from-[#10b981] to-[#047857]';
case 'passive':
return 'from-[#6b7280] to-[#374151]';
default:
return 'from-[#4FBBFE] to-[#2090F3]';
}
};
const fallbackIcon = (category: string): string => {
switch (category) {
case 'courses':
return '\ueb4d';
case 'notices':
return '\ueb24';
case 'documents':
return '\ueb6f';
case 'folio':
return '\ueb16';
case 'portals':
return '\ueb01';
case 'reports':
return '\ueb70';
case 'goals':
return '\uea15';
case 'passive':
return '\ueb71';
default:
return '\ue924';
}
};
</script>
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group transition-colors duration-100 ring-0 dark:ring-zinc-600/50
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white dark:ring-[1px] dark:shadow' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={onclick}
>
<div class="flex items-center w-full">
<div
class="flex-none scale-90 w-8 h-8 text-xl font-IconFamily flex items-center justify-center text-white rounded-md bg-gradient-to-br {gradientForCategory(item.category)}"
>
{item.metadata?.icon || fallbackIcon(item.category)}
</div>
<span class="ml-4 text-lg truncate">
<HighlightedText text={item.text} term={searchTerm} matches={matches} />
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
{item.metadata?.subjectCode || categoryLabel(item.category)}
</span>
</div>
{#if item.content}
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
<HighlightedText text={item.content} term={searchTerm} matches={matches} />
</div>
{/if}
</button>
@@ -25,11 +25,11 @@ async function getCurrentLesson() {
try {
const response = await fetch(`${location.origin}/seqta/student/load/timetable?`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from: todayFormatted,
until: todayFormatted,
student: 69,
}),
});
@@ -214,7 +214,7 @@ const staticCommands: StaticCommandItem[] = [
code: 'KeyM',
keyCode: 77,
altKey: true
}, location.origin);
}, "*");
},
keywords: ["compose", "message", "dm", "direct message", "new message"],
priority: 3,
@@ -15,10 +15,6 @@ import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
import { IndexedDbManager } from "embeddia";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { checkAndHandleUpdate } from "../utils/versionCheck";
import {
getStoredPassiveItems,
installPassiveObserver,
} from "../indexing/passiveObserver";
// Platform-aware default hotkey
const getDefaultHotkey = () => {
@@ -47,19 +43,11 @@ const settings = defineSettings({
title: "Index on Page Load",
description: "Run content indexing when SEQTA loads",
}),
passiveIndexing: booleanSetting({
default: true,
title: "Index Browsed Content",
description:
"Capture safe text from SEQTA pages you visit so they're searchable. Sensitive routes (settings, files, login) are always excluded.",
}),
resetIndex: buttonSetting({
title: "Reset Index",
description: "Reset the search index and storage",
trigger: async () => {
const confirmed = confirm(
"Reset the search index and all stored Global Search data?\n\nAfter this, reload this SEQTA tab so indexing can run again and rebuild the index.",
);
const confirmed = confirm("Are you sure you want to reset the search index and storage?");
if (confirmed) {
try {
@@ -118,9 +106,7 @@ const settings = defineSettings({
try {
await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index");
alert(
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
);
alert("Search index and storage have been reset successfully.");
} catch (e) {
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
}
@@ -145,9 +131,6 @@ class GlobalSearchPlugin extends BasePlugin<typeof settings> {
@Setting(settings.runIndexingOnLoad)
runIndexingOnLoad!: boolean;
@Setting(settings.passiveIndexing)
passiveIndexing!: boolean;
@Setting(settings.resetIndex)
resetIndex!: () => void;
}
@@ -167,35 +150,26 @@ const globalSearchPlugin: Plugin<typeof settings> = {
run: async (api) => {
const appRef = { current: null };
// Run the version check BEFORE we open any IndexedDB connections.
// On a normal load (no version change) this is just a string compare
// and a manifest read, so the cost is negligible. On a real update,
// we want the database wipe to complete before `IndexedDbManager`
// grabs a handle on `embeddiaDB`, otherwise the delete request comes
// back blocked.
try {
const wasUpdated = await checkAndHandleUpdate();
if (wasUpdated) {
console.log(
"[Global Search] Extension updated — search index reset; the next indexing pass will repopulate.",
);
// Check for extension updates and clear caches if needed
// Use a timeout to avoid blocking initialization
setTimeout(async () => {
try {
const wasUpdated = await checkAndHandleUpdate();
if (wasUpdated) {
console.log("[Global Search] Extension updated - caches cleared");
}
} catch (error: any) {
// Handle CSS preload errors and other failures gracefully
// These can happen in Firefox or when assets aren't available
if (error?.message?.includes("preload CSS") ||
error?.message?.includes("MIME type") ||
error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")) {
console.debug("[Global Search] Version check skipped due to asset loading restrictions:", error.message);
} else {
console.warn("[Global Search] Failed to check for updates:", error);
}
}
} catch (error: any) {
// Firefox sometimes refuses CSS preloads or asset reads; we never
// want this path to take the whole plugin down.
if (
error?.message?.includes("preload CSS") ||
error?.message?.includes("MIME type") ||
error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")
) {
console.debug(
"[Global Search] Version check skipped due to asset loading restrictions:",
error.message,
);
} else {
console.warn("[Global Search] Failed to check for updates:", error);
}
}
}, 100);
try {
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
@@ -236,17 +210,6 @@ const globalSearchPlugin: Plugin<typeof settings> = {
const workerManager = VectorWorkerManager.getInstance();
console.log("Streaming active:", workerManager.isStreamingActive());
},
passiveItems: async () => {
const items = await getStoredPassiveItems();
console.log(`Captured ${items.length} passive items`);
return items;
},
runSelfTests: async () => {
const { runGlobalSearchSelfTests } = await import(
"../indexing/selfTests"
);
return runGlobalSearchSelfTests();
},
checkIndexedDBSize: async () => {
try {
const estimate = await navigator.storage.estimate();
@@ -269,14 +232,6 @@ const globalSearchPlugin: Plugin<typeof settings> = {
}
};
if (api.settings.passiveIndexing) {
try {
installPassiveObserver();
} catch (error) {
console.warn("[Global Search] Passive observer install failed:", error);
}
}
if (api.settings.runIndexingOnLoad) {
setTimeout(async () => {
await runIndexing();
@@ -286,10 +241,10 @@ const globalSearchPlugin: Plugin<typeof settings> = {
const title = document.querySelector("#title");
if (title) {
void mountSearchBar(title, api, appRef);
mountSearchBar(title, api, appRef);
} else {
const titleElement = await waitForElm("#title", true, 100, 60);
void mountSearchBar(titleElement, api, appRef);
mountSearchBar(titleElement, api, appRef);
}
return () => {
@@ -1,18 +1,14 @@
import renderSvelte from "@/interface/main";
import SearchBar from "../components/SearchBar.svelte";
import { unmount } from "svelte";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
import browser from "webextension-polyfill";
export async function mountSearchBar(
export function mountSearchBar(
titleElement: Element,
api: any,
appRef: {
current: any;
storageChangeHandler?: any;
progressHandler?: any;
clearDoneFlashTimer?: () => void;
},
appRef: { current: any; storageChangeHandler?: any; progressHandler?: any },
) {
if (titleElement.querySelector(".search-trigger")) {
return;
@@ -22,260 +18,89 @@ export async function mountSearchBar(
let currentHotkey = isValidHotkey(api.settings.searchHotkey) ? api.settings.searchHotkey : "ctrl+k";
let hotkeyDisplay = formatHotkeyForDisplay(currentHotkey);
// Search trigger + progress UI live in one wrapper so the auto-margin
// pushes the whole group to the left edge of the topbar instead of
// stranding the progress text on the far right of the screen.
const searchWrapper = document.createElement("div");
searchWrapper.className = "search-trigger-wrapper";
// Anchor stacks button + slim progress strip in one rounded chip (see
// `.search-trigger-anchor` in styles.css).
const searchAnchor = document.createElement("div");
searchAnchor.className = "search-trigger-anchor";
const searchButton = document.createElement("div");
searchButton.className = "search-trigger";
const searchIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
searchIcon.setAttribute("xmlns", "http://www.w3.org/2000/svg");
searchIcon.setAttribute("width", "16");
searchIcon.setAttribute("height", "16");
searchIcon.setAttribute("viewBox", "0 0 24 24");
searchIcon.setAttribute("fill", "none");
searchIcon.setAttribute("stroke", "currentColor");
searchIcon.setAttribute("stroke-width", "2");
searchIcon.setAttribute("stroke-linecap", "round");
searchIcon.setAttribute("stroke-linejoin", "round");
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", "11");
circle.setAttribute("cy", "11");
circle.setAttribute("r", "8");
searchIcon.appendChild(circle);
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", "21");
line.setAttribute("y1", "21");
line.setAttribute("x2", "16.65");
line.setAttribute("y2", "16.65");
searchIcon.appendChild(line);
const searchLabel = document.createElement("p");
searchLabel.textContent = "Quick search...";
const hotkeySpan = document.createElement("span");
hotkeySpan.className = "search-trigger-hotkey";
hotkeySpan.style.marginLeft = "auto";
hotkeySpan.style.display = "flex";
hotkeySpan.style.alignItems = "center";
hotkeySpan.style.color = "#777";
hotkeySpan.style.fontSize = "12px";
// Create progress indicator container
const progressContainer = document.createElement("div");
progressContainer.className = "search-progress-container";
progressContainer.style.cssText = "display: flex; align-items: center; gap: 8px; margin-left: 8px; min-width: 120px;";
// Create progress bar
const progressBarWrapper = document.createElement("div");
progressBarWrapper.className = "search-progress-bar-wrapper";
const progressTrack = document.createElement("div");
progressTrack.className = "search-progress-track";
progressBarWrapper.style.cssText = "flex: 1; height: 4px; background: rgba(0, 0, 0, 0.1); border-radius: 2px; overflow: hidden; display: none;";
const progressBar = document.createElement("div");
progressBar.className = "search-progress-bar";
progressTrack.appendChild(progressBar);
progressBarWrapper.appendChild(progressTrack);
// Use a block-level <div> so the label reliably participates in flex
// layout. A <span> defaults to `display: inline`, which silently ignores
// `max-width`, `overflow`, and `text-overflow: ellipsis`, and was the
// reason the label appeared blank when the bar was visible.
const progressText = document.createElement("div");
progressBar.style.cssText = "height: 100%; background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6); transition: width 0.3s ease-out; width: 0%; position: relative;";
// Add shimmer effect
const shimmer = document.createElement("div");
shimmer.style.cssText = "position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); animation: shimmer 2s infinite;";
progressBar.appendChild(shimmer);
progressBarWrapper.appendChild(progressBar);
// Create progress text
const progressText = document.createElement("span");
progressText.className = "search-progress-text";
progressText.setAttribute("aria-live", "polite");
searchAnchor.appendChild(searchButton);
searchAnchor.appendChild(progressBarWrapper);
searchWrapper.appendChild(searchAnchor);
searchWrapper.appendChild(progressText);
progressText.style.cssText = "font-size: 11px; color: #666; white-space: nowrap; display: none;";
progressContainer.appendChild(progressBarWrapper);
progressContainer.appendChild(progressText);
// Indexing state
let isIndexing = false;
/** True while indexing has run until it finishes/fails — used for Done! flash only */
let ranIndexingCycle = false;
let completedJobs = 0;
let totalJobs = 0;
let indexingStatus: string | null = null;
let doneFlashTimer: ReturnType<typeof setTimeout> | null = null;
let doneFadeTimer: ReturnType<typeof setTimeout> | null = null;
/** Captures `wasIndexing && !indexing` for the current dispatcher tick */
let indexingJustStoppedFlag = false;
const DONE_HOLD_MS = 5000;
const DONE_FADE_MS = 550;
/** Treat as failure copy — plain “Done!” would be misleading */
const statusLooksRough = (s: string) =>
/\b(fail|error|cancel)\b/i.test(s);
const truncateStatus = (s: string, max = 44) =>
s.length > max ? s.slice(0, max - 1) + "…" : s;
const clearDoneFlashTimer = () => {
if (doneFlashTimer) {
clearTimeout(doneFlashTimer);
doneFlashTimer = null;
}
if (doneFadeTimer) {
clearTimeout(doneFadeTimer);
doneFadeTimer = null;
}
};
const updateProgressDisplay = () => {
const indexingStoppedThisTick = indexingJustStoppedFlag;
indexingJustStoppedFlag = false;
const active = isIndexing && totalJobs > 0;
// Stray pulses (missing total, 0 completed, etc.) used to hit the idle
// branch and call clearDoneFlashTimer(), killing the Done! hold/fade.
if (doneFlashTimer !== null || doneFadeTimer !== null) {
if (!active) {
return;
}
clearDoneFlashTimer();
}
const completionEligible =
ranIndexingCycle &&
!active &&
totalJobs > 0 &&
(completedJobs >= totalJobs || indexingStoppedThisTick);
if (active) {
clearDoneFlashTimer();
progressBarWrapper.classList.remove("is-rough-complete");
progressText.classList.remove(
"is-rough",
"is-fading-done",
"is-done-message",
);
if (isIndexing && totalJobs > 0) {
const percentage = Math.round((completedJobs / totalJobs) * 100);
progressBar.style.width = `${Math.max(2, percentage)}%`;
progressBarWrapper.classList.add("is-active");
searchAnchor.classList.add("is-indexing");
searchButton.classList.add("is-indexing");
progressBarWrapper.style.display = "block";
if (indexingStatus) {
progressText.textContent = `${truncateStatus(indexingStatus)} · ${percentage}%`;
progressText.textContent = indexingStatus.length > 20 ? indexingStatus.substring(0, 20) + "..." : indexingStatus;
progressText.style.display = "block";
} else {
progressText.textContent = `Indexing ${completedJobs}/${totalJobs} (${percentage}%)`;
progressText.textContent = `${completedJobs}/${totalJobs} (${percentage}%)`;
progressText.style.display = "block";
}
progressText.classList.add("is-active");
return;
} else {
progressBarWrapper.style.display = "none";
progressText.style.display = "none";
}
if (completionEligible) {
// Duplicate end-of-run ticks must not reschedule hold/fade timers
if (doneFlashTimer !== null || doneFadeTimer !== null) {
return;
}
const rough =
indexingStatus != null && statusLooksRough(indexingStatus);
progressBar.style.width = "0%";
progressBarWrapper.classList.remove("is-active");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove("is-fading-done");
progressText.textContent = rough ? truncateStatus(indexingStatus!, 52) : "Done!";
if (rough) {
progressText.classList.add("is-rough");
progressBarWrapper.classList.add("is-rough-complete");
} else {
progressText.classList.remove("is-rough");
progressBarWrapper.classList.remove("is-rough-complete");
}
progressText.classList.add("is-active", "is-done-message");
doneFlashTimer = setTimeout(() => {
doneFlashTimer = null;
progressText.classList.add("is-fading-done");
doneFadeTimer = setTimeout(() => {
doneFadeTimer = null;
ranIndexingCycle = false;
indexingStatus = null;
progressBar.style.width = "0%";
progressBarWrapper.classList.remove("is-active");
progressBarWrapper.classList.remove("is-rough-complete");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove(
"is-active",
"is-rough",
"is-fading-done",
"is-done-message",
);
progressText.textContent = "";
}, DONE_FADE_MS);
}, DONE_HOLD_MS);
return;
}
clearDoneFlashTimer();
progressBarWrapper.classList.remove("is-active");
progressBarWrapper.classList.remove("is-rough-complete");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove(
"is-active",
"is-rough",
"is-fading-done",
"is-done-message",
);
progressBar.style.width = "0%";
progressText.textContent = "";
ranIndexingCycle = false;
indexingStatus = null;
};
// Listen for indexing progress events
const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing, status } = event.detail as {
completed?: number;
total?: number;
indexing?: boolean;
status?: string;
};
const wasIndexing = isIndexing;
completedJobs = completed ?? 0;
totalJobs = total ?? 0;
isIndexing = Boolean(indexing);
indexingStatus = status ?? null;
indexingJustStoppedFlag = wasIndexing && !isIndexing;
if (!wasIndexing && isIndexing) ranIndexingCycle = true;
if (wasIndexing && !isIndexing) ranIndexingCycle = true;
if (totalJobs > 0 && completedJobs >= totalJobs && !isIndexing) {
ranIndexingCycle = true;
}
const { completed, total, indexing, status } = event.detail;
completedJobs = completed || 0;
totalJobs = total || 0;
isIndexing = indexing || false;
indexingStatus = status || null;
updateProgressDisplay();
};
window.addEventListener('indexing-progress', progressHandler as EventListener);
appRef.progressHandler = progressHandler;
appRef.clearDoneFlashTimer = clearDoneFlashTimer;
const updateSearchButtonDisplay = () => {
hotkeySpan.textContent = hotkeyDisplay;
if (!searchButton.contains(searchIcon)) {
searchButton.replaceChildren(searchIcon, searchLabel, hotkeySpan);
}
searchButton.innerHTML = /* html */ `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<p>Quick search...</p>
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">${hotkeyDisplay}</span>
`;
};
updateSearchButtonDisplay();
titleElement.appendChild(searchWrapper);
titleElement.appendChild(searchButton);
titleElement.appendChild(progressContainer);
// Listen for hotkey setting changes
const handleStorageChange = (changes: any, area: string) => {
@@ -304,7 +129,6 @@ export async function mountSearchBar(
});
try {
const { default: renderSvelte } = await import("@/interface/main");
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
transparencyEffects: api.settings.transparencyEffects ? true : false,
showRecentFirst: api.settings.showRecentFirst,
@@ -315,12 +139,7 @@ export async function mountSearchBar(
}
}
export function cleanupSearchBar(appRef: {
current: any;
storageChangeHandler?: any;
progressHandler?: any;
clearDoneFlashTimer?: () => void;
}) {
export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any; progressHandler?: any }) {
if (appRef.current) {
try {
unmount(appRef.current);
@@ -330,29 +149,23 @@ export function cleanupSearchBar(appRef: {
}
}
try {
appRef.clearDoneFlashTimer?.();
} catch {
/* ignore */
}
appRef.clearDoneFlashTimer = undefined;
// Remove progress event listener
if (appRef.progressHandler) {
window.removeEventListener('indexing-progress', appRef.progressHandler as EventListener);
appRef.progressHandler = null;
}
// Remove search trigger wrapper (which contains the button and progress UI)
const searchWrapper = document.querySelector(".search-trigger-wrapper");
if (searchWrapper) {
searchWrapper.remove();
// Remove search trigger button
const searchTrigger = document.querySelector(".search-trigger");
if (searchTrigger) {
searchTrigger.remove();
}
// Remove progress container
const progressContainer = document.querySelector(".search-progress-container");
if (progressContainer) {
progressContainer.remove();
}
// Defensive cleanup for older mounts that may have left the trigger or
// progress container as direct children of the topbar.
document.querySelector(".search-trigger")?.remove();
document.querySelector(".search-progress-container")?.remove();
// Remove search root
const searchRoot = document.querySelector("div[data-search-root]");
@@ -1,72 +1,15 @@
/*
* Wrapper that owns the auto-margin so the whole search-trigger-and-progress
* group sits at the left of the SEQTA topbar. Previously, only the
* `.search-trigger` had `margin-right: auto`, which pushed the progress text
* all the way to the far right of the screen.
*/
.search-trigger-wrapper {
display: flex !important;
align-items: center;
gap: 12px;
margin-left: 10px;
margin-right: auto !important;
/* Allow the bar's bottom portion to peek out below the wrapper without
getting clipped by the topbar's flex line. */
overflow: visible;
}
/*
* Stacks the clickable row and the progress strip as one visual chip
* so the bar is flush under the button (no floating gap).
*/
.search-trigger-anchor {
display: inline-flex;
flex-direction: column;
align-items: stretch;
vertical-align: middle;
border-radius: 8px;
overflow: hidden;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.06) inset,
0 3px 8px rgba(0, 0, 0, 0.12);
}
.dark .search-trigger-anchor {
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 3px 10px rgba(0, 0, 0, 0.45);
}
.search-trigger-anchor.is-indexing {
/* Very soft “rear card” edge — tweak opacity if SEQTA chrome is noisy */
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.06) inset,
0 3px 8px rgba(0, 0, 0, 0.14),
1px 3px 0 rgba(139, 92, 246, 0.14),
0 2px 6px rgba(0, 0, 0, 0.08);
}
.dark .search-trigger-anchor.is-indexing {
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.05) inset,
0 4px 12px rgba(0, 0, 0, 0.5),
1px 3px 0 rgba(167, 139, 250, 0.12),
0 2px 8px rgba(0, 0, 0, 0.25);
}
.search-trigger {
display: flex;
align-items: center;
justify-content: center;
flex: none;
height: 32px;
border-radius: 0;
margin-left: 10px;
border-radius: 8px;
cursor: pointer;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
transition: all 0.2s ease;
margin-right: auto !important;
padding: 3px 12px;
box-shadow: none;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
user-select: none;
@@ -85,12 +28,10 @@
}
}
/* Light mode chip */
/* Light mode styles */
.search-trigger {
background-color: rgba(248, 250, 252, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
border-bottom: none;
border-radius: 8px 8px 0 0;
background-color: rgba(248, 250, 252, 0.94) !important;
color: #555 !important;
p {
@@ -103,10 +44,8 @@
}
.dark .search-trigger {
background-color: rgba(0, 0, 0, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-bottom: none;
border-radius: 8px 8px 0 0;
background-color: rgba(24, 24, 27, 0.92) !important;
color: #aaa !important;
p {
@@ -118,17 +57,7 @@
}
}
/*
* Idle: full pill rounding + closed bottom border on the anchor chip.
*/
.search-trigger-anchor:not(.is-indexing) .search-trigger {
border-radius: 8px !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.1) !important;
}
.dark .search-trigger-anchor:not(.is-indexing) .search-trigger {
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
}
.highlight {
background-color: rgba(255, 213, 0, 0.3);
font-weight: 500;
@@ -154,139 +83,57 @@
animation: shimmer 2s infinite;
}
/*
* Thin track flush under `.search-trigger` same width as chip, shared
* `overflow:hidden` rounding on `.search-trigger-anchor`.
*/
/* Progress indicator next to search trigger */
.search-progress-container {
display: flex;
align-items: center;
gap: 8px;
margin-left: 8px;
min-width: 120px;
max-width: 200px;
height: 32px;
}
.search-progress-bar-wrapper {
flex: none;
height: 0;
min-height: 0;
border: none;
background: transparent;
border-radius: 0;
overflow: hidden;
opacity: 1;
transform: none;
pointer-events: none;
transition: height 0.22s cubic-bezier(0.2, 0.7, 0.3, 1);
}
.search-progress-bar-wrapper.is-active {
flex: 1;
height: 4px;
}
.search-progress-track {
box-sizing: border-box;
height: 100%;
width: 100%;
position: relative;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
overflow: hidden;
background: rgba(15, 23, 42, 0.08);
display: none;
min-width: 60px;
}
.dark .search-progress-track {
background: rgba(248, 250, 252, 0.1);
.dark .search-progress-bar-wrapper {
background: rgba(255, 255, 255, 0.1);
}
.search-progress-bar {
position: relative;
height: 100%;
background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6);
transition: width 0.3s ease-out;
width: 0%;
background: linear-gradient(90deg, #38bdf8, #2563eb);
transition:
width 0.35s cubic-bezier(0.2, 0.7, 0.35, 1),
background 0.25s ease;
}
.search-progress-bar-wrapper.is-rough-complete .search-progress-track {
background: rgba(185, 28, 28, 0.12);
}
.dark .search-progress-bar-wrapper.is-rough-complete .search-progress-track {
background: rgba(248, 113, 113, 0.12);
}
.search-progress-bar-wrapper.is-rough-complete .search-progress-bar {
background: linear-gradient(90deg, #f87171, #dc2626);
position: relative;
border-radius: 2px;
}
.search-progress-bar::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.28),
transparent
);
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
animation: shimmer 2s infinite;
border-radius: 2px;
}
/*
* Progress label sits as a flex child immediately to the right of the
* search button (gap is provided by .search-trigger-wrapper). It's hidden
* by default and fades in once an indexing pass is active.
*/
.search-progress-text {
display: block;
font-size: 12px;
color: #475569;
font-size: 11px;
color: #666;
white-space: nowrap;
display: none;
font-weight: 500;
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.2s ease, transform 0.2s ease, color 0.25s ease;
pointer-events: none;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.35;
letter-spacing: 0.01em;
flex: 0 0 auto;
align-self: center;
}
/* While indexing: same neutral label colour as default (only “Done!” is green). */
.search-progress-text.is-active {
opacity: 1;
transform: translateX(0);
color: #475569;
}
/* Completed pass — green text only here, not on the strip or chip */
.search-progress-text.is-active.is-done-message {
font-weight: 600;
letter-spacing: 0.02em;
color: #15803d !important;
}
.dark .search-progress-text.is-active.is-done-message {
color: #4ade80 !important;
}
/* After DONE_HOLD_MS, fade out before DOM teardown */
.search-progress-text.is-active.is-fading-done {
opacity: 0;
transform: translateX(-4px);
transition:
opacity 0.5s ease,
transform 0.45s ease,
color 0.25s ease;
}
.dark .search-progress-text {
color: #cbd5e1;
}
.dark .search-progress-text.is-active {
color: #cbd5e1;
}
.search-progress-text.is-active.is-rough {
color: #b91c1c;
}
.dark .search-progress-text.is-active.is-rough {
color: #fca5a5;
color: #999;
}
@@ -1,161 +0,0 @@
/**
* Representative SEQTA response shapes captured from a real `/seqta/student/`
* session via the websiteskimmer recorder. These are static fixtures used
* by `selfTests.ts` to verify our extractors and the passive observer
* remain compatible with the upstream API as it evolves.
*
* NOTE: These fixtures are scrubbed of any secrets and reduced in size; the
* structure (keys, types, nesting) faithfully matches what SEQTA returns
* but the values are illustrative rather than real student data.
*/
export const subjectsListPayload = [
{
code: "2026S1",
description: "Sample Semester 1 timetable",
active: 1,
id: 77,
subjects: [
{
code: "ENGG1",
classunit: 29248,
description: "English GEN 1",
metaclass: 29611,
title: "English GEN 1",
programme: 3830,
marksbook_type: "numeric",
},
{
code: "MASA1",
classunit: 29247,
description: "Mathematics Specialist 1",
metaclass: 29610,
title: "Mathematics Specialist 1",
programme: 3831,
marksbook_type: "numeric",
},
],
},
];
export const coursesPayload = {
c: "ENGG1#1",
t: "English GEN 1",
i: 3830,
m: 29611,
document:
'{"document":{"modules":[{"uuid":"1641cf87-ae08-4bcb-832d-d5709d84d0c5"}]}}',
w: [
[
{ t: "", h: "", i: 248293, l: "", n: 0, o: "" },
{
t: "",
i: 248316,
l: '<p><a href="http://ed.ted.com/on/r80lnJL0#watch">http://ed.ted.com/on/r80lnJL0#watch</a></p>',
n: 1,
o: "",
},
],
[{ t: "Lesson 2", h: "<h1>Module 2</h1>", i: 248294, l: "", n: 0, o: "" }],
],
};
export const messagesListPayload = {
hasMore: false,
messages: [
{
date: "2026-04-29 04:26:25.075868+00",
attachments: false,
read: 1,
sender: "Jacob Johannesburg",
subject: "test",
sender_type: "student",
attachmentCount: 0,
id: 81469,
sender_id: 3111,
},
],
ts: "2026-04-30 03:25:02.27900",
};
export const documentsPayload = [
{
docs: [
{
file: 49555,
filename: "School Glossary.docx",
size: "14931",
context_uuid: "3162189c-2052-4f83-ad83-a66c57460ea2",
mimetype:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
created_date: "2021-08-04 12:55:55.102653+00",
title: "School Glossary",
uuid: "3162189c-2052-4f83-ad83-a66c57460ea2",
created_by: "537",
},
],
id: 9,
category: "Document repository",
},
];
export const noticesPayload = [
{
id: 12345,
title: "Lunchtime sport tomorrow",
contents: "<p>Bring shoes.</p>",
staff: "Mr Coach",
staff_id: 246,
label: 9,
label_title: "All Students",
colour: "#ff5722",
},
];
export const portalsPayload = [
{
is_power_portal: false,
inherit_styles: true,
icon: "colour-cerulean",
id: 328,
label: "Mathletics",
priority: 20,
uuid: "9d20f40c-fdc9-4aa3-91f1-905d86e240c4",
url: "www.mathletics.com/",
},
];
export const folioListPayload = {
me: "Jacob Johannesburg",
list: [
{
student: "Jacob Johannesburg",
id: 203,
published: "2026-04-14 20:02:50",
title: "My folio",
},
],
};
export const folioEntryPayload = {
forum: 478,
contents:
'[[embed:raw|<p>Some <strong>reflection</strong> text.</p>]] Plain trailing text.',
created: "2026-04-14 10:32:34.264641+00",
allow_comments: true,
author: { year: "Year 10", name: "Jacob Johannesburg", id: 3111 },
files: [],
id: 203,
published: "2026-04-14 20:02:50",
title: "My folio",
updated: "2026-04-14 10:32:50.696678+00",
};
/**
* Settings payload contains tenant-wide configuration including third-party
* URLs and API keys. The passive observer must NEVER index this route.
*/
export const settingsPayload = {
"global.dropbox.api.key": { value: "xxx-do-not-index" },
"global.ai.api.baseurl": { value: "https://example.com" },
};
@@ -28,40 +28,6 @@ interface AssessmentMetadata {
type ActionHandler<T = any> = (item: IndexItem & { metadata: T }) => void;
/**
* Navigate to a SEQTA SPA hash route in the most reliable way available.
*
* Setting `location.hash` works when the destination module is already
* registered with SEQTA's hashchange router (as is the case for the
* existing `message`/`assessment` actions, which then poke at the live
* DOM). For navigations that switch to a module the SPA may not have
* loaded yet (courses, forums, folios, portals, documents, reports,
* goals, notices, ...) we instead assign through `location.href` against
* the canonical `${origin}/` base. The path stays `/`, so the browser
* still treats this as a hash-only change in practice but if anything
* went sideways with the path, we get a clean reload that bootstraps the
* SPA fresh, which is far less surprising than a blank screen.
*/
function navigateToHashRoute(routeWithLeadingSlash: string): void {
const target = `${location.origin}/#?page=${routeWithLeadingSlash}`;
window.location.href = target;
}
function navigateInCurrentSeqtaApp(routeWithLeadingSlash: string): void {
window.location.hash = `#?page=${routeWithLeadingSlash}`;
}
/**
* Final-fallback hub when an item has no usable deep-link metadata.
*
* `/dashboard` is the standard SEQTA Learn landing page and is the
* destination the websiteskimmer recording captured for unknown routes.
* `/home` is BetterSEQTA-Plus's custom replacement which only renders
* after our content script has hooked the SPA using it as a fallback
* from a fresh nav can produce a blank frame.
*/
const FALLBACK_ROUTE = "/dashboard";
export const actionMap: Record<string, ActionHandler<any>> = {
message: (async (item: IndexItem & { metadata: MessageMetadata }) => {
window.location.hash = `#?page=/messages`;
@@ -115,34 +81,32 @@ export const actionMap: Record<string, ActionHandler<any>> = {
}
}
// Try to extract metadata values using multiple methods to handle XrayWrapper.
// The metadata bag is intentionally typed loosely here because Firefox's
// XrayWrapper occasionally surfaces extra/casing-variant keys we still
// want to read defensively.
// Try to extract metadata values using multiple methods to handle XrayWrapper
const getMetadataValue = (key: string, altKey?: string): any => {
const bag = metadata as unknown as Record<string, any>;
try {
const value = bag[key];
// Try direct access first
const value = metadata[key];
if (value !== undefined && value !== null) {
return value;
}
if (altKey) {
const altValue = bag[altKey];
const altValue = metadata[altKey];
if (altValue !== undefined && altValue !== null) {
return altValue;
}
}
// Try accessing via Object.keys iteration (works around XrayWrapper)
try {
const keys = Object.keys(bag);
const keys = Object.keys(metadata);
for (const k of keys) {
if (k === key || k === altKey) {
const val = bag[k];
const val = metadata[k];
if (val !== undefined && val !== null) {
return val;
}
}
}
} catch {
} catch (e) {
// Object.keys might fail on XrayWrapper, that's okay
}
return undefined;
@@ -225,218 +189,14 @@ export const actionMap: Record<string, ActionHandler<any>> = {
}) as ActionHandler<any>,
subjectassessment: ((item: IndexItem) => {
navigateToHashRoute(
`/assessments/${item.metadata.programme}:${item.metadata.subjectId}`,
);
window.location.href = `/#?page=/assessments/${item.metadata.programme}:${item.metadata.subjectId}`;
}) as ActionHandler<any>,
subjectcourse: ((item: IndexItem) => {
navigateToHashRoute(
`/courses/${item.metadata.programme}:${item.metadata.subjectId}`,
);
window.location.href = `/#?page=/courses/${item.metadata.programme}:${item.metadata.subjectId}`;
}) as ActionHandler<any>,
forum: ((item: IndexItem) => {
navigateToHashRoute(`/forums/${item.metadata.forumId}`);
}) as ActionHandler<any>,
course: ((item: IndexItem) => {
const programme = item.metadata?.programme;
const metaclass = item.metadata?.metaclass ?? item.metadata?.subjectId;
if (programme !== undefined && metaclass !== undefined) {
navigateToHashRoute(`/courses/${programme}:${metaclass}`);
return;
}
if (item.metadata?.route) {
navigateToHashRoute(String(item.metadata.route));
return;
}
navigateToHashRoute(FALLBACK_ROUTE);
}) as ActionHandler<any>,
notice: ((_item: IndexItem) => {
// SEQTA's notices route doesn't honour `&date=` from the hash, so just
// open the listing.
navigateToHashRoute("/notices");
}) as ActionHandler<any>,
document: ((_item: IndexItem) => {
// We don't trigger downloads automatically: opening the documents page
// gives users full SEQTA controls (preview, download, share) without
// needing the JWT-stamped streaming URL we deliberately avoid storing.
navigateToHashRoute("/documents");
}) as ActionHandler<any>,
folio: ((_item: IndexItem) => {
// SEQTA's folio SPA does not expose a per-id route; the previous
// `?page=/folios/read?id=N` shape contained a literal `?` inside the
// `page` query value and was unmatchable, which sent users to the
// dashboard. Always land on the read view and let the user pick.
navigateToHashRoute("/folios/read");
}) as ActionHandler<any>,
portal: ((item: IndexItem) => {
// SEQTA renders portals via the in-app viewer at `?page=/portals/<uuid>`
// (verified via the websiteskimmer capture). Prefer that so SSO/headers
// are preserved; only pop the external URL as a fallback if we don't
// have a UUID; final fallback to the dashboard rather than blanking.
const uuid = item.metadata?.portalUuid;
if (typeof uuid === "string" && uuid) {
navigateToHashRoute(`/portals/${uuid}`);
return;
}
const url = item.metadata?.url;
if (typeof url === "string" && url) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
navigateToHashRoute(FALLBACK_ROUTE);
}) as ActionHandler<any>,
report: ((_item: IndexItem) => {
navigateToHashRoute("/reports");
}) as ActionHandler<any>,
goal: ((item: IndexItem) => {
const year = item.metadata?.year;
if (year !== undefined) {
navigateToHashRoute(`/goals/${year}`);
} else {
navigateToHashRoute("/goals");
}
}) as ActionHandler<any>,
/**
* Routes for passively-captured items.
*
* The passive observer captures whatever `/seqta/student/...` JSON the
* page is fetching, so we can't trust a single category to imply a
* single SEQTA SPA route. Instead, derive the destination from the API
* route the entity came from, augmented with entity-shaped hints
* (programme/metaclass/year/uuid/...) that the observer hoists into
* metadata. We never replay the original POST: actions are user-driven
* and must stay safe even though the observer's own denylist excludes
* `save/*` and friends.
*/
passive: ((item: IndexItem) => {
const md = (item.metadata ?? {}) as Record<string, unknown>;
const route = typeof md.route === "string" ? (md.route as string) : "";
const sourcePage =
typeof md.sourcePage === "string" ? (md.sourcePage as string) : "";
const routeParts = route
.replace(/^\/seqta\/student\/?/, "")
.replace(/^load\//, "")
.split("/")
.filter(Boolean)
.map((part) => part.toLowerCase());
const tail = routeParts[0] ?? "";
const child = routeParts[1] ?? "";
const num = (key: string): number | undefined => {
const value = md[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value && Number.isFinite(Number(value))) {
return Number(value);
}
return undefined;
};
const str = (key: string): string | undefined => {
const value = md[key];
return typeof value === "string" && value ? value : undefined;
};
const programme = num("programme") ?? num("programmeId") ?? num("programmeID");
const metaclass =
num("metaclass") ?? num("metaclassId") ?? num("metaclassID");
const portalUuid = str("portalUuid") ?? str("uuid");
const forumId = num("forumId") ?? num("forum");
const year = num("year");
const assessmentId =
num("assessmentId") ?? num("assessmentID") ?? num("id");
const messageId = num("messageId");
if (sourcePage === "/messages") {
navigateInCurrentSeqtaApp("/messages");
return;
}
switch (tail) {
case "courses":
if (programme !== undefined && metaclass !== undefined) {
navigateToHashRoute(`/courses/${programme}:${metaclass}`);
return;
}
break;
case "assessments":
if (programme !== undefined && metaclass !== undefined) {
const itemSuffix =
assessmentId !== undefined ? `&item=${assessmentId}` : "";
navigateToHashRoute(
`/assessments/${programme}:${metaclass}${itemSuffix}`,
);
return;
}
if (assessmentId !== undefined) {
navigateToHashRoute(`/assessments/upcoming&item=${assessmentId}`);
return;
}
navigateToHashRoute("/assessments/upcoming");
return;
case "forums":
case "forum":
if (forumId !== undefined) {
navigateToHashRoute(`/forums/${forumId}`);
return;
}
break;
case "portals":
case "portal":
if (portalUuid) {
navigateToHashRoute(`/portals/${portalUuid}`);
return;
}
break;
case "goals":
case "goal":
navigateToHashRoute(year !== undefined ? `/goals/${year}` : "/goals");
return;
case "folio":
case "folios":
navigateToHashRoute("/folios/read");
return;
case "notices":
case "notice":
navigateToHashRoute("/notices");
return;
case "documents":
case "document":
navigateToHashRoute("/documents");
return;
case "reports":
case "report":
navigateToHashRoute("/reports");
return;
case "messages":
case "message":
// `/seqta/student/load/message/people` and related endpoints are
// only meaningful while SEQTA's message module is mounted. Use the
// same live hash navigation as the real message action instead of
// forcing a fresh bootstrap, which can drop back to dashboard for
// context-only endpoints.
void messageId; // noqa — preserved for future deep-select work
navigateInCurrentSeqtaApp("/messages");
return;
case "people":
if (route.includes("/load/message/people") || child === "people") {
navigateInCurrentSeqtaApp("/messages");
return;
}
break;
case "timetable":
navigateToHashRoute("/timetable");
return;
}
navigateToHashRoute(FALLBACK_ROUTE);
window.location.href = `/#?page=/forums/${item.metadata.forumId}`;
}) as ActionHandler<any>,
};
@@ -1,386 +0,0 @@
import { delay } from "@/seqta/utils/delay";
/**
* Shared SEQTA HTTP layer used by every indexing job.
*
* - All requests are same-origin POSTs against `/seqta/student/...` with
* `credentials: "include"` so they inherit the user's existing session.
* - Responses are parsed as JSON and lightly validated (status === "200" and
* payload present, mirroring the SEQTA convention).
* - Failures are retried with exponential backoff up to a configurable limit.
* - A simple per-route concurrency / spacing limiter prevents heavy jobs (e.g.
* per-subject course crawls) from hammering SEQTA.
*/
export interface SeqtaResponse<T = any> {
payload: T;
status: string;
}
export interface SeqtaFetchOptions {
/** Defaults to "POST". */
method?: "POST" | "GET";
/** Maximum number of retries for transient failures (default 2). */
retries?: number;
/** Initial backoff delay in ms (default 200). */
baseDelayMs?: number;
/** Hard cap on total request time in ms (default 20s). */
timeoutMs?: number;
/** AbortSignal for cancellation. */
signal?: AbortSignal;
/** Skip the routing limiter (rare; only for already-throttled callers). */
skipLimiter?: boolean;
}
const DEFAULT_RETRIES = 2;
const DEFAULT_BASE_DELAY = 200;
const DEFAULT_TIMEOUT = 20_000;
/* ------------------------------------------------------------------ */
/* limiter */
/* ------------------------------------------------------------------ */
/**
* Caps concurrent in-flight requests per normalized SEQTA route. Indexing
* jobs often fan out (e.g. one /load/courses per subject); we don't want them
* sending dozens of requests in parallel.
*/
class RouteLimiter {
private inFlight = new Map<string, number>();
private waiters = new Map<string, Array<() => void>>();
private readonly maxConcurrent: number;
constructor(maxConcurrent = 4) {
this.maxConcurrent = maxConcurrent;
}
async acquire(route: string): Promise<() => void> {
const current = this.inFlight.get(route) ?? 0;
if (current < this.maxConcurrent) {
this.inFlight.set(route, current + 1);
return () => this.release(route);
}
return new Promise((resolve) => {
const queue = this.waiters.get(route) ?? [];
queue.push(() => {
this.inFlight.set(route, (this.inFlight.get(route) ?? 0) + 1);
resolve(() => this.release(route));
});
this.waiters.set(route, queue);
});
}
private release(route: string) {
const next = (this.inFlight.get(route) ?? 1) - 1;
if (next <= 0) {
this.inFlight.delete(route);
} else {
this.inFlight.set(route, next);
}
const queue = this.waiters.get(route);
if (queue && queue.length > 0) {
const wake = queue.shift()!;
if (queue.length === 0) this.waiters.delete(route);
wake();
}
}
}
const routeLimiter = new RouteLimiter(4);
/* ------------------------------------------------------------------ */
/* route normalization */
/* ------------------------------------------------------------------ */
/**
* Strips the volatile anti-replay query token (e.g. `?mokx3qef`) so we can
* key caches and limiters off the canonical route.
*/
export function normalizeSeqtaPath(url: string): string {
try {
const parsed = new URL(url, location.origin);
// SEQTA appends a single random query token like `?mokx3qef`. Drop the
// entire query string so canonicalization is robust.
return parsed.pathname;
} catch {
// Fallback for already-relative URLs.
return url.split("?")[0];
}
}
/* ------------------------------------------------------------------ */
/* sensitive routes */
/* ------------------------------------------------------------------ */
/**
* Routes whose responses must never be indexed because they contain
* credentials, secrets, JWTs, or arbitrary configuration blobs.
*/
const SENSITIVE_PATH_PATTERNS: RegExp[] = [
/\/seqta\/student\/login(\b|\/)/i,
/\/seqta\/student\/save\//i,
/\/seqta\/student\/load\/settings(\b|\/)/i,
/\/seqta\/student\/load\/prefs(\b|\/)/i,
/\/seqta\/student\/heartbeat(\b|\/)/i,
/\/seqta\/student\/storage(\b|\/)/i,
/\/seqta\/student\/themes\//i,
/\/seqta\/student\/branding\//i,
/\/seqta\/student\/releasealert\//i,
/\/seqta\/student\/files\/stream(\b|\/)/i,
/\/seqta\/student\/load\/file(\b|\/)/i,
/\/seqta\/ta\/masquerade(\b|\/)/i,
];
export function isSensitiveSeqtaPath(path: string): boolean {
const normalized = normalizeSeqtaPath(path);
return SENSITIVE_PATH_PATTERNS.some((re) => re.test(normalized));
}
/* ------------------------------------------------------------------ */
/* student / user identity */
/* ------------------------------------------------------------------ */
interface SeqtaUserInfo {
id?: number;
personUUID?: string;
username?: string;
[key: string]: unknown;
}
let cachedUserInfo: SeqtaUserInfo | null = null;
let inflightUserInfo: Promise<SeqtaUserInfo | null> | null = null;
/**
* Resolves the current SEQTA user identity by re-using the same `login`
* handshake that the host page performs. This is the canonical way to
* discover the active student id and avoids the historical hard-coded
* `student: 69` placeholder that was incorrect on every real instance.
*
* Failures are intentionally NOT cached a transient login glitch on the
* very first call must not poison the cache for the lifetime of the page,
* because every subsequent indexing pass that needs the student id (e.g.
* the assignments job) would skip silently.
*/
export async function getCurrentUserInfo(): Promise<SeqtaUserInfo | null> {
if (cachedUserInfo) return cachedUserInfo;
if (inflightUserInfo) return inflightUserInfo;
inflightUserInfo = (async () => {
try {
const res = await fetch(`${location.origin}/seqta/student/login`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
mode: "normal",
query: null,
redirect_url: location.origin,
}),
});
if (!res.ok) return null;
const json = (await res.json()) as { payload?: SeqtaUserInfo };
const payload = json?.payload ?? null;
if (payload && typeof payload === "object") {
cachedUserInfo = payload;
return payload;
}
return null;
} catch (e) {
console.warn(
"[Global Search API] Failed to resolve current user info:",
e,
);
return null;
} finally {
inflightUserInfo = null;
}
})();
return inflightUserInfo;
}
/**
* Best-effort lookup of the active student id. Returns `undefined` when the
* value cannot be discovered (jobs should fall back gracefully rather than
* fabricating an id).
*/
export async function getCurrentStudentId(): Promise<number | undefined> {
const info = await getCurrentUserInfo();
const id = info?.id;
if (typeof id === "number" && Number.isFinite(id)) return id;
return undefined;
}
/* ------------------------------------------------------------------ */
/* core fetch */
/* ------------------------------------------------------------------ */
class SeqtaApiError extends Error {
status: number;
route: string;
constructor(message: string, status: number, route: string) {
super(message);
this.name = "SeqtaApiError";
this.status = status;
this.route = route;
}
}
function isTransientError(err: unknown): boolean {
if (err instanceof SeqtaApiError) {
if (err.status === 0 || err.status >= 500) return true;
if (err.status === 429) return true;
return false;
}
if (err instanceof TypeError) return true;
if ((err as any)?.name === "AbortError") return false;
return true;
}
/**
* Sends a JSON POST against a SEQTA route and returns the parsed envelope.
*
* - Adds `credentials: "include"` so requests reuse the active session.
* - Sets `X-Requested-With: XMLHttpRequest` so SEQTA classifies the request
* the same way as the first-party SPA (some routes 4xx without it).
* - Retries transient network/server errors with exponential backoff.
* - Validates that the response is JSON and has `status === "200"` (matches
* the SEQTA convention; jobs that need raw payloads can pass `path` but
* call `seqtaFetch` directly via the underlying API if they need to).
*/
export async function seqtaFetchJson<T = any>(
path: string,
body: Record<string, unknown> | undefined = {},
options: SeqtaFetchOptions = {},
): Promise<SeqtaResponse<T>> {
const route = normalizeSeqtaPath(path);
const retries = Math.max(0, options.retries ?? DEFAULT_RETRIES);
const baseDelay = Math.max(50, options.baseDelayMs ?? DEFAULT_BASE_DELAY);
const timeoutMs = Math.max(1_000, options.timeoutMs ?? DEFAULT_TIMEOUT);
let release: (() => void) | null = null;
if (!options.skipLimiter) {
release = await routeLimiter.acquire(route);
}
try {
let attempt = 0;
let lastError: unknown = null;
while (attempt <= retries) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const onAbort = () => controller.abort();
if (options.signal) {
if (options.signal.aborted) controller.abort();
else options.signal.addEventListener("abort", onAbort, { once: true });
}
try {
const res = await fetch(`${location.origin}${route}`, {
method: options.method ?? "POST",
credentials: "include",
headers: {
"Content-Type": "application/json; charset=utf-8",
"X-Requested-With": "XMLHttpRequest",
Accept: "text/javascript, text/html, application/xml, text/xml, */*",
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
throw new SeqtaApiError(
`HTTP ${res.status} ${res.statusText} for ${route}`,
res.status,
route,
);
}
const rawJson = (await res.json()) as unknown;
if (!rawJson || typeof rawJson !== "object") {
throw new SeqtaApiError(
`Invalid SEQTA response (not a JSON object) for ${route}`,
res.status,
route,
);
}
// SEQTA's "envelope" convention is `{ status, payload }`, but in
// practice some endpoints — notably `/seqta/student/load/subjects`
// and `/seqta/student/assessment/list/*` — occasionally return
// either a bare array or an envelope with a non-"200" status.
// Strict validation here was historically silently killing the
// assignments + courses indexing pipelines when those endpoints
// returned a quirky shape, so we normalize permissively and let
// callers handle missing/empty payloads.
let json: SeqtaResponse<T>;
if (Array.isArray(rawJson)) {
json = { payload: rawJson as unknown as T, status: "200" };
} else {
const obj = rawJson as Record<string, unknown>;
const hasEnvelopeKey = "payload" in obj || "status" in obj;
if (hasEnvelopeKey) {
json = {
payload: ("payload" in obj ? obj.payload : undefined) as T,
status:
typeof obj.status === "string"
? obj.status
: typeof obj.status === "number"
? String(obj.status)
: "200",
};
} else {
json = { payload: rawJson as unknown as T, status: "200" };
}
}
if (json.status && json.status !== "200") {
console.warn(
`[Global Search API] Non-200 SEQTA status "${json.status}" for ${route} — returning payload anyway`,
);
}
return json;
} catch (err) {
lastError = err;
if (!isTransientError(err) || attempt === retries) {
throw err;
}
const wait = Math.min(5_000, baseDelay * Math.pow(2, attempt));
await delay(wait);
attempt++;
} finally {
clearTimeout(timer);
if (options.signal) options.signal.removeEventListener("abort", onAbort);
}
}
throw lastError ?? new Error(`seqtaFetchJson exhausted retries for ${route}`);
} finally {
if (release) release();
}
}
/**
* Convenience helper: fetch and unwrap `.payload` directly. Returns `null`
* on failure rather than throwing, so jobs can use the value optionally.
*/
export async function seqtaFetchPayload<T = any>(
path: string,
body: Record<string, unknown> | undefined = {},
options: SeqtaFetchOptions = {},
): Promise<T | null> {
try {
const res = await seqtaFetchJson<T>(path, body, options);
return res.payload ?? null;
} catch (e) {
console.warn(
`[Global Search API] Request to ${normalizeSeqtaPath(path)} failed:`,
e,
);
return null;
}
}
@@ -184,56 +184,6 @@ export async function put(
}
}
/**
* Apply puts and deletes in a single readwrite transaction.
*/
export async function applyStoreDiff(
store: string,
puts: Array<{ key: string; value: any }>,
removeKeys: string[],
): Promise<void> {
if (puts.length === 0 && removeKeys.length === 0) return;
try {
const db = await openDB();
if (!db.objectStoreNames.contains(store)) {
await upgradeDB(store);
const upgradedDb = await openDB();
await runStoreDiffTransaction(upgradedDb, store, puts, removeKeys);
return;
}
await runStoreDiffTransaction(db, store, puts, removeKeys);
} catch (error) {
console.error(`Error in applyStoreDiff for store ${store}:`, error);
throw error;
}
}
function runStoreDiffTransaction(
db: IDBDatabase,
store: string,
puts: Array<{ key: string; value: any }>,
removeKeys: string[],
): Promise<void> {
return new Promise((resolve, reject) => {
const tx = db.transaction(store, "readwrite");
const objectStore = tx.objectStore(store);
for (const key of removeKeys) {
objectStore.delete(key);
}
for (const { key, value } of puts) {
objectStore.put(value, key);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
export async function remove(store: string, key: string): Promise<void> {
try {
const s = await getStore(store, "readwrite");
@@ -1,303 +0,0 @@
import { htmlToPlainText } from "./utils";
import type { IndexItem } from "./types";
/**
* Safe extraction helpers used by both active SEQTA jobs and the passive
* network observer.
*
* The goal is to take arbitrary SEQTA JSON / embedded HTML fragments and
* derive concise, redacted, search-friendly text without ever indexing
* obvious credentials, tokens, JWTs, or large binary blobs.
*/
/* ------------------------------------------------------------------ */
/* sensitive keys */
/* ------------------------------------------------------------------ */
/**
* Field names whose values should never be indexed regardless of context.
* Matches SEQTA's frequently-used credential / config keys plus generic
* security-related names. Comparison is case-insensitive and matches both
* the full key and any sub-string fragments (so `client_secret`,
* `apiKey`, `dropboxKey` all hit).
*/
const SENSITIVE_KEY_FRAGMENTS: readonly string[] = [
"password",
"passwd",
"pwd",
"secret",
"token",
"jwt",
"session",
"cookie",
"auth",
"apikey",
"api_key",
"clientid",
"client_id",
"clientsecret",
"client_secret",
"credential",
"private",
"salt",
"hash",
"csrf",
"x-api",
"bearer",
"dropbox",
"oauth",
"signature",
];
export function isSensitiveKey(key: string): boolean {
if (!key) return false;
const lower = key.toLowerCase();
return SENSITIVE_KEY_FRAGMENTS.some((frag) => lower.includes(frag));
}
/**
* Returns true if the supplied scalar value looks credential-shaped: a long
* hex/base64-like blob that doesn't decode to readable text. This catches
* arbitrary tokens that don't have a clear field-name signal.
*/
export function looksLikeSecretValue(value: unknown): boolean {
if (typeof value !== "string") return false;
const trimmed = value.trim();
if (trimmed.length < 32) return false;
// Long contiguous base64 / hex with no whitespace and no humanish punctuation.
if (/\s/.test(trimmed)) return false;
if (/^[A-Za-z0-9+/=._-]{32,}$/.test(trimmed) && !/[.,!?]/.test(trimmed)) {
// Reject obvious URLs and UUIDs (they're useful and not secret).
if (/^https?:\/\//i.test(trimmed)) return false;
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
trimmed,
)
) {
return false;
}
return true;
}
// JWT detection: three base64url segments separated by dots.
if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(trimmed)) {
return true;
}
return false;
}
/* ------------------------------------------------------------------ */
/* text extraction */
/* ------------------------------------------------------------------ */
/**
* Recursively pulls human-readable text out of an arbitrary JSON value.
*
* - HTML strings are passed through `htmlToPlainText`.
* - Sensitive keys and secret-shaped values are skipped.
* - Long blobs are truncated to keep the index lean.
* - Arrays and objects are walked; depth is bounded to avoid pathological
* structures.
*/
export interface ExtractTextOptions {
/** Hard cap on combined characters across the walk (default 4000). */
maxChars?: number;
/** Maximum recursion depth (default 6). */
maxDepth?: number;
/** Maximum array length to traverse (default 200). */
maxArrayItems?: number;
/** Skip individual string values longer than this (default 8000). */
maxStringLength?: number;
}
const DEFAULT_EXTRACT_OPTIONS: Required<ExtractTextOptions> = {
maxChars: 4000,
maxDepth: 6,
maxArrayItems: 200,
maxStringLength: 8000,
};
export function extractTextFromValue(
value: unknown,
options: ExtractTextOptions = {},
): string {
const opts = { ...DEFAULT_EXTRACT_OPTIONS, ...options };
const parts: string[] = [];
let remaining = opts.maxChars;
const push = (text: string) => {
if (!text || remaining <= 0) return;
const trimmed = text.trim();
if (!trimmed) return;
const slice = trimmed.length > remaining ? trimmed.slice(0, remaining) : trimmed;
parts.push(slice);
remaining -= slice.length + 1;
};
const walk = (node: unknown, depth: number, parentKey: string | null) => {
if (remaining <= 0) return;
if (node === null || node === undefined) return;
if (parentKey && isSensitiveKey(parentKey)) return;
if (typeof node === "string") {
if (node.length > opts.maxStringLength) return;
if (looksLikeSecretValue(node)) return;
if (node.includes("<") && node.includes(">")) {
push(htmlToPlainText(node));
} else {
push(node);
}
return;
}
if (typeof node === "number" || typeof node === "boolean") {
// Numbers/booleans rarely contribute to search recall; skip to keep
// the index focused on text.
return;
}
if (depth >= opts.maxDepth) return;
if (Array.isArray(node)) {
const limit = Math.min(node.length, opts.maxArrayItems);
for (let i = 0; i < limit; i++) {
walk(node[i], depth + 1, parentKey);
if (remaining <= 0) return;
}
return;
}
if (typeof node === "object") {
for (const [key, child] of Object.entries(node as Record<string, unknown>)) {
if (remaining <= 0) return;
if (isSensitiveKey(key)) continue;
walk(child, depth + 1, key);
}
}
};
walk(value, 0, null);
return parts.join("\n").trim();
}
/* ------------------------------------------------------------------ */
/* redacted clones */
/* ------------------------------------------------------------------ */
/**
* Returns a deep clone of `value` with sensitive keys/values stripped. The
* passive observer uses this when persisting metadata so we never store
* raw tokens or settings blobs in IndexedDB.
*/
export function redactSensitive<T>(value: T, depth = 0): T {
if (value === null || value === undefined) return value;
if (depth >= 8) return value;
if (Array.isArray(value)) {
return value
.slice(0, 200)
.map((v) => redactSensitive(v, depth + 1)) as unknown as T;
}
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
if (isSensitiveKey(key)) continue;
if (typeof child === "string" && looksLikeSecretValue(child)) continue;
out[key] = redactSensitive(child, depth + 1);
}
return out as T;
}
if (typeof value === "string" && looksLikeSecretValue(value)) {
return "" as unknown as T;
}
return value;
}
/* ------------------------------------------------------------------ */
/* title / id heuristics */
/* ------------------------------------------------------------------ */
const TITLE_KEYS = [
"title",
"subject",
"name",
"label",
"heading",
"displayName",
"filename",
"code",
];
const ID_KEYS = ["id", "uuid", "messageID", "assessmentID", "notificationID"];
/**
* Best-effort title extraction: returns the first sensible string-valued
* field commonly used by SEQTA payloads. Falls back to an empty string when
* none are present.
*/
export function pickTitle(node: unknown, fallback = ""): string {
if (!node || typeof node !== "object") return fallback;
const obj = node as Record<string, unknown>;
for (const key of TITLE_KEYS) {
const v = obj[key];
if (typeof v === "string" && v.trim()) return v.trim();
}
return fallback;
}
export function pickId(node: unknown, fallback = ""): string {
if (!node || typeof node !== "object") return fallback;
const obj = node as Record<string, unknown>;
for (const key of ID_KEYS) {
const v = obj[key];
if (typeof v === "string" && v.trim()) return v.trim();
if (typeof v === "number" && Number.isFinite(v)) return String(v);
}
return fallback;
}
/* ------------------------------------------------------------------ */
/* IndexItem builders */
/* ------------------------------------------------------------------ */
/**
* Constructs an `IndexItem` from a raw entity, applying our standard
* extraction rules. Callers fill in the things that need domain knowledge
* (`category`, `actionId`, `metadata`, deep-link route hints) and we handle
* the boring text + redaction work.
*/
export function buildIndexItem(input: {
id: string;
text: string;
category: string;
rawForContent?: unknown;
contentOverride?: string;
metadata?: Record<string, unknown>;
actionId: string;
renderComponentId: string;
dateAdded?: number;
contentMaxChars?: number;
}): IndexItem {
const content =
input.contentOverride !== undefined
? input.contentOverride
: extractTextFromValue(input.rawForContent, {
maxChars: input.contentMaxChars ?? 1500,
});
const metadata = input.metadata ? redactSensitive(input.metadata) : {};
return {
id: input.id,
text: input.text,
category: input.category,
content,
dateAdded: input.dateAdded ?? Date.now(),
metadata,
actionId: input.actionId,
renderComponentId: input.renderComponentId,
};
}
@@ -1,11 +1,10 @@
import { applyStoreDiff, get, getAll, put, remove, resetDatabase } from "./db";
import { clear, get, getAll, put, remove } from "./db";
import { jobs } from "./jobs";
import { decorateIndexItems } from "./renderComponents";
import { renderComponentMap } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types";
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
import { loadDynamicItems } from "../utils/dynamicItems";
import { getVectorizedItemIds, pruneOrphanVectorEmbeddings } from "./utils";
import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion";
import { getVectorizedItemIds } from "./utils";
const META_STORE = "meta";
const LOCK_KEY = "bsq-indexer-lock";
@@ -13,50 +12,6 @@ const HEARTBEAT_INTERVAL = 10000;
const LOCK_TIMEOUT = 20000;
const LOCK_ACQUIRE_TIMEOUT = 5000;
let schemaCheckPromise: Promise<void> | null = null;
async function ensureSchemaCurrent(): Promise<void> {
if (schemaCheckPromise) return schemaCheckPromise;
schemaCheckPromise = (async () => {
let storedRaw: string | null = null;
try {
storedRaw = localStorage.getItem(SCHEMA_VERSION_KEY);
} catch {
return;
}
const stored = storedRaw ? parseInt(storedRaw, 10) : 0;
if (stored === INDEX_SCHEMA_VERSION) return;
console.warn(
`[Indexer] Schema version changed (${stored} -> ${INDEX_SCHEMA_VERSION}); resetting structured + vector indexes.`,
);
try {
await resetDatabase();
} catch (e) {
console.warn("[Indexer] Failed to reset structured database:", e);
}
try {
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase("embeddiaDB");
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
} catch (e) {
console.warn("[Indexer] Failed to reset embeddiaDB:", e);
}
try {
localStorage.setItem(SCHEMA_VERSION_KEY, String(INDEX_SCHEMA_VERSION));
} catch {
/* ignore */
}
})();
return schemaCheckPromise;
}
/* ─────────── Progressmeta helpers ─────────── */
async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
const rec = await get(META_STORE, `progress:${jobId}`);
@@ -89,64 +44,12 @@ function shouldRun(job: Job, lastRun?: number): boolean {
}
function getLastRunMeta(jobId: string): Promise<number | undefined> {
return get(META_STORE, jobId).then((rec) => rec?.lastRun);
}
function indexItemStorageKey(item: IndexItem): string {
return JSON.stringify({
id: item.id,
text: item.text,
category: item.category,
content: item.content,
dateAdded: item.dateAdded,
metadata: item.metadata,
actionId: item.actionId,
renderComponentId: item.renderComponentId,
return getAll(META_STORE).then((metaItems) => {
const match = metaItems.find((m: any) => m.jobId === jobId);
return match?.lastRun;
});
}
function indexItemsEqual(a: IndexItem, b: IndexItem): boolean {
return indexItemStorageKey(a) === indexItemStorageKey(b);
}
async function diffAndStoreItems(
targetStore: string,
items: IndexItem[],
): Promise<void> {
const validItems = items.filter((i) => i && i.id);
if (validItems.length !== items.length) {
console.warn(
`[Indexer] Filtered out ${items.length - validItems.length} invalid items before storing in '${targetStore}'.`,
);
}
const existing = (await getAll(targetStore)) as IndexItem[];
const existingMap = new Map(
existing.filter((i) => i?.id).map((i) => [i.id, i]),
);
const newMap = new Map(validItems.map((i) => [i.id, i]));
const puts: Array<{ key: string; value: IndexItem }> = [];
const removeKeys: string[] = [];
for (const [id, item] of newMap) {
const prev = existingMap.get(id);
if (!prev || !indexItemsEqual(prev, item)) {
puts.push({ key: id, value: item });
}
}
for (const id of existingMap.keys()) {
if (!newMap.has(id)) {
removeKeys.push(id);
}
}
if (puts.length > 0 || removeKeys.length > 0) {
await applyStoreDiff(targetStore, puts, removeKeys);
}
}
async function updateLastRunMeta(jobId: string): Promise<void> {
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
}
@@ -259,8 +162,6 @@ export async function loadAllStoredItems(): Promise<IndexItem[]> {
}
export async function runIndexing(): Promise<void> {
await ensureSchemaCurrent();
if (!(await acquireLock())) {
console.debug(
"%c[Indexer] Could not acquire lock - another tab is indexing or this tab is already indexing",
@@ -272,12 +173,13 @@ export async function runIndexing(): Promise<void> {
startHeartbeat();
console.debug("%c[Indexer] Starting indexing...", "color: green");
try {
const jobIds = Object.keys(jobs);
let completedJobs = 0;
const totalSteps = jobIds.length + 1;
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs");
let hasStreamingJobs = false;
for (const jobId of jobIds) {
dispatchProgress(
completedJobs,
@@ -307,7 +209,14 @@ export async function runIndexing(): Promise<void> {
await getAll(storeId ?? jobId);
const setStoredItems = async (items: IndexItem[], storeId?: string) => {
const targetStore = storeId ?? jobId;
await diffAndStoreItems(targetStore, items);
await clear(targetStore);
const validItems = items.filter((i) => i && i.id);
if (validItems.length !== items.length) {
console.warn(
`[Indexer Job ${jobId} -> Store ${targetStore}] Filtered out ${items.length - validItems.length} invalid items before storing.`,
);
}
await Promise.all(validItems.map((i) => put(targetStore, i, i.id)));
};
const addItem = async (item: IndexItem, storeId?: string) => {
const targetStore = storeId ?? jobId;
@@ -346,6 +255,10 @@ export async function runIndexing(): Promise<void> {
await setStoredItems(merged);
await updateLastRunMeta(jobId);
if (jobId === 'messages' || jobId === 'notifications') {
hasStreamingJobs = true;
}
console.debug(
`%c[Indexer] ${job.label}: ${newItemsRaw.length} new items reported by run, ${merged.length} total items now in '${jobId}' store.`,
"color: #00c46f",
@@ -366,17 +279,6 @@ export async function runIndexing(): Promise<void> {
let allItemsInPrimaryStores = await loadAllStoredItems();
const liveItemIds = new Set(allItemsInPrimaryStores.map((item) => item.id));
const prunedCount = await pruneOrphanVectorEmbeddings(liveItemIds);
if (prunedCount > 0) {
try {
const { refreshVectorCache } = await import("../search/vector/vectorSearch");
await refreshVectorCache();
} catch (e) {
console.warn("[Indexer] Failed to refresh vector cache after prune:", e);
}
}
if (allItemsInPrimaryStores.length > 0) {
console.debug(
`%c[Indexer] Checking ${allItemsInPrimaryStores.length} items for vectorization...`,
@@ -491,17 +393,38 @@ export async function runIndexing(): Promise<void> {
);
}
stopHeartbeat();
allItemsInPrimaryStores = await loadAllStoredItems();
const itemsWithComponents = decorateIndexItems(allItemsInPrimaryStores);
// Create new objects to avoid XrayWrapper issues in Firefox
const itemsWithComponents = allItemsInPrimaryStores.map(item => {
try {
const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) {
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
} else if (renderComponentMap[item.renderComponentId]) {
renderComponent = renderComponentMap[item.renderComponentId];
}
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
// Use JSON serialization to ensure all nested properties are accessible
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch (e) {
// Fallback to shallow copy if deep clone fails
console.warn("[Indexer] Failed to deep clone item, using shallow copy:", e);
return { ...item, renderComponent };
}
} catch (error) {
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
console.warn("[Indexer] Failed to add render component to item (Firefox XrayWrapper):", error);
return item;
}
});
loadDynamicItems(itemsWithComponents);
window.dispatchEvent(
new CustomEvent("dynamic-items-updated", {
detail: { fullRebuild: true },
}),
);
} finally {
stopHeartbeat();
}
window.dispatchEvent(new Event("dynamic-items-updated"));
}
function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] {
@@ -4,14 +4,6 @@ import { notificationsJob } from "./jobs/notifications";
import { forumsJob } from "./jobs/forums";
import { subjectsJob } from "./jobs/subjects";
import { assignmentsJob } from "./jobs/assignments";
import { coursesJob } from "./jobs/courses";
import { noticesJob } from "./jobs/notices";
import { documentsJob } from "./jobs/documents";
import { folioJob } from "./jobs/folio";
import { portalsJob } from "./jobs/portals";
import { reportsJob } from "./jobs/reports";
import { goalsJob } from "./jobs/goals";
import { passiveJob } from "./jobs/passive";
export const jobs: Record<string, Job> = {
messages: messagesJob,
@@ -19,12 +11,4 @@ export const jobs: Record<string, Job> = {
forums: forumsJob,
subjects: subjectsJob,
assignments: assignmentsJob,
courses: coursesJob,
notices: noticesJob,
documents: documentsJob,
folio: folioJob,
portals: portalsJob,
reports: reportsJob,
goals: goalsJob,
passive: passiveJob,
};
@@ -37,7 +37,7 @@ const fetchSubjects = async () => {
const fetchPastAssessments = async (student: number = 69, subjects: any[]) => {
const map: Record<number, any> = {};
// Fetch past assessments for all subjects in parallel (like assessmentsOverview does)
// This is much faster than sequential fetching
await Promise.all(
@@ -49,7 +49,7 @@ const fetchPastAssessments = async (student: number = 69, subjects: any[]) => {
metaclass: subject.metaclass,
student,
});
// Past assessments API can return data in payload.tasks OR payload.pending (or both)
// Based on analytics.rs fetch_past_assessments, we need to check both arrays
const processAssessment = (assessment: any) => {
@@ -65,13 +65,13 @@ const fetchPastAssessments = async (student: number = 69, subjects: any[]) => {
};
}
};
// Match analytics.rs: Check both pending and tasks arrays
// Check for pending array first (matching Rust code order)
if (res.payload?.pending && Array.isArray(res.payload.pending)) {
res.payload.pending.forEach(processAssessment);
}
// Check for tasks array
if (res.payload?.tasks && Array.isArray(res.payload.tasks)) {
res.payload.tasks.forEach(processAssessment);
@@ -81,7 +81,7 @@ const fetchPastAssessments = async (student: number = 69, subjects: any[]) => {
}
})
);
return Object.values(map);
};
@@ -127,7 +127,7 @@ export const assignmentsJob: Job = {
const existingIds = new Set(existingItems.map((i) => i.id));
const student = 69; // TODO: Get from context if available
console.debug("[Assignments job] Starting indexing - fetching all assessments (upcoming and past)...");
// Fetch data in parallel
@@ -1,179 +0,0 @@
import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api";
import { buildIndexItem } from "../extract";
import { htmlToPlainText } from "../utils";
/**
* Indexes per-subject course content from `/seqta/student/load/courses`.
*
* The course payload contains the lesson grid in `w[][]` where each cell's
* `l` field is a (possibly empty) HTML snippet authored by teachers. We
* concatenate these into searchable text per course, plus the course title
* and code from `t` / `c`. Embedded files referenced via TED/SEQTA URLs are
* preserved as plain-text links so users can find them by URL fragment.
*/
interface SubjectsListPayload {
code: string;
description?: string;
active: number;
subjects: Array<{
code: string;
title?: string;
description?: string;
metaclass: number;
programme: number;
}>;
}
interface CoursePayload {
c?: string;
t?: string;
i?: number;
m?: number;
w?: Array<Array<{ l?: string; h?: string; t?: string; o?: string; i?: number }>>;
document?: string;
}
const fetchActiveSubjects = async (): Promise<
SubjectsListPayload["subjects"]
> => {
const payload = await seqtaFetchPayload<SubjectsListPayload[]>(
"/seqta/student/load/subjects",
{},
);
if (!Array.isArray(payload)) return [];
const out: SubjectsListPayload["subjects"] = [];
for (const semester of payload) {
if (!semester || !Array.isArray(semester.subjects)) continue;
if (semester.active !== 1) continue;
for (const subject of semester.subjects) {
if (
subject &&
Number.isFinite(subject.programme) &&
Number.isFinite(subject.metaclass)
) {
out.push(subject);
}
}
}
return out;
};
function flattenLessonHtml(payload: CoursePayload): string {
if (!Array.isArray(payload.w)) return "";
const fragments: string[] = [];
for (const row of payload.w) {
if (!Array.isArray(row)) continue;
for (const cell of row) {
if (!cell) continue;
if (typeof cell.l === "string" && cell.l.trim()) {
fragments.push(cell.l);
}
if (typeof cell.h === "string" && cell.h.trim()) {
fragments.push(cell.h);
}
if (typeof cell.t === "string" && cell.t.trim()) {
fragments.push(cell.t);
}
if (typeof cell.o === "string" && cell.o.trim()) {
fragments.push(cell.o);
}
}
}
if (fragments.length === 0) return "";
return htmlToPlainText(fragments.join("\n"));
}
export const coursesJob: Job = {
id: "courses",
label: "Courses",
renderComponentId: "course",
// Course content rarely changes minute-to-minute but does evolve per term.
// Refresh once per day (after pageLoad cool-down) to keep new lessons
// discoverable without hammering SEQTA.
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 },
boostCriteria: (item, searchTerm) => {
if (!searchTerm) return -50;
let score = 0;
if (item.metadata?.subjectCode) score += 0.05;
if (item.metadata?.isActive) score += 0.02;
return score;
},
run: async (_ctx) => {
const subjects = await fetchActiveSubjects();
if (subjects.length === 0) {
console.debug("[Courses job] No active subjects discovered.");
return [];
}
const items: IndexItem[] = [];
const seenIds = new Set<string>();
// Sequential per-subject fetch keeps load on SEQTA bounded; the shared
// API layer also limits concurrency per route as a defense in depth.
for (const subject of subjects) {
const id = `course-${subject.programme}-${subject.metaclass}`;
if (seenIds.has(id)) continue;
seenIds.add(id);
const payload = await seqtaFetchPayload<CoursePayload>(
"/seqta/student/load/courses",
{
programme: String(subject.programme),
metaclass: String(subject.metaclass),
},
);
if (!payload) continue;
const title =
(typeof payload.t === "string" && payload.t.trim()) ||
subject.title ||
subject.description ||
subject.code ||
"Course";
const lessonText = flattenLessonHtml(payload);
const courseCode =
(typeof payload.c === "string" && payload.c.trim()) || subject.code;
const summary = [courseCode, lessonText]
.filter((s) => s && s.length > 0)
.join("\n")
.slice(0, 4000);
items.push(
buildIndexItem({
id,
text: title,
category: "courses",
contentOverride: summary || `Course content for ${title}`,
metadata: {
subjectCode: subject.code,
subjectName: subject.title ?? title,
programme: subject.programme,
metaclass: subject.metaclass,
courseCode,
isActive: true,
route: `/courses/${subject.programme}:${subject.metaclass}`,
entityType: "course",
icon: "\ueb4d",
},
actionId: "course",
renderComponentId: "course",
}),
);
}
console.debug(
`[Courses job] Indexed ${items.length} courses across ${subjects.length} subjects.`,
);
return items;
},
purge: (items) => items,
};
@@ -1,139 +0,0 @@
import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api";
/**
* Indexes file metadata from `/seqta/student/load/documents`.
*
* Each top-level entry is a category containing one or more documents
* (`docs[]`). We capture the human-readable title, filename, mimetype, and
* stable UUID/category for every doc, but never download or index the
* binary content itself - the document streaming endpoint uses one-time
* JWTs that are unsafe to persist or replay.
*/
interface DocumentEntry {
file?: number | string;
filename?: string;
size?: string | number;
context_uuid?: string;
mimetype?: string;
created_date?: string;
title?: string;
uuid?: string;
created_by?: string;
}
interface DocumentCategory {
id: number | string;
category: string;
colour?: string;
docs: DocumentEntry[];
}
function prettySize(size: string | number | undefined): string | null {
if (size === undefined || size === null) return null;
const bytes = typeof size === "string" ? parseInt(size, 10) : size;
if (!Number.isFinite(bytes) || bytes <= 0) return null;
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return `${value.toFixed(value < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
function describeMime(mime: string | undefined): string | null {
if (!mime) return null;
if (mime.startsWith("application/pdf")) return "PDF";
if (mime.includes("officedocument.wordprocessingml")) return "Word";
if (mime.includes("officedocument.spreadsheetml")) return "Excel";
if (mime.includes("officedocument.presentationml")) return "PowerPoint";
if (mime.startsWith("image/")) return "Image";
if (mime.startsWith("video/")) return "Video";
if (mime.startsWith("audio/")) return "Audio";
return null;
}
export const documentsJob: Job = {
id: "documents",
label: "Documents",
renderComponentId: "document",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 12 }, // 12 hours
boostCriteria: (_item, searchTerm) => {
if (!searchTerm) return -20;
return 0;
},
run: async (_ctx) => {
const payload = await seqtaFetchPayload<DocumentCategory[] | null>(
"/seqta/student/load/documents",
{},
);
if (!Array.isArray(payload)) return [];
const items: IndexItem[] = [];
const seen = new Set<string>();
for (const category of payload) {
if (!category || !Array.isArray(category.docs)) continue;
for (const doc of category.docs) {
const uuid = doc.uuid || doc.context_uuid;
if (!uuid && !doc.file) continue;
const id = `document-${uuid ?? doc.file}`;
if (seen.has(id)) continue;
seen.add(id);
const title =
doc.title?.trim() ||
doc.filename?.trim() ||
`Document ${doc.file ?? uuid}`;
const sizeText = prettySize(doc.size);
const mimeLabel = describeMime(doc.mimetype);
const contentParts: string[] = [];
if (doc.filename && doc.filename !== title) contentParts.push(doc.filename);
if (category.category) contentParts.push(`Category: ${category.category}`);
if (mimeLabel) contentParts.push(mimeLabel);
if (sizeText) contentParts.push(sizeText);
if (doc.created_date) contentParts.push(`Added ${doc.created_date}`);
const dateAdded = doc.created_date
? new Date(doc.created_date).getTime() || Date.now()
: Date.now();
items.push({
id,
text: title,
category: "documents",
content: contentParts.join(" \u2022 "),
dateAdded,
metadata: {
documentUuid: uuid,
fileId: doc.file,
filename: doc.filename,
mimetype: doc.mimetype,
sizeBytes:
typeof doc.size === "string" ? parseInt(doc.size, 10) : doc.size,
categoryId: category.id,
categoryName: category.category,
createdDate: doc.created_date,
entityType: "document",
route: "/documents",
icon: "\ueb6f",
},
actionId: "document",
renderComponentId: "document",
});
}
}
console.debug(`[Documents job] Indexed ${items.length} document entries.`);
return items;
},
purge: (items) => items,
};
@@ -1,134 +0,0 @@
import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api";
import { htmlToPlainText } from "../utils";
import { delay } from "@/seqta/utils/delay";
/**
* Indexes student folio entries from `/seqta/student/folio`.
*
* The list mode returns `{ me, list: [{ id, title, published, student }] }`,
* and the load mode returns the full body via `{ contents, files, ... }`.
* Folio bodies frequently contain `[[embed:raw|<html>]]` blocks which we
* normalize to plain text before indexing - the htmlToPlainText sanitizer
* never executes scripts because it parses into an inert document.
*/
interface FolioListPayload {
me?: string;
list?: Array<{
id: number | string;
title?: string;
published?: string;
student?: string;
}>;
}
interface FolioEntryPayload {
forum?: number;
contents?: string;
created?: string;
allow_comments?: boolean;
author?: { name?: string; year?: string; id?: number };
files?: unknown[];
id?: number | string;
published?: string;
title?: string;
updated?: string;
}
const PER_ITEM_DELAY_MS = 80;
function stripEmbedRaw(text: string): string {
if (!text) return "";
return text.replace(/\[\[embed:raw\|([\s\S]*?)\]\]/g, (_match, inner) => {
return htmlToPlainText(typeof inner === "string" ? inner : "");
});
}
export const folioJob: Job = {
id: "folio",
label: "Folio",
renderComponentId: "folio",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 },
boostCriteria: (_item, searchTerm) => {
if (!searchTerm) return -30;
return 0;
},
run: async (ctx) => {
const stored = await ctx.getStoredItems("folio");
const existing = new Map(stored.map((i) => [i.id, i]));
const list = await seqtaFetchPayload<FolioListPayload | null>(
"/seqta/student/folio",
{ mode: "list", page: 0, filters: {} },
);
if (!list || !Array.isArray(list.list)) return [];
const items: IndexItem[] = [];
for (const entry of list.list) {
if (!entry || entry.id === undefined) continue;
const id = `folio-${entry.id}`;
const dateAdded = entry.published
? new Date(entry.published).getTime() || Date.now()
: Date.now();
// If we already have this folio and the title hasn't changed, reuse
// the stored content instead of paying for another /folio?mode=load.
const existingItem = existing.get(id);
const titleChanged = existingItem && existingItem.text !== (entry.title ?? "");
if (existingItem && !titleChanged) {
items.push({
...existingItem,
dateAdded,
});
continue;
}
try {
const detail = await seqtaFetchPayload<FolioEntryPayload | null>(
"/seqta/student/folio",
{ mode: "load", id: entry.id },
);
const rawContents = detail?.contents ?? "";
const flattened = stripEmbedRaw(rawContents);
const content = flattened.slice(0, 4000);
items.push({
id,
text: entry.title?.trim() || `Folio ${entry.id}`,
category: "folio",
content,
dateAdded,
metadata: {
folioId: entry.id,
student: list.me ?? entry.student,
publishedAt: entry.published,
updatedAt: detail?.updated,
createdAt: detail?.created,
authorName: detail?.author?.name,
authorId: detail?.author?.id,
forumId: detail?.forum,
allowComments: detail?.allow_comments,
fileCount: Array.isArray(detail?.files) ? detail!.files!.length : 0,
entityType: "folio",
route: "/folios/read",
icon: "\ueb16",
},
actionId: "folio",
renderComponentId: "folio",
});
} catch (e) {
console.warn(`[Folio job] Failed to load folio ${entry.id}:`, e);
}
await delay(PER_ITEM_DELAY_MS);
}
console.debug(`[Folio job] Indexed ${items.length} folio entries.`);
return items;
},
purge: (items) => items,
};
@@ -1,109 +0,0 @@
import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api";
import { extractTextFromValue } from "../extract";
import { delay } from "@/seqta/utils/delay";
/**
* Indexes student goals from `/seqta/student/load/goals`.
*
* The endpoint exposes `mode: "years"` which returns the list of available
* years and `mode: "list"` (per-year) which returns the actual goals. We
* gracefully degrade if the school has goals disabled (the years payload
* is empty in that case).
*/
interface GoalEntry {
id?: number | string;
uuid?: string;
title?: string;
description?: string;
status?: string;
year?: number | string;
created?: string;
updated?: string;
}
const PER_YEAR_DELAY_MS = 80;
export const goalsJob: Job = {
id: "goals",
label: "Goals",
renderComponentId: "goal",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 * 3 }, // every 3 days
boostCriteria: (_item, searchTerm) => {
if (!searchTerm) return -40;
return 0;
},
run: async (_ctx) => {
const years = await seqtaFetchPayload<Array<string | number> | null>(
"/seqta/student/load/goals",
{ mode: "years" },
);
if (!Array.isArray(years) || years.length === 0) {
console.debug("[Goals job] No goal years available; skipping.");
return [];
}
const items: IndexItem[] = [];
const seen = new Set<string>();
for (const year of years) {
try {
const yearGoals = await seqtaFetchPayload<GoalEntry[] | null>(
"/seqta/student/load/goals",
{ mode: "list", year },
);
if (!Array.isArray(yearGoals)) continue;
for (const goal of yearGoals) {
if (!goal) continue;
const stableId = goal.uuid ?? goal.id;
if (stableId === undefined || stableId === null) continue;
const id = `goal-${stableId}`;
if (seen.has(id)) continue;
seen.add(id);
const title =
goal.title?.trim() || goal.description?.slice(0, 80) || `Goal ${stableId}`;
const dateAdded = goal.updated || goal.created
? new Date(goal.updated ?? goal.created!).getTime() || Date.now()
: Date.now();
items.push({
id,
text: title,
category: "goals",
content: extractTextFromValue(
{ description: goal.description, status: goal.status },
{ maxChars: 1000 },
),
dateAdded,
metadata: {
goalId: goal.id,
goalUuid: goal.uuid,
status: goal.status,
year: goal.year ?? year,
createdAt: goal.created,
updatedAt: goal.updated,
entityType: "goal",
route: `/goals/${year}`,
icon: "\uea15",
},
actionId: "goal",
renderComponentId: "goal",
});
}
} catch (e) {
console.warn(`[Goals job] Failed to fetch goals for year ${year}:`, e);
}
await delay(PER_YEAR_DELAY_MS);
}
console.debug(`[Goals job] Indexed ${items.length} goal entries.`);
return items;
},
purge: (items) => items,
};
@@ -1,218 +0,0 @@
import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api";
import { htmlToPlainText } from "../utils";
import { delay } from "@/seqta/utils/delay";
/**
* Indexes daily notices from `/seqta/student/load/notices`.
*
* SEQTA returns notices keyed by date, so we sweep a sliding window
* (default: 14 days back) the first time we run, then incrementally pull
* the most recent days on subsequent runs. Sensitive routes are excluded
* because notices are surfaced for the active student already.
*/
interface NoticeRecord {
id?: number | string;
title?: string;
contents?: string;
staff?: string;
staff_id?: number;
date?: string;
label?: number;
label_title?: string;
colour?: string;
}
interface NoticesProgress {
earliestDate: string | null;
lastSweepBackTo: string | null;
}
const SWEEP_DAYS = 14;
const MAX_HISTORY_DAYS = 365;
const FETCH_DELAY_MS = 60;
function formatYmd(date: Date): string {
const y = date.getFullYear();
const m = (date.getMonth() + 1).toString().padStart(2, "0");
const d = date.getDate().toString().padStart(2, "0");
return `${y}-${m}-${d}`;
}
function parseYmd(value: string | null | undefined): Date | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return null;
const [, y, m, d] = match;
return new Date(Number(y), Number(m) - 1, Number(d));
}
const fetchNoticesForDate = async (date: string): Promise<NoticeRecord[]> => {
const payload = await seqtaFetchPayload<NoticeRecord[] | { notices?: NoticeRecord[] } | null>(
"/seqta/student/load/notices",
{ date },
);
if (!payload) return [];
if (Array.isArray(payload)) return payload;
if (Array.isArray((payload as any).notices)) return (payload as any).notices;
return [];
};
const fetchLabelLookup = async (): Promise<Map<number, string>> => {
const payload = await seqtaFetchPayload<
Array<{ id: number; title?: string }>
>("/seqta/student/load/notices", { mode: "labels" });
const map = new Map<number, string>();
if (Array.isArray(payload)) {
for (const entry of payload) {
if (entry && typeof entry.id === "number" && entry.title) {
map.set(entry.id, entry.title);
}
}
}
return map;
};
export const noticesJob: Job = {
id: "notices",
label: "Notices",
renderComponentId: "notice",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 6 }, // 6 hours
boostCriteria: (item, searchTerm) => {
if (!searchTerm) return -10;
let score = 0;
const ts = item.metadata?.timestamp;
if (typeof ts === "string") {
const ageDays =
(Date.now() - new Date(ts).getTime()) / (1000 * 60 * 60 * 24);
if (ageDays >= 0 && ageDays <= 7) score += 0.05;
}
return score;
},
run: async (ctx) => {
const stored = await ctx.getStoredItems("notices");
const existingIds = new Set(stored.map((i) => i.id));
const progress = (await ctx.getProgress<NoticesProgress>()) ?? {
earliestDate: null,
lastSweepBackTo: null,
};
const labelLookup = await fetchLabelLookup();
const today = new Date();
today.setHours(0, 0, 0, 0);
// Sweep window: always the most recent SWEEP_DAYS, plus extend further
// back the first time we run until we hit MAX_HISTORY_DAYS.
const earliestEverIso = formatYmd(
new Date(today.getTime() - MAX_HISTORY_DAYS * 86_400_000),
);
const dates: string[] = [];
for (let offset = 0; offset < SWEEP_DAYS; offset++) {
const day = new Date(today.getTime() - offset * 86_400_000);
dates.push(formatYmd(day));
}
if (
!progress.lastSweepBackTo ||
progress.lastSweepBackTo > earliestEverIso
) {
// Walk backwards in batches of ~30 days per run so we don't blow up
// a single indexing pass.
const startBack = parseYmd(progress.lastSweepBackTo) ?? today;
const targetBack = new Date(startBack.getTime() - 30 * 86_400_000);
const minBack = parseYmd(earliestEverIso) ?? targetBack;
const stopBack = targetBack < minBack ? minBack : targetBack;
for (
let cursor = new Date(startBack.getTime() - SWEEP_DAYS * 86_400_000);
cursor >= stopBack;
cursor = new Date(cursor.getTime() - 86_400_000)
) {
dates.push(formatYmd(cursor));
}
progress.lastSweepBackTo = formatYmd(stopBack);
}
const items: IndexItem[] = [];
const seen = new Set<string>();
for (const date of dates) {
try {
const notices = await fetchNoticesForDate(date);
for (const notice of notices) {
if (!notice || (notice.id === undefined && !notice.title)) continue;
const id = `notice-${date}-${notice.id ?? notice.title}`;
if (seen.has(id)) continue;
seen.add(id);
const labelTitle =
notice.label_title ??
(typeof notice.label === "number"
? labelLookup.get(notice.label) ?? null
: null);
const bodyText = notice.contents
? htmlToPlainText(notice.contents)
: "";
items.push({
id,
text: notice.title?.trim() || `Notice ${notice.id ?? date}`,
category: "notices",
content: bodyText.slice(0, 4000),
dateAdded: new Date(date).getTime(),
metadata: {
noticeId: notice.id,
date,
author: notice.staff,
authorId: notice.staff_id,
label: labelTitle,
labelId: notice.label,
colour: notice.colour,
timestamp: date,
entityType: "notice",
route: "/notices",
icon: "\ueb24",
},
actionId: "notice",
renderComponentId: "notice",
});
}
} catch (e) {
console.warn(`[Notices job] Failed to fetch notices for ${date}:`, e);
}
await delay(FETCH_DELAY_MS);
}
if (items.length > 0) {
const dateStrings = items
.map((i) => i.metadata?.date as string | undefined)
.filter((d): d is string => !!d);
if (dateStrings.length > 0) {
const earliest = dateStrings.sort()[0];
if (
!progress.earliestDate ||
earliest < progress.earliestDate
) {
progress.earliestDate = earliest;
}
}
}
await ctx.setProgress(progress);
const newCount = items.filter((i) => !existingIds.has(i.id)).length;
console.debug(
`[Notices job] Indexed ${items.length} notices across ${dates.length} dates (${newCount} new).`,
);
return items;
},
purge: (items) => {
const oneYearAgo = Date.now() - 365 * 24 * 60 * 60 * 1000;
return items.filter((i) => i.dateAdded >= oneYearAgo);
},
};
@@ -1,49 +0,0 @@
import type { Job } from "../types";
/**
* Stub job for the passive-observer store.
*
* The passive observer (see `passiveObserver.ts`) writes captured items
* directly into IndexedDB via `getAll`/`put`. We still register a job here
* so the indexer:
* - Creates the `passive` object store on first use.
* - Picks up the right `renderComponentId` when materializing in-memory
* items in `loadAllStoredItems()`.
* - Applies a deterministic boost / purge policy to passive results.
*
* `run()` is a no-op: the passive observer has its own write path so it
* works whether or not an active indexing pass is running.
*/
export const passiveJob: Job = {
id: "passive",
label: "Recently viewed",
renderComponentId: "passive",
// Run frequently so any newly captured items are merged into the
// dynamic-items cache on the next indexing tick. The actual capture is
// continuous; this is only the synchronization cadence.
frequency: { type: "interval", ms: 1000 * 60 * 5 },
boostCriteria: (item, searchTerm) => {
// Passive items are noisier than curated ones, so penalize them
// slightly when there's no query and only modestly help on matches.
if (!searchTerm) return -60;
let score = 0;
if (item.metadata?.entityType) score += 0.02;
return score;
},
run: async () => {
return [];
},
purge: (items) => {
// Keep the most recent ~500 passive entries and anything newer than
// 30 days. This caps storage growth from heavy browsing sessions.
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
const recent = items
.filter((i) => i.dateAdded >= cutoff)
.sort((a, b) => b.dateAdded - a.dateAdded)
.slice(0, 500);
return recent;
},
};
@@ -1,90 +0,0 @@
import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api";
/**
* Indexes the user's external portal entries from `/seqta/student/load/portals`.
*
* Portals are user-facing tiles linking to third-party tools (Mathletics,
* Seesaw, Google Classroom, ...). We index their labels and external URLs
* so users can jump to them via the global search palette without scrolling
* the dashboard.
*/
interface PortalPayload {
id: number | string;
label?: string;
url?: string;
uuid?: string;
icon?: string;
priority?: number;
is_power_portal?: boolean;
contents?: string;
inherit_styles?: boolean;
}
function normalizePortalUrl(raw: string | undefined): string | undefined {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed.replace(/^\/+/, "")}`;
}
export const portalsJob: Job = {
id: "portals",
label: "Portals",
renderComponentId: "portal",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 * 7 }, // weekly
boostCriteria: (_item, searchTerm) => {
if (!searchTerm) return -50;
return 0;
},
run: async (_ctx) => {
const payload = await seqtaFetchPayload<PortalPayload[] | null>(
"/seqta/student/load/portals",
{},
);
if (!Array.isArray(payload)) return [];
const items: IndexItem[] = [];
const seen = new Set<string>();
for (const portal of payload) {
if (!portal || (portal.id === undefined && !portal.uuid)) continue;
const id = `portal-${portal.uuid ?? portal.id}`;
if (seen.has(id)) continue;
seen.add(id);
const url = normalizePortalUrl(portal.url);
const label = portal.label?.trim() || `Portal ${portal.id}`;
const contentParts: string[] = [];
if (url) contentParts.push(url);
if (portal.is_power_portal) contentParts.push("Power Portal");
items.push({
id,
text: label,
category: "portals",
content: contentParts.join(" \u2022 "),
dateAdded: Date.now(),
metadata: {
portalId: portal.id,
portalUuid: portal.uuid,
url,
isPowerPortal: !!portal.is_power_portal,
entityType: "portal",
icon: "\ueb01",
},
actionId: "portal",
renderComponentId: "portal",
});
}
console.debug(`[Portals job] Indexed ${items.length} portal entries.`);
return items;
},
purge: (items) => items,
};
@@ -1,97 +0,0 @@
import type { IndexItem, Job } from "../types";
import { seqtaFetchPayload } from "../api";
/**
* Indexes report metadata from `/seqta/student/load/reports`.
*
* Reports are PDFs gated behind SEQTA's authenticated download endpoint, so
* we only index the human-readable metadata (year, term, title, file UUID)
* and a stable hash route so the search palette can deep-link straight
* into the reports page.
*/
interface ReportEntry {
id?: number | string;
uuid?: string;
title?: string;
description?: string;
date_published?: string;
date_created?: string;
year?: number | string;
term?: number | string;
metaclass?: number;
programme?: number;
filename?: string;
}
export const reportsJob: Job = {
id: "reports",
label: "Reports",
renderComponentId: "report",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 }, // daily
boostCriteria: (_item, searchTerm) => {
if (!searchTerm) return -25;
return 0;
},
run: async (_ctx) => {
const payload = await seqtaFetchPayload<ReportEntry[] | null>(
"/seqta/student/load/reports",
{},
);
if (!Array.isArray(payload)) return [];
const items: IndexItem[] = [];
const seen = new Set<string>();
for (const report of payload) {
if (!report) continue;
const stableId = report.uuid ?? report.id;
if (stableId === undefined || stableId === null) continue;
const id = `report-${stableId}`;
if (seen.has(id)) continue;
seen.add(id);
const title = report.title?.trim() || `Report ${stableId}`;
const dateAdded = report.date_published
? new Date(report.date_published).getTime() || Date.now()
: Date.now();
const contentParts: string[] = [];
if (report.description) contentParts.push(report.description);
if (report.year) contentParts.push(`Year ${report.year}`);
if (report.term) contentParts.push(`Term ${report.term}`);
if (report.date_published) contentParts.push(report.date_published);
items.push({
id,
text: title,
category: "reports",
content: contentParts.join(" \u2022 "),
dateAdded,
metadata: {
reportId: report.id,
reportUuid: report.uuid,
year: report.year,
term: report.term,
metaclass: report.metaclass,
programme: report.programme,
publishedAt: report.date_published,
createdAt: report.date_created,
filename: report.filename,
entityType: "report",
route: "/reports",
icon: "\ueb70",
},
actionId: "report",
renderComponentId: "report",
});
}
console.debug(`[Reports job] Indexed ${items.length} reports.`);
return items;
},
purge: (items) => items,
};
@@ -1,615 +0,0 @@
import type { IndexItem } from "./types";
import { getAll, put } from "./db";
import {
buildIndexItem,
extractTextFromValue,
pickId,
pickTitle,
} from "./extract";
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
import { mergeDynamicItems } from "../utils/dynamicItems";
import { decorateIndexItems } from "./renderComponents";
/**
* Passive network observer.
*
* Wraps the page's `fetch` (and best-effort `XMLHttpRequest`) so that any
* successful same-origin SEQTA JSON response observed while the user
* browses is opportunistically distilled into IndexItems and persisted to
* the `passive` object store.
*
* Hard guarantees:
* - Only same-origin requests under `/seqta/student/` are considered.
* - The shared sensitive-route denylist (login, save/*, settings, prefs,
* heartbeat, branding, themes, file streams, masquerade, ...) is checked
* before any persistence.
* - Response bodies are read via `Response.clone()` so we never consume the
* body the host page intends to use.
* - Sensitive keys/values are stripped via `redactSensitive` before the
* item is stored.
* - Binary file contents are never indexed (we only work on JSON responses
* served as `text/json` / `application/json`).
*/
const STORE_ID = "passive";
const FLUSH_DEBOUNCE_MS = 1500;
const MAX_ITEMS_PER_RESPONSE = 50;
const MAX_PER_RESPONSE_TEXT_CHARS = 1500;
let installed = false;
let pendingFlush: ReturnType<typeof setTimeout> | null = null;
let pendingDirty = false;
/** Items persisted since the last flush — only these are pushed to the search layer. */
const pendingChangedItems = new Map<string, IndexItem>();
export function isPassiveObserverInstalled(): boolean {
return installed;
}
/* ------------------------------------------------------------------ */
/* eligibility checks */
/* ------------------------------------------------------------------ */
function isSameOriginSeqtaUrl(url: string): boolean {
try {
const parsed = new URL(url, location.origin);
if (parsed.origin !== location.origin) return false;
return parsed.pathname.startsWith("/seqta/student/");
} catch {
return false;
}
}
function looksLikeJsonContentType(contentType: string | null): boolean {
if (!contentType) return false;
return /json/i.test(contentType);
}
/* ------------------------------------------------------------------ */
/* item synthesis */
/* ------------------------------------------------------------------ */
interface CapturedContext {
route: string;
requestBody: unknown;
observedAt: number;
}
function categoryFromRoute(route: string): string {
// /seqta/student/load/courses -> courses
// /seqta/student/load/message -> message
const tail = route.replace(/^\/seqta\/student\//, "").split("/").filter(Boolean);
if (tail.length === 0) return "passive";
// message/people is a support endpoint that backs the messages compose UI.
// We treat it as a low-priority `messages-support` record rather than a
// standalone "people" category so it never competes with real assessments
// / messages in the result list.
if (route.includes("/load/message/people")) return "messages-support";
return tail[tail.length - 1].toLowerCase();
}
/**
* `/seqta/student/load/message/people` returns the contact picker dataset
* used by the messages compose view. We only want to surface entries that
* actually carry a human display name the rest is structural noise that
* historically caused raw API paths to appear as titles.
*/
function isPeopleEntityWorthIndexing(entity: unknown): boolean {
if (!entity || typeof entity !== "object") return false;
const obj = entity as Record<string, unknown>;
const first = stringField(obj, [
"preferredName",
"preferred",
"firstname",
"firstName",
"first_name",
"given",
"givenName",
]);
const last = stringField(obj, [
"surname",
"lastname",
"lastName",
"last_name",
"familyName",
]);
const display = stringField(obj, ["displayName", "name", "fullName"]);
return Boolean((first && last) || display);
}
function sourcePageForRoute(route: string): string | undefined {
if (route.includes("/load/message/people")) return "/messages";
if (route.includes("/load/message")) return "/messages";
if (route.includes("/load/messages")) return "/messages";
if (route.includes("/load/courses")) return "/courses";
if (route.includes("/load/assessments")) return "/assessments/upcoming";
if (route.includes("/load/notices")) return "/notices";
if (route.includes("/load/documents")) return "/documents";
if (route.includes("/folio")) return "/folios/read";
if (route.includes("/load/forums")) return "/forums";
if (route.includes("/load/goals")) return "/goals";
if (route.includes("/load/reports")) return "/reports";
if (route.includes("/load/portals")) return "/dashboard";
return undefined;
}
/** Programme + metaclass for `/load/courses` POST body or embedded course JSON. */
function extractProgrammeMetaclass(
requestBody: unknown,
entity: unknown,
): { programme: number; metaclass: number } | null {
const coerce = (value: unknown): number | undefined => {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const t = value.trim();
if (!t) return undefined;
const n = Number(t);
return Number.isFinite(n) ? n : undefined;
}
return undefined;
};
const read = (
src: Record<string, unknown> | null,
): { programme: number; metaclass: number } | null => {
if (!src) return null;
const programme = coerce(
src.programme ?? src.programmeId ?? src.programmeID,
);
const metaclass = coerce(
src.metaclass ?? src.metaclassId ?? src.metaclassID ?? src.subjectId,
);
if (programme !== undefined && metaclass !== undefined) {
return { programme, metaclass };
}
return null;
};
if (requestBody && typeof requestBody === "object" && !Array.isArray(requestBody)) {
const r = read(requestBody as Record<string, unknown>);
if (r) return r;
}
if (entity && typeof entity === "object" && !Array.isArray(entity)) {
const r = read(entity as Record<string, unknown>);
if (r) return r;
}
return null;
}
function entitiesFromPayload(payload: unknown): unknown[] {
if (Array.isArray(payload)) return payload;
if (payload && typeof payload === "object") {
const obj = payload as Record<string, unknown>;
// SEQTA frequently nests arrays as `payload.list`, `.messages`,
// `.items`, `.tasks`, etc. Pull the first array-shaped child as our
// best guess; if none exists, fall back to the object itself so we
// still index a single entry.
for (const key of [
"list",
"items",
"messages",
"tasks",
"pending",
"forums",
"docs",
]) {
const value = obj[key];
if (Array.isArray(value)) return value;
}
return [payload];
}
return [];
}
/**
* Whitelist of entity-shaped fields we hoist into item metadata so the
* `passive` action handler can deep-link into the right SEQTA SPA route.
* These mirror what the active jobs already store (see `courses.ts`,
* `portals.ts`, etc.) so the action only has to consult one source.
*/
const DEEP_LINK_FIELDS = [
"programme",
"programmeId",
"programmeID",
"metaclass",
"metaclassId",
"metaclassID",
"year",
"uuid",
"portalUuid",
"forum",
"forumId",
"assessmentId",
"assessmentID",
"messageId",
] as const;
function pickDeepLinkHints(
entity: unknown,
): Record<string, string | number> {
if (!entity || typeof entity !== "object") return {};
const src = entity as Record<string, unknown>;
const out: Record<string, string | number> = {};
for (const key of DEEP_LINK_FIELDS) {
const value = src[key];
if (typeof value === "number" && Number.isFinite(value)) {
out[key] = value;
} else if (typeof value === "string" && value) {
out[key] = value;
}
}
return out;
}
function stringField(
entity: Record<string, unknown>,
keys: readonly string[],
): string | undefined {
for (const key of keys) {
const value = entity[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
function titleFromEndpoint(
route: string,
entity: unknown,
extractedContent: string,
fallback: string,
): string {
if (route.includes("/load/message/people") && entity && typeof entity === "object") {
const obj = entity as Record<string, unknown>;
const first = stringField(obj, [
"preferredName",
"preferred",
"firstname",
"firstName",
"first_name",
"given",
"givenName",
]);
const last = stringField(obj, [
"surname",
"lastname",
"lastName",
"last_name",
"familyName",
]);
const full = [first, last].filter(Boolean).join(" ").trim();
if (full) return full.slice(0, 200);
}
const picked = pickTitle(entity, "");
if (picked) return picked.slice(0, 200);
// Last resort: show a human-readable content preview instead of a raw API
// path like `/seqta/student/load/message/people#20`.
const firstLine = extractedContent
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
return (firstLine || fallback).slice(0, 200);
}
function synthesizeItems(
ctx: CapturedContext,
payload: unknown,
): IndexItem[] {
const entities = entitiesFromPayload(payload);
if (entities.length === 0) return [];
const category = categoryFromRoute(ctx.route);
const now = ctx.observedAt;
const out: IndexItem[] = [];
const isPeopleSupport = ctx.route.includes("/load/message/people");
const limit = Math.min(entities.length, MAX_ITEMS_PER_RESPONSE);
for (let i = 0; i < limit; i++) {
const entity = entities[i];
if (!entity || (typeof entity !== "object" && typeof entity !== "string")) {
continue;
}
// For the messages compose-people endpoint, skip records that don't
// carry a real human name. We never want raw entries like
// `/seqta/student/load/message/people#20` becoming titles, and we
// explicitly route the rest to /messages so they're treated as support
// records, not standalone "people" results.
if (isPeopleSupport && !isPeopleEntityWorthIndexing(entity)) {
continue;
}
const fallbackId = `${ctx.route}#${i}`;
const entityId = pickId(entity, fallbackId);
const stableId = `passive-${ctx.route.replace(/\//g, "_")}-${entityId}`;
const content = extractTextFromValue(entity, {
maxChars: MAX_PER_RESPONSE_TEXT_CHARS,
});
const title = titleFromEndpoint(ctx.route, entity, content, fallbackId);
if (!content && (!title || title === fallbackId)) {
// Skip records that produced neither title nor content; they are
// structurally noise (e.g. tiny acknowledgement payloads).
continue;
}
const deepLinkHints = pickDeepLinkHints(entity);
const sourcePage = sourcePageForRoute(ctx.route);
const coursePm = ctx.route.includes("/load/courses")
? extractProgrammeMetaclass(ctx.requestBody, entity)
: null;
out.push(
buildIndexItem({
id: stableId,
text: title,
category,
contentOverride: content,
metadata: {
route: ctx.route,
source: "passive",
observedAt: new Date(now).toISOString(),
entityType: category,
entityId,
icon: "\ueb71",
sourcePage,
// Mark message/people as a low-priority support record so the
// search ranker can deprioritize it relative to real messages,
// assessments, courses, etc.
...(isPeopleSupport ? { supportRecord: true, priority: "low" } : {}),
...deepLinkHints,
...(coursePm
? { programme: coursePm.programme, metaclass: coursePm.metaclass }
: {}),
},
actionId: "passive",
renderComponentId: "passive",
dateAdded: now,
}),
);
}
return out;
}
/* ------------------------------------------------------------------ */
/* persistence */
/* ------------------------------------------------------------------ */
async function persistItems(items: IndexItem[]): Promise<void> {
if (items.length === 0) return;
// Dedupe against existing entries. We replace on collision so the latest
// observation wins (e.g. if a message changes title).
for (const item of items) {
try {
await put(STORE_ID, item, item.id);
pendingChangedItems.set(item.id, item);
} catch (e) {
console.warn(
`[Passive Observer] Failed to persist item ${item.id}:`,
e,
);
}
}
pendingDirty = true;
scheduleFlush();
}
function scheduleFlush() {
if (pendingFlush) return;
pendingFlush = setTimeout(() => {
pendingFlush = null;
if (!pendingDirty) return;
pendingDirty = false;
void flushDynamicItems();
}, FLUSH_DEBOUNCE_MS);
}
async function flushDynamicItems(): Promise<void> {
if (pendingChangedItems.size === 0) return;
const rawChanged = Array.from(pendingChangedItems.values());
pendingChangedItems.clear();
try {
const decorated = decorateIndexItems(rawChanged);
mergeDynamicItems(decorated);
window.dispatchEvent(
new CustomEvent("dynamic-items-updated", {
detail: {
incremental: true,
jobId: STORE_ID,
changedItems: decorated,
streaming: false,
},
}),
);
} catch (e) {
console.warn("[Passive Observer] Failed to refresh dynamic items:", e);
}
}
/* ------------------------------------------------------------------ */
/* fetch hook */
/* ------------------------------------------------------------------ */
async function consumeResponse(
response: Response,
url: string,
requestBody: unknown,
): Promise<void> {
if (!response.ok) return;
const route = normalizeSeqtaPath(url);
if (isSensitiveSeqtaPath(route)) return;
const contentType = response.headers.get("content-type");
if (!looksLikeJsonContentType(contentType)) return;
let body: any;
try {
body = await response.clone().json();
} catch {
return;
}
if (!body || typeof body !== "object") return;
if (body.status && body.status !== "200") return;
const payload = body.payload;
if (payload === undefined || payload === null) return;
const items = synthesizeItems(
{
route,
requestBody,
observedAt: Date.now(),
},
payload,
);
if (items.length > 0) {
await persistItems(items);
}
}
function tryParseJson(value: unknown): unknown {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
/**
* Installs the passive observer once. Subsequent calls are no-ops.
*
* Designed to be called from the global-search plugin bootstrap after
* `mountSearchBar` succeeds so the observer is only active when the
* plugin itself is enabled.
*/
export function installPassiveObserver(): void {
if (installed) return;
if (typeof window === "undefined" || typeof window.fetch !== "function") {
return;
}
installed = true;
const originalFetch = window.fetch.bind(window);
window.fetch = async function patchedFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const response = await originalFetch(input, init);
try {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
if (isSameOriginSeqtaUrl(url)) {
const body = init?.body;
const parsed =
body && typeof body === "string"
? tryParseJson(body)
: undefined;
// Fire-and-forget: never block the host page on indexing work.
void consumeResponse(response, url, parsed);
}
} catch (e) {
// Never let observer errors bubble up to the host page.
console.debug("[Passive Observer] fetch hook error:", e);
}
return response;
};
// Best-effort XHR hook for the rare callers that bypass fetch.
const ProtoXhr = (window as any).XMLHttpRequest?.prototype;
if (ProtoXhr) {
const originalOpen = ProtoXhr.open;
const originalSend = ProtoXhr.send;
ProtoXhr.open = function patchedOpen(
this: XMLHttpRequest,
method: string,
url: string,
...rest: any[]
) {
try {
(this as any).__bsplusUrl = url;
(this as any).__bsplusMethod = method;
} catch {
/* ignore */
}
return originalOpen.call(this, method, url, ...rest);
};
ProtoXhr.send = function patchedSend(
this: XMLHttpRequest,
body?: any,
) {
try {
const url = (this as any).__bsplusUrl as string | undefined;
if (url && isSameOriginSeqtaUrl(url)) {
const parsed =
typeof body === "string" ? tryParseJson(body) : undefined;
this.addEventListener("load", () => {
try {
if (this.status < 200 || this.status >= 300) return;
const ct = this.getResponseHeader("content-type");
if (!looksLikeJsonContentType(ct)) return;
const route = normalizeSeqtaPath(url);
if (isSensitiveSeqtaPath(route)) return;
let json: any;
try {
json = JSON.parse(this.responseText);
} catch {
return;
}
if (!json || typeof json !== "object") return;
if (json.status && json.status !== "200") return;
const payload = json.payload;
if (payload === undefined || payload === null) return;
const items = synthesizeItems(
{
route,
requestBody: parsed,
observedAt: Date.now(),
},
payload,
);
if (items.length > 0) {
void persistItems(items);
}
} catch (e) {
console.debug("[Passive Observer] xhr load error:", e);
}
});
}
} catch {
/* ignore */
}
return originalSend.call(this, body);
};
}
console.debug("[Passive Observer] Installed.");
}
/**
* Returns currently-stored passive items. Mainly used for diagnostics from
* `window.globalSearchDebug`.
*/
export async function getStoredPassiveItems(): Promise<IndexItem[]> {
try {
return (await getAll(STORE_ID)) as IndexItem[];
} catch {
return [];
}
}
@@ -2,59 +2,10 @@ import type { SvelteComponent } from "svelte";
import AssessmentItem from "../components/items/AssessmentItem.svelte";
import ForumItem from "../components/items/ForumItem.svelte";
import SubjectItem from "../components/items/SubjectItem.svelte";
import GenericItem from "../components/items/GenericItem.svelte";
import type { IndexItem } from "./types";
import { jobs } from "./jobs";
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
assessment: AssessmentItem as unknown as typeof SvelteComponent,
message: AssessmentItem as unknown as typeof SvelteComponent,
forum: ForumItem as unknown as typeof SvelteComponent,
subject: SubjectItem as unknown as typeof SvelteComponent,
// New categories share a generic, category-aware row component to keep
// the palette consistent without bespoke layouts for every job. The
// component reads `item.metadata.icon` and the `category` to pick a
// sensible default visual treatment.
course: GenericItem as unknown as typeof SvelteComponent,
notice: GenericItem as unknown as typeof SvelteComponent,
document: GenericItem as unknown as typeof SvelteComponent,
folio: GenericItem as unknown as typeof SvelteComponent,
portal: GenericItem as unknown as typeof SvelteComponent,
report: GenericItem as unknown as typeof SvelteComponent,
goal: GenericItem as unknown as typeof SvelteComponent,
passive: GenericItem as unknown as typeof SvelteComponent,
};
function resolveRenderComponent(item: IndexItem): typeof SvelteComponent | undefined {
const jobDef =
jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId];
if (jobDef) {
return renderComponentMap[jobDef.renderComponentId] || item.renderComponent;
}
if (renderComponentMap[item.renderComponentId]) {
return renderComponentMap[item.renderComponentId];
}
return item.renderComponent;
}
/**
* Attach render components and deep-clone items for search UI (Firefox XrayWrapper).
*/
export function decorateIndexItems(items: IndexItem[]): IndexItem[] {
return items.map((item) => {
try {
const renderComponent = resolveRenderComponent(item);
try {
const cloned = JSON.parse(JSON.stringify(item)) as IndexItem;
cloned.renderComponent = renderComponent;
return cloned;
} catch {
return { ...item, renderComponent };
}
} catch {
return item;
}
});
}
};
@@ -1,112 +0,0 @@
import { SCHEMA_VERSION_KEY } from "./schemaVersion";
/**
* Hard-reset of all global-search persistence.
*
* This module is intentionally dependency-free (no imports from `db.ts`,
* the worker manager, embeddia, or any heavy bundle) so it can be
* statically imported from:
*
* - The always-loaded plugin shell (`lazy.ts`) for the manual
* "Reset Index" settings button. Statically importing means the button
* keeps working across extension updates there's no chunk hash to
* chase via dynamic import, which previously produced
* `Failed to fetch dynamically imported module: .../assets/<chunk>.js`
* when an older settings page tried to load a chunk that the new build
* had already replaced.
*
* - The version-check path (`utils/versionCheck.ts`) for the auto-reset
* that fires whenever the extension's manifest version changes.
*
* The function:
* 1. Notifies in-process modules to drop in-memory caches and any open
* IndexedDB connections via custom DOM events (best effort).
* 2. Deletes the structured `betterseqta-index` and the vector
* `embeddiaDB` databases.
* 3. Clears version-tracking localStorage keys so the next indexing
* pass treats the world as fresh.
*
* It never throws on partial failure: each step is wrapped in try/catch
* so a stuck connection on one DB doesn't block the other.
*/
const STRUCTURED_DB = "betterseqta-index";
const VECTOR_DB = "embeddiaDB";
const STRUCTURED_VERSION_KEY = "betterseqta-index-version";
function deleteIndexedDb(name: string): Promise<void> {
return new Promise((resolve) => {
let resolved = false;
const finish = () => {
if (resolved) return;
resolved = true;
resolve();
};
let req: IDBOpenDBRequest;
try {
req = indexedDB.deleteDatabase(name);
} catch (e) {
console.warn(`[Reset] Could not start delete of ${name}:`, e);
finish();
return;
}
req.onsuccess = () => finish();
req.onerror = () => {
console.warn(`[Reset] Error deleting ${name}:`, req.error);
finish();
};
req.onblocked = () => {
// Connections are still open in another tab. Wait briefly, retry,
// then resolve regardless so we never hang the caller forever.
console.warn(
`[Reset] Delete of ${name} blocked; will retry then resolve.`,
);
setTimeout(() => {
try {
const retry = indexedDB.deleteDatabase(name);
retry.onsuccess = () => finish();
retry.onerror = () => finish();
retry.onblocked = () => finish();
} catch {
finish();
}
}, 600);
};
});
}
export async function resetSearchIndexes(): Promise<void> {
try {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("betterseqta-clear-search-cache"),
);
window.dispatchEvent(
new CustomEvent("betterseqta-clear-embedding-cache"),
);
window.dispatchEvent(
new CustomEvent("betterseqta-reset-search-index"),
);
}
} catch {
/* ignore — events are best-effort */
}
// Give listeners a tick to close any open IDB connections; otherwise
// the delete request below comes back with `onblocked`.
await new Promise<void>((resolve) => setTimeout(resolve, 150));
await Promise.allSettled([
deleteIndexedDb(STRUCTURED_DB),
deleteIndexedDb(VECTOR_DB),
]);
try {
localStorage.removeItem(STRUCTURED_VERSION_KEY);
localStorage.removeItem(SCHEMA_VERSION_KEY);
} catch {
/* ignore */
}
}
@@ -1,16 +0,0 @@
/**
* Index schema version. Bump whenever the IndexItem shape, category set,
* or text construction changes in a way that should invalidate previously
* stored items (and their embeddings).
*
* On mismatch, both the structured IndexedDB store and the embeddiaDB are
* wiped before the next indexing pass so we don't serve stale results.
*
* Kept in its own file (with no imports) so very lightweight callers the
* always-loaded plugin shell in `lazy.ts`, the version-check path can
* pull it in without bringing the heavy indexer/worker bundle along.
*/
export const INDEX_SCHEMA_VERSION = 6;
/** Key used to track the schema version a previous run wrote out. */
export const SCHEMA_VERSION_KEY = "bsq-index-schema-version";

Some files were not shown because too many files have changed in this diff Show More