Compare commits

..

95 Commits

Author SHA1 Message Date
AdenMGB b535e87023 chore: update changelog 2026-06-05 10:00:38 +09:30
AdenMGB dd0830d349 feat: add new copy rubric button 2026-06-05 09:31:42 +09:30
AdenMGB b4a59330c5 fix: fix assement overview not choosing actuve subjects and improve styling 2026-06-05 09:18:33 +09:30
AdenMGB 314c555d87 fix: ensure the ability to override weightings 2026-06-05 08:55:06 +09:30
Aden Lindsay 24e38870a9 Merge pull request #446 from StroepWafel/SyncPFP
Sync PFP on change
2026-06-04 13:36:57 +09:30
StroepWafel 0878910043 Sync PFP on change 2026-06-04 12:44:34 +09:30
SethBurkart123 ce18412405 fix: text on analytics page reload button 2026-06-03 10:32:36 +10:00
AdenMGB 4f6c978043 finalise compat 2026-06-02 13:33:45 +09:30
AdenMGB 9093553ff1 compat: improve compat for nueromphic theme 2026-06-02 13:11:34 +09:30
AdenMGB acb2c682f3 tweak: tweak priv policy placement and style 2026-06-02 12:52:52 +09:30
SethBurkart123 3987871a6a fix: theme store stuck on loading skeleton after fetch failures
Harden theme list fetching with normalized API responses, timeouts, retries,
and a visible error state so the store no longer stays blank when messaging
or payloads fail (common after extension updates without a SEQTA tab reload).
2026-06-02 12:43:35 +10:00
SethBurkart123 9000cb28cd Replace analytics grade filter with dual-handle range slider.
Use a single track with min/max thumbs instead of two separate sliders for clearer filtering UX.
2026-06-02 12:32:07 +10:00
AdenMGB 337f85c3cc feat: prep fopr v3.7.0 as well as minor tweaks 2026-06-02 08:42:24 +09:30
AdenMGB 9e521722f1 Merge branch 'main' of https://github.com/BetterSEQTA/BetterSEQTA-Plus 2026-06-01 19:53:58 +09:30
AdenMGB 2b7c5e17b6 feat: analytics page 2026-06-01 19:43:47 +09:30
Aden Lindsay 21bc6078d7 Merge pull request #441 from StroepWafel/main
fix: TOTM
2026-06-01 18:37:59 +09:30
Aden Lindsay 6c966a8c38 Merge pull request #444 from StroepWafel/theme-stuff
feat: handlers for night city theme's features
2026-06-01 18:37:24 +09:30
StroepWafel 774be0ceed fix issues with injected sidebar 2026-06-01 14:16:38 +09:30
codefactor-io 2356a49fcd [CodeFactor] Apply fixes to commit 3d13202 2026-05-29 02:01:49 +00:00
StroepWafel efbc734d61 Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus 2026-05-29 11:30:05 +09:30
StroepWafel f5034ca0bc clean up TOTM popup. properly remember closes 2026-05-29 11:29:50 +09:30
StroepWafel a1d95ebd40 Update package.json 2026-05-29 11:02:36 +09:30
StroepWafel 943623e0fb Update OpenWhatsNewPopup.ts 2026-05-29 11:02:29 +09:30
StroepWafel a23eda1162 show TOTM popup until dismissed - forgotten commit
i forgot to commit this
basically just shows until dismissed even persistent over reloads so the user doesn't lose it the moment they navigate away or if they load the page and close it immediately
2026-05-29 10:55:55 +09:30
StroepWafel 3d13202779 feat: handlers for night city theme's features 2026-05-29 00:44:02 +09:30
StroepWafel 9f263c8c02 Update package.json 2026-05-28 15:17:38 +09:30
StroepWafel e8d1dadfa7 Update OpenWhatsNewPopup.ts 2026-05-28 15:16:43 +09:30
StroepWafel 7a3cbb50cc fix style 2026-05-25 13:49:46 +09:30
StroepWafel eb49e8d7f1 fix up TOTM 2026-05-25 13:26:33 +09:30
Aden Lindsay 65cd0a1c4f Merge pull request #435 from StroepWafel/improved-global-search
Improved global search
2026-05-25 13:18:28 +09:30
StroepWafel 0007b55c03 Merge branch 'main' into improved-global-search 2026-05-25 13:13:57 +09:30
StroepWafel 93e0a2b123 Update OpenWhatsNewPopup.ts 2026-05-25 13:11:46 +09:30
AdenMGB 1a8f025a04 feat: engage shortcuts on homepage 2026-05-24 17:30:03 +09:30
AdenMGB f0358bec07 feat: make assement overview for SEQTA Engage 2026-05-24 17:28:20 +09:30
AdenMGB 4f6916d8b3 feat: bring assement weighting to engage 2026-05-24 17:21:21 +09:30
AdenMGB fee79e8623 temp: disable global search on engage 2026-05-24 17:14:06 +09:30
AdenMGB 475b865000 feat: apply our exisitng icons to engage sidebar 2026-05-24 17:11:47 +09:30
SethBurkart123 304ce2e128 feat: refine startup announcement cards 2026-05-23 22:53:06 +10:00
AdenMGB 0bc6beb0f1 chore: bump ver & release notes 2026-05-23 09:08:31 +09:30
AdenMGB 68173a8b75 fix: fix custom teacher names not applying to popup 2026-05-23 08:58:21 +09:30
Aden Lindsay 7583d0ee47 Merge pull request #439 from StroepWafel/main
feat: Theme of the Month
2026-05-19 20:36:01 +09:30
codefactor-io 6c79fe3588 [CodeFactor] Apply fixes 2026-05-19 10:53:27 +00:00
StroepWafel c0a8a76105 feat: Theme Of The Month 2026-05-19 20:19:50 +09:30
StroepWafel 6ad214bb09 Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus 2026-05-18 20:29:35 +09:30
AdenMGB b4598668d4 feat: re enable message folders with improvments 2026-05-13 13:30:27 +09:30
AdenMGB 01e679eab6 Revert "fix: add some better detection logic for assements widget #429"
This reverts commit 01cd5d1428.
2026-05-06 17:31:41 +09:30
Aden Lindsay f57bd107b9 Merge pull request #436 from Jaxx7594/asessment-average-manual-input
Feat: ability to manually input weightings for assessments
2026-05-05 19:51:00 +09:30
Jaxon Lewis-Wilson aa5d193e55 assessmentsAverage: Fix inaccurate weight when a weight == N/A
N/A weights were automatically set to a weight of 1 for some reason. I removed it from the calculations completely with this commit.
2026-05-05 18:14:06 +08:00
Jaxon Lewis-Wilson da5bc7ab11 assessmentsAverage: Fix weight display upon setting override 2026-05-05 18:10:13 +08:00
Jaxon Lewis-Wilson b0857054eb assessmentsAverage: Fix unmarked/upcoming assessment indexing and weight display 2026-05-05 17:56:06 +08:00
Jaxon Lewis-Wilson f721bf6609 Revert "feat: dont inject weightings page in assements without results"
This reverts commit 2aecd63850.
Reverting so that I can solve the indexing issue. Only marked assessments are getting indexed, which is incorrect behaviour that slipped testing when the plugin was first made.
2026-05-05 16:32:12 +08:00
AdenMGB 2aecd63850 feat: dont inject weightings page in assements without results 2026-05-05 17:44:58 +09:30
Jaxx7594 f35520029f assessmentAverage: Remove remnant comment 2026-05-04 22:53:05 +08:00
Jaxx7594 95994fcd3a Merge branch 'main' into asessment-average-manual-input 2026-05-04 22:46:29 +08:00
Jaxon Lewis-Wilson 999f12e958 assessmentsAverage: Add changes to changelog 2026-05-04 22:39:53 +08:00
Jaxon Lewis-Wilson 260afac294 assessmentsAverage: Fix display of missing weighting, and minor change to override section. 2026-05-04 18:36:16 +08:00
Jaxon Lewis-Wilson 678a958351 assessmentsAverage: Add ability to override/set weighting per assessment. 2026-05-04 18:32:32 +08:00
SethBurkart123 608fc96c4e chore: temporarily disable message folders plugin and remove from changelog 2026-05-01 15:39:20 +10:00
StroepWafel 577478ba7e titles > Content 2026-05-01 14:34:15 +09:30
SethBurkart123 23ccac4836 update bun.lock 2026-05-01 12:15:53 +10:00
AdenMGB f6472ea9bd fix: add timeout lock to ensure completion of vecotrisation 2026-05-01 08:49:00 +09:30
StroepWafel f3f4491f04 fix @SethBurkart123 's comments 2026-04-30 21:17:48 +09:30
StroepWafel c987e4d54e patch: default to on
Forgot to make it default to on
2026-04-30 18:40:32 +09:30
StroepWafel cc7f2bc634 update what's new 2026-04-30 18:30:19 +09:30
StroepWafel 189a30a611 fix: various ui/ux improvements and duplicate rm'd 2026-04-30 18:20:19 +09:30
StroepWafel 710c03f463 Feat: Updated global search
- Add shared SEQTA fetch layer, extract helpers, passive JSON capture, many new index jobs
- Schema version + shared reset; auto-reset on extension update; fix manual reset (no dynamic import)
- Stabilize hybrid search: lexical title scoring, stale-query guard, vector guardrails, drop route from Fuse keys
- Improve passive titles/routing; tighten message/people handling
- Fix assignment/course indexing (lenient envelopes, subjects mode:list, student id fallback)
- Top bar: indexing label + stacked progress bar under quick search trigger
2026-04-30 17:09:34 +09:30
Aden Lindsay a875f35f1a Merge pull request #432 from StroepWafel/patch-3 2026-04-29 22:25:18 +09:30
StroepWafel e2cf9afbf9 Fix typos in WhatsNewPopup text 2026-04-29 22:05:46 +09:30
StroepWafel a1131cf6cd Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus 2026-04-29 21:47:11 +09:30
Aden Lindsay 3f493ac716 chore: add forgoten custom messages editor to whatsnew 2026-04-29 18:53:13 +09:30
Aden Lindsay e64ef7f95c Custom Message Folders (#431)
* feat: start custom messages plugin

* feat: finish custom message folders
2026-04-29 18:51:05 +09:30
AdenMGB c118b5b8dd chore: update whats new 2026-04-29 11:59:21 +09:30
AdenMGB 6535ec6932 feat: update whatsnew and update video 2026-04-29 11:59:03 +09:30
AdenMGB fba5d09c75 feat: theme flavours for theme varients 2026-04-29 11:13:32 +09:30
AdenMGB b88d29967d fix: ensure the theme store triggers the cloud sync upon install 2026-04-29 10:25:41 +09:30
AdenMGB 1b87d20a27 feat: auto install themes if not present locally with BS Cloud 2026-04-29 10:21:45 +09:30
Aden Lindsay 7bd3158b05 fix: fix wording 2026-04-29 09:56:00 +09:30
StroepWafel 7a4fa1e5bf feat: add RGB handler for alpine theme (#427)
* add handlers for individual Channels

* add notes

* patch fix theme overrides for adaptive colour

* idk

* Update package.json

* fix issue spelling

* Update OpenWhatsNewPopup.ts

* Update OpenWhatsNewPopup.ts

* fix: remove debug line from .gitignore

* chore: fix up patch notes to be a bit more user friendly

* chore: finalise patch notes and fix grammer

* Add empty line to .gitignore

---------

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
2026-04-29 09:54:26 +09:30
Jaxon Lewis-Wilson f7d9199500 assessmentsAverage: Minor WEIGHT label styling fixes 2026-04-27 00:28:51 +08:00
AdenMGB 01cd5d1428 fix: add some better detection logic for assements widget #429 2026-04-23 17:26:58 +09:30
StroepWafel 5178408f39 Update OpenWhatsNewPopup.ts 2026-04-22 06:28:48 +09:30
StroepWafel 0b51db5434 Update OpenWhatsNewPopup.ts 2026-04-22 06:27:01 +09:30
StroepWafel 9c47fa38ae fix issue spelling 2026-04-22 06:25:47 +09:30
StroepWafel 5c4d7e1be3 Update package.json 2026-04-21 21:35:41 +09:30
StroepWafel acbbac8266 Merge branch 'main' into main 2026-04-21 21:34:17 +09:30
StroepWafel fa8f36f3d5 idk 2026-04-21 20:31:06 +09:30
SethBurkart123 fcc856e798 fix: resolve assessments overview failing to load in production builds
Replace dynamic import of ./ui with static import to prevent Vite from
code-splitting into a separate chunk that CRXJS cannot resolve at runtime.

Bumps version to 3.6.3.
2026-04-21 19:08:30 +10:00
StroepWafel 44116edca5 patch fix theme overrides for adaptive colour 2026-04-20 22:50:39 +09:30
StroepWafel 37be31859f add notes 2026-04-20 21:47:05 +09:30
StroepWafel 10667f17b4 Merge branch 'main' of https://github.com/StroepWafel/BetterSEQTA-Plus 2026-04-20 21:43:15 +09:30
StroepWafel 0ca0c7cf43 add handlers for individual Channels 2026-04-20 21:43:05 +09:30
SethBurkart123 a0038ac871 chore: bump version to 3.6.2 2026-04-20 15:53:20 +10:00
SethBurkart123 49824e9eab refactor: remove alarms permission, throttle cloud sync to once per day on page load 2026-04-20 15:47:22 +10:00
AdenMGB e2270602a3 feat: finish custom message folders 2026-04-17 15:39:58 +09:30
AdenMGB 8b1e5b2ee7 feat: start custom messages plugin 2026-04-16 20:22:00 +09:30
134 changed files with 18766 additions and 1616 deletions
+1
View File
@@ -23,3 +23,4 @@ betterseqtaplus-safari/
.env .env
.env.submit .env.submit
dependency-graph.svg dependency-graph.svg
+122 -22
View File
@@ -5,7 +5,7 @@
"": { "": {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"dependencies": { "dependencies": {
"@bedframe/core": "^0.0.46", "@bedframe/core": "^0.1.0",
"@codemirror/autocomplete": "^6.18.6", "@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0", "@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
@@ -13,7 +13,7 @@
"@codemirror/search": "^6.5.10", "@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4", "@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4", "@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.1.4", "@types/chrome": "^0.1.4",
@@ -52,17 +52,17 @@
"react-dom": "17", "react-dom": "17",
"rss-parser": "^3.13.0", "rss-parser": "^3.13.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"svelte": "^5.22.6", "svelte": "^5.46.4",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vite": "^6.2.1", "vite": "^8.0.5",
"webextension-polyfill": "^0.12.0", "webextension-polyfill": "^0.12.0",
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9", "@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9", "@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.95", "@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.2.0", "@crxjs/vite-plugin": "^2.4.0",
"@types/mime-types": "^3.0.1", "@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@@ -127,9 +127,9 @@
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], "@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
"@bedframe/cli": ["@bedframe/cli@0.0.95", "", { "dependencies": { "@bedframe/core": "0.0.46", "commander": "14.0.0", "execa": "9.6.0", "kolorist": "1.8.0", "listr": "0.14.3", "nanospinner": "1.2.2", "node-fetch": "3.3.2", "pkg-install": "1.0.0", "prompts": "2.4.2", "vite": "6.3.5" }, "peerDependencies": { "concurrently": "8.2.2" }, "bin": { "bedframe": "dist/bedframe.js", "create-bedframe": "dist/create-bedframe.js" } }, "sha512-WSb0HhHCfH7/tS5dDC/HL/VgKrIFGLmI0OesmcwQntrJdHVirtjrDVjcTFG3lC3LB5Ax85P1CY50Gy5aDNc0sQ=="], "@bedframe/cli": ["@bedframe/cli@0.1.2", "", { "dependencies": { "@bedframe/core": "0.1.0", "commander": "^14.0.2", "execa": "^9.6.1", "kolorist": "^1.8.0", "listr": "^0.14.3", "nanospinner": "^1.2.2", "node-fetch": "^3.3.2", "pkg-install": "^1.0.0", "prompts": "^2.4.2", "vite": "^6.4.1" }, "peerDependencies": { "concurrently": "^8.2.1" }, "bin": { "bedframe": "dist/bedframe.js", "create-bedframe": "dist/create-bedframe.js" } }, "sha512-nu0VSfGLhY9f62w+fDRQi2YnfoY9c6u28ZlJ8rH6f57ItLo5TNrZetfw37fYNnh8yK2RSAWU7+6KCkdVm0Fokg=="],
"@bedframe/core": ["@bedframe/core@0.0.46", "", { "dependencies": { "@crxjs/vite-plugin": "2.0.2" }, "peerDependencies": { "vite-plugin-dts": "3.9.1", "vite-plugin-externalize-deps": "0.7.0", "vitest": "0.34.6" } }, "sha512-cOshFUrBksWnVQ08chunlvAetwhuytkX7NdH6blNNylYzsgCaLGBbCJ2EZ0d18kimFVNZoODrc+812if5dio/w=="], "@bedframe/core": ["@bedframe/core@0.1.0", "", { "dependencies": { "@crxjs/vite-plugin": "2.3.0" }, "peerDependencies": { "vite-plugin-dts": "^3.7.0", "vite-plugin-externalize-deps": "^0.7.0", "vitest": "^0.34.6" } }, "sha512-bM9vuYG67m9lVTui966AmkoxPPdEHEDOKKjzAWV/Ymgur818fRhMMpblx3+PLs8kTCek1m79fjYKoE8PhqJ22g=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "6.11.3", "@codemirror/state": "6.5.2", "@codemirror/view": "6.38.1", "@lezer/common": "1.2.3" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="], "@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "6.11.3", "@codemirror/state": "6.5.2", "@codemirror/view": "6.38.1", "@lezer/common": "1.2.3" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="],
@@ -147,7 +147,13 @@
"@codemirror/view": ["@codemirror/view@6.38.1", "", { "dependencies": { "@codemirror/state": "6.5.2", "crelt": "1.0.6", "style-mod": "4.1.2", "w3c-keyname": "2.2.8" } }, "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ=="], "@codemirror/view": ["@codemirror/view@6.38.1", "", { "dependencies": { "@codemirror/state": "6.5.2", "crelt": "1.0.6", "style-mod": "4.1.2", "w3c-keyname": "2.2.8" } }, "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ=="],
"@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.2.0", "", { "dependencies": { "@rollup/pluginutils": "4.2.1", "@webcomponents/custom-elements": "1.6.0", "acorn-walk": "8.3.4", "cheerio": "1.1.2", "convert-source-map": "1.9.0", "debug": "4.4.1", "es-module-lexer": "0.10.5", "fast-glob": "3.3.3", "fs-extra": "10.1.0", "jsesc": "3.1.0", "magic-string": "0.30.18", "pathe": "2.0.3", "picocolors": "1.1.1", "react-refresh": "0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-HpT1GLbUQy42nlpN4sGzFgulacBraMM778s8Q+oPo4cb26DwO9tTwdndlvAS8fe6vEProFXvbdt37objp/0IQA=="], "@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.4.0", "", { "dependencies": { "@rollup/pluginutils": "^4.1.2", "@webcomponents/custom-elements": "^1.5.0", "acorn-walk": "^8.2.0", "convert-source-map": "^1.7.0", "debug": "^4.3.3", "es-module-lexer": "^0.10.0", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "jsesc": "^3.0.2", "magic-string": "^0.30.12", "node-html-parser": "^7.0.2", "pathe": "^2.0.1", "picocolors": "^1.1.1", "react-refresh": "^0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" }, "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bDLdq0W2V1SkMQDJjrcYyjK9/uKtdl4joT7GRImcootCjZdKRiRYt+cv9z8tJoU/tK3o1lX48LTqN7JMsk5AQg=="],
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
@@ -293,12 +299,16 @@
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="], "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "1.19.1" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "1.19.1" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "1.0.3", "is-glob": "4.0.3", "micromatch": "4.0.8", "node-addon-api": "7.1.1" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="], "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "1.0.3", "is-glob": "4.0.3", "micromatch": "4.0.8", "node-addon-api": "7.1.1" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="], "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
@@ -349,6 +359,38 @@
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
"@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "2.0.2", "picomatch": "2.3.1" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="], "@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "2.0.2", "picomatch": "2.3.1" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="],
@@ -409,14 +451,14 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "4.0.1", "debug": "4.4.1", "deepmerge": "4.3.1", "kleur": "4.1.5", "magic-string": "0.30.18", "vitefu": "1.1.1" }, "peerDependencies": { "svelte": "5.38.6", "vite": "6.3.5" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "5.1.1", "svelte": "5.38.6", "vite": "6.3.5" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.10", "", { "dependencies": { "mini-svg-data-uri": "1.4.4" }, "peerDependencies": { "tailwindcss": "3.4.17" } }, "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw=="], "@tailwindcss/forms": ["@tailwindcss/forms@0.5.10", "", { "dependencies": { "mini-svg-data-uri": "1.4.4" }, "peerDependencies": { "tailwindcss": "3.4.17" } }, "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw=="],
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.5", "", {}, "sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ=="], "@tsconfig/svelte": ["@tsconfig/svelte@5.0.5", "", {}, "sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="],
"@types/chai": ["@types/chai@4.3.20", "", {}, "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ=="], "@types/chai": ["@types/chai@4.3.20", "", {}, "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ=="],
@@ -527,7 +569,7 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="], "assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="],
@@ -631,7 +673,7 @@
"colors-named-hex": ["colors-named-hex@1.0.2", "", {}, "sha512-k6kq1e1pUCQvSVwIaGFq2l0LrkAPQZWyeuZn1Z8nOiYSEZiKoFj4qx690h2Kd34DFl9Me0gKS6MUwAMBJj8nuA=="], "colors-named-hex": ["colors-named-hex@1.0.2", "", {}, "sha512-k6kq1e1pUCQvSVwIaGFq2l0LrkAPQZWyeuZn1Z8nOiYSEZiKoFj4qx690h2Kd34DFl9Me0gKS6MUwAMBJj8nuA=="],
"commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="], "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"complex.js": ["complex.js@2.4.2", "", {}, "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g=="], "complex.js": ["complex.js@2.4.2", "", {}, "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g=="],
@@ -693,6 +735,8 @@
"detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
"devalue": ["devalue@5.7.1", "", {}, "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
@@ -773,7 +817,7 @@
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
"esrap": ["esrap@2.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA=="], "esrap": ["esrap@2.2.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
@@ -787,7 +831,7 @@
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"execa": ["execa@9.6.0", "", { "dependencies": { "@sindresorhus/merge-streams": "4.0.0", "cross-spawn": "7.0.6", "figures": "6.1.0", "get-stream": "9.0.1", "human-signals": "8.0.1", "is-plain-obj": "4.1.0", "is-stream": "4.0.1", "npm-run-path": "6.0.0", "pretty-ms": "9.2.0", "signal-exit": "4.1.0", "strip-final-newline": "4.0.0", "yoctocolors": "2.1.2" } }, "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw=="], "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
@@ -995,6 +1039,30 @@
"lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "3.0.6" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="], "lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "3.0.6" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -1125,6 +1193,8 @@
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="],
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -1143,6 +1213,8 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
@@ -1293,6 +1365,8 @@
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
"rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="], "rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="],
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "2.2.0", "xml2js": "0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="], "rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "2.2.0", "xml2js": "0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
@@ -1397,7 +1471,7 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.38.6", "", { "dependencies": { "@jridgewell/remapping": "2.3.5", "@jridgewell/sourcemap-codec": "1.5.5", "@sveltejs/acorn-typescript": "1.0.5", "@types/estree": "1.0.8", "acorn": "8.15.0", "aria-query": "5.3.2", "axobject-query": "4.1.0", "clsx": "2.1.1", "esm-env": "1.2.2", "esrap": "2.1.0", "is-reference": "3.0.3", "locate-character": "3.0.0", "magic-string": "0.30.18", "zimmerframe": "1.1.2" } }, "sha512-ltBPlkvqk3bgCK7/N323atUpP3O3Y+DrGV4dcULrsSn4fZaaNnOmdplNznwfdWclAgvSr5rxjtzn/zJhRm6TKg=="], "svelte": ["svelte@5.55.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ=="],
"symbol-observable": ["symbol-observable@1.2.0", "", {}, "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="], "symbol-observable": ["symbol-observable@1.2.0", "", {}, "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="],
@@ -1423,7 +1497,7 @@
"tinycolor2": ["tinycolor2@1.4.2", "", {}, "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="], "tinycolor2": ["tinycolor2@1.4.2", "", {}, "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="],
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinypool": ["tinypool@0.7.0", "", {}, "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww=="], "tinypool": ["tinypool@0.7.0", "", {}, "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww=="],
@@ -1477,7 +1551,7 @@
"validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="], "validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="],
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "0.25.9", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.49.0", "tinyglobby": "0.2.14" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "jiti": "1.21.7", "sass": "1.91.0", "yaml": "2.8.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], "vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
"vite-node": ["vite-node@0.34.6", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.1", "mlly": "1.8.0", "pathe": "1.1.2", "picocolors": "1.1.1", "vite": "5.4.19" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA=="], "vite-node": ["vite-node@0.34.6", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.1", "mlly": "1.8.0", "pathe": "1.1.2", "picocolors": "1.1.1", "vite": "5.4.19" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA=="],
@@ -1485,7 +1559,7 @@
"vite-plugin-externalize-deps": ["vite-plugin-externalize-deps@0.7.0", "", { "peerDependencies": { "vite": "6.3.5" } }, "sha512-do2gPrR79Tm8UKcqsw3RTAtN4YO8GkVRBckWdJWINZ3Qdp3KN9S1oyUZxKszTB/iyg4zdOUweLOeBI8t86QVow=="], "vite-plugin-externalize-deps": ["vite-plugin-externalize-deps@0.7.0", "", { "peerDependencies": { "vite": "6.3.5" } }, "sha512-do2gPrR79Tm8UKcqsw3RTAtN4YO8GkVRBckWdJWINZ3Qdp3KN9S1oyUZxKszTB/iyg4zdOUweLOeBI8t86QVow=="],
"vitefu": ["vitefu@1.1.1", "", { "optionalDependencies": { "vite": "6.3.5" } }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"vitest": ["vitest@0.34.6", "", { "dependencies": { "@types/chai": "4.3.20", "@types/chai-subset": "1.3.6", "@types/node": "24.3.0", "@vitest/expect": "0.34.6", "@vitest/runner": "0.34.6", "@vitest/snapshot": "0.34.6", "@vitest/spy": "0.34.6", "@vitest/utils": "0.34.6", "acorn": "8.15.0", "acorn-walk": "8.3.4", "cac": "6.7.14", "chai": "4.5.0", "debug": "4.4.1", "local-pkg": "0.4.3", "magic-string": "0.30.18", "pathe": "1.1.2", "picocolors": "1.1.1", "std-env": "3.9.0", "strip-literal": "1.3.0", "tinybench": "2.9.0", "tinypool": "0.7.0", "vite": "5.4.19", "vite-node": "0.34.6", "why-is-node-running": "2.3.0" }, "bin": { "vitest": "vitest.mjs" } }, "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q=="], "vitest": ["vitest@0.34.6", "", { "dependencies": { "@types/chai": "4.3.20", "@types/chai-subset": "1.3.6", "@types/node": "24.3.0", "@vitest/expect": "0.34.6", "@vitest/runner": "0.34.6", "@vitest/snapshot": "0.34.6", "@vitest/spy": "0.34.6", "@vitest/utils": "0.34.6", "acorn": "8.15.0", "acorn-walk": "8.3.4", "cac": "6.7.14", "chai": "4.5.0", "debug": "4.4.1", "local-pkg": "0.4.3", "magic-string": "0.30.18", "pathe": "1.1.2", "picocolors": "1.1.1", "std-env": "3.9.0", "strip-literal": "1.3.0", "tinybench": "2.9.0", "tinypool": "0.7.0", "vite": "5.4.19", "vite-node": "0.34.6", "why-is-node-running": "2.3.0" }, "bin": { "vitest": "vitest.mjs" } }, "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q=="],
@@ -1555,7 +1629,9 @@
"@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@bedframe/core/@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.0.2", "", { "dependencies": { "@rollup/pluginutils": "4.2.1", "@webcomponents/custom-elements": "1.6.0", "acorn-walk": "8.3.4", "cheerio": "1.1.2", "convert-source-map": "1.9.0", "debug": "4.4.1", "es-module-lexer": "0.10.5", "fast-glob": "3.3.3", "fs-extra": "10.1.0", "jsesc": "3.1.0", "magic-string": "0.30.18", "pathe": "2.0.3", "picocolors": "1.1.1", "react-refresh": "0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-BeaVEkCTmna2tzl5DL9nw1kxll1IpIFZ+wbl2+iILz4fNJy1xRD6c1nF8w8/CvrWUuPYTFTpyX9K+A30ISDXHA=="], "@bedframe/cli/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
"@bedframe/core/@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.3.0", "", { "dependencies": { "@rollup/pluginutils": "^4.1.2", "@webcomponents/custom-elements": "^1.5.0", "acorn-walk": "^8.2.0", "cheerio": "^1.0.0-rc.10", "convert-source-map": "^1.7.0", "debug": "^4.3.3", "es-module-lexer": "^0.10.0", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "jsesc": "^3.0.2", "magic-string": "^0.30.12", "pathe": "^2.0.1", "picocolors": "^1.1.1", "react-refresh": "^0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-+0CNVGS4bB30OoaF1vUsHVwWU1Lm7MxI0XWY9Fd/Ob+ZVTZgEFNqJ1ZC69IVwQsoYhY0sMQLvpLWiFIuDz8htg=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -1591,6 +1667,8 @@
"@samverschueren/stream-to-observable/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="], "@samverschueren/stream-to-observable/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
"@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@vitest/runner/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "@vitest/runner/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
@@ -1621,6 +1699,8 @@
"concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"dependency-cruiser/commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"eslint/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "eslint/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
@@ -1633,6 +1713,8 @@
"htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"lightningcss/detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
"listr/is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="], "listr/is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="],
"listr/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="], "listr/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
@@ -1715,9 +1797,13 @@
"tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="], "vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"vite-node/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "vite-node/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
@@ -1725,6 +1811,8 @@
"vite-plugin-dts/@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "1.0.8", "estree-walker": "2.0.2", "picomatch": "4.0.3" }, "optionalDependencies": { "rollup": "4.49.0" } }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="], "vite-plugin-dts/@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "1.0.8", "estree-walker": "2.0.2", "picomatch": "4.0.3" }, "optionalDependencies": { "rollup": "4.49.0" } }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="],
"vite-plugin-dts/vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "0.25.9", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.49.0", "tinyglobby": "0.2.14" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "jiti": "1.21.7", "sass": "1.91.0", "yaml": "2.8.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
"vitest/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "vitest/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"vitest/vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.6", "rollup": "4.49.0" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "sass": "1.91.0" }, "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="], "vitest/vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.6", "rollup": "4.49.0" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "sass": "1.91.0" }, "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="],
@@ -1741,6 +1829,14 @@
"z-schema/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "z-schema/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"@bedframe/cli/vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"@bedframe/cli/vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"@bedframe/cli/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"@bedframe/core/@crxjs/vite-plugin/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
@@ -1865,6 +1961,10 @@
"vite-plugin-dts/@rollup/pluginutils/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="], "vite-plugin-dts/@rollup/pluginutils/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"vite-plugin-dts/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
"vite-plugin-dts/vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"vitest/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], "vitest/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"vitest/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="], "vitest/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
+11 -3
View File
@@ -35,13 +35,16 @@ Upserts the callers settings backup.
```json ```json
{ {
"schemaVersion": 1, "schemaVersion": 1,
"themeId": "uuid-string-or-empty",
"data": { "data": {
"...": "flat key-value map mirroring extension storage (see Payload shape)" "...": "flat key-value map mirroring extension storage (see Payload shape)",
"selectedTheme": "uuid-or-empty-string"
} }
} }
``` ```
- **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations. - **`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`). - **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`).
**Success response:** HTTP `200` (or `201` if you prefer create semantics). Example: **Success response:** HTTP `200` (or `201` if you prefer create semantics). Example:
@@ -67,15 +70,19 @@ Returns the callers latest settings backup.
```json ```json
{ {
"schemaVersion": 1, "schemaVersion": 1,
"themeId": "uuid-string-or-empty",
"data": { }, "data": { },
"updated_at": "2026-04-07T12:00:00.000Z" "updated_at": "2026-04-07T12:00:00.000Z"
} }
``` ```
- **`data`**: required for restore; must be the same shape as accepted in `PUT` (flat map of storage keys). - **`data`**: required for restore; must be the same shape as accepted in `PUT` (flat map of storage keys).
- **`themeId`**: optional; if present must match **`data.selectedTheme`** (see `PUT`).
- **`schemaVersion`**: optional but recommended; should match what was stored. - **`schemaVersion`**: optional but recommended; should match what was stored.
- **`updated_at`**: optional; included for UX if the client shows “last backup” time. - **`updated_at`**: optional; included for UX if the client shows “last backup” time.
The extension resolves **`themeId`** (if non-empty), else **`data.selectedTheme`,** to [`resolveThemeIdForPostSyncDownload`](../src/seqta/utils/cloudSettingsSync.ts) after downloading the envelope — used only to reinstall theme assets from **`betterseqta.org`** when IndexedDB lacks that id (see **BetterSEQTA Cloud** flavour note in **[THEME_STORE_FLAVOURS_API](./THEME_STORE_FLAVOURS_API.md)** section “Cloud settings sync compatibility”).
**No backup yet:** HTTP **`404`**. The extension treats this as “nothing in the cloud” and shows an error to the user. **No backup yet:** HTTP **`404`**. The extension treats this as “nothing in the cloud” and shows an error to the user.
**Error responses:** `401` if the token is invalid, etc. **Error responses:** `401` if the token is invalid, etc.
@@ -113,6 +120,7 @@ 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`. - **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). - **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. - **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_known_remote_updated_at`** — client-only watermark for auto-sync (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. On restore, those keys are **not** taken from the server; the device keeps its current local values.
@@ -128,6 +136,6 @@ This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages,
## Client reference (extension) ## Client reference (extension)
- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, and **`bsplus_cloud_settings_known_remote_updated_at`**). - 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: `applyDownloadedEnvelope` after `GET`; local auth keys, sensitive device keys, and the client-only watermark key are merged back after `chrome.storage.local.clear()`. - 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`. - 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`.
+7 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"version": "3.6.1", "version": "3.7.0",
"type": "module", "type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!", "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
"browserslist": "> 0.5%, last 2 versions, not dead", "browserslist": "> 0.5%, last 2 versions, not dead",
@@ -40,6 +40,8 @@
"@babel/runtime": "^7.26.9", "@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.1.2", "@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.4.0", "@crxjs/vite-plugin": "^2.4.0",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.1.8",
"@types/mime-types": "^3.0.1", "@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@@ -83,8 +85,10 @@
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"color": "^5.0.0", "color": "^5.0.0",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"embeddia": "^1.2.1", "embeddia": "^1.3.0",
"embla-carousel-autoplay": "^8.5.2", "embla-carousel-autoplay": "^8.5.2",
"embla-carousel-svelte": "^8.5.2", "embla-carousel-svelte": "^8.5.2",
"esbuild": "^0.25.3", "esbuild": "^0.25.3",
@@ -92,6 +96,7 @@
"flexsearch": "^0.8.147", "flexsearch": "^0.8.147",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"idb": "^8.0.2", "idb": "^8.0.2",
"layerchart": "2.0.0-next.27",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mathjs": "^14.4.0", "mathjs": "^14.4.0",
+4
View File
@@ -59,6 +59,10 @@ async function init() {
IsSEQTAPage = true; IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page"); console.info("[BetterSEQTA+] Verified SEQTA Page");
if (typeof window !== "undefined" && window === window.top) {
void browser.runtime.sendMessage({ type: "cloudSettingsPoll" }).catch(() => {});
}
registerFetchSeqtaAppLinkListener(); registerFetchSeqtaAppLinkListener();
const documentLoadStyle = document.createElement("style"); const documentLoadStyle = document.createElement("style");
+207 -6
View File
@@ -1,13 +1,30 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import semver from "semver";
import type { SettingsState } from "@/types/storage"; import type { SettingsState } from "@/types/storage";
import { fetchNews } from "./background/news"; import { fetchNews } from "./background/news";
import { import {
initCloudSettingsAutoSync, initCloudSettingsAutoSync,
performCloudSettingsDownloadWithRetry, performCloudSettingsDownloadWithRetry,
performCloudSettingsUploadWithRetry, performCloudSettingsUploadWithRetry,
requestCloudSettingsDebouncedUpload,
runCloudSettingsPoll, runCloudSettingsPoll,
} from "./background/cloudSettingsAutoSync"; } from "./background/cloudSettingsAutoSync";
/**
* 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() { function reloadSeqtaPages() {
const result = browser.tabs.query({}); const result = browser.tabs.query({});
function open(tabs: any) { function open(tabs: any) {
@@ -26,20 +43,68 @@ function reloadSeqtaPages() {
/** Callback for sending a response back to the message sender */ /** Callback for sending a response back to the message sender */
type MessageSender = { (response?: unknown): void }; type MessageSender = { (response?: unknown): void };
/** 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 { function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
const { token } = request; const { token } = request;
const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`; 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 githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(apiUrl, { cache: "no-store", headers }) fetch(apiUrl, { cache: "no-store", headers })
.then((r) => r.json()) .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) .then(sendResponse)
.catch((err) => { .catch((err) => {
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message); console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
fetch(githubUrl, { cache: "no-store" }) fetch(githubUrl, { cache: "no-store" })
.then((r) => r.json()) .then(async (r) => {
.then((data) => sendResponse({ success: true, data: { themes: data.themes ?? [] } })) 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) => { .catch((fallbackErr) => {
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr); console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
sendResponse({ success: false, error: fallbackErr?.message }); sendResponse({ success: false, error: fallbackErr?.message });
@@ -56,7 +121,7 @@ function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boo
} }
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers }) fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers })
.then((r) => r.json()) .then((r) => r.json())
.then(sendResponse) .then(sendResponse)
.catch((err) => { .catch((err) => {
@@ -282,7 +347,7 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean
return false; return false;
} }
const isFavorite = action === "favorite"; const isFavorite = action === "favorite";
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, { fetch(`${apiBase()}/api/themes/${themeId}/favorite`, {
method: isFavorite ? "POST" : "DELETE", method: isFavorite ? "POST" : "DELETE",
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
@@ -309,8 +374,19 @@ function isSeqtaOrigin(origin: string): boolean {
} }
} }
function handleSetDevApiBase(request: any): boolean {
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> = { const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
reloadTabs: () => reloadSeqtaPages(), reloadTabs: () => reloadSeqtaPages(),
setDevApiBase: handleSetDevApiBase,
extensionPages: (req) => { extensionPages: (req) => {
browser.tabs.query({}).then((tabs) => { browser.tabs.query({}).then((tabs) => {
for (const tab of tabs) { for (const tab of tabs) {
@@ -346,6 +422,10 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
void runCloudSettingsPoll(); void runCloudSettingsPoll();
return false; return false;
}, },
cloudSettingsRequestDebouncedUpload: () => {
requestCloudSettingsDebouncedUpload();
return false;
},
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => { getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
(async () => { (async () => {
try { try {
@@ -464,6 +544,7 @@ function getDefaultValues(): SettingsState {
adaptiveThemeColour: false, adaptiveThemeColour: false,
adaptiveThemeGradient: false, adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true, adaptiveThemeColourTransition: true,
themeOfTheMonthDisabled: false,
autoCloudSettingsSync: true, autoCloudSettingsSync: true,
}; };
} }
@@ -474,6 +555,120 @@ function SetStorageValue(object: any) {
} }
} }
/** 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,
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);
}
}
/** 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,
);
}
}
browser.runtime.onInstalled.addListener(function (event) { browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.remove(["justupdated"]); browser.storage.local.remove(["justupdated"]);
browser.storage.local.remove(["data"]); browser.storage.local.remove(["data"]);
@@ -481,6 +676,12 @@ browser.runtime.onInstalled.addListener(function (event) {
if (event.reason == "install" || event.reason == "update") { if (event.reason == "install" || event.reason == "update") {
browser.storage.local.set({ justupdated: true }); browser.storage.local.set({ justupdated: true });
} }
if (event.reason === "update" && event.previousVersion) {
void migrateGlobalSearchDefaultsFor365Upgrade(event.previousVersion);
void resetThemeOfTheMonthDisabledFor366Upgrade(event.previousVersion);
void resetThemeOfTheMonthDismissalFor370Upgrade(event.previousVersion);
}
}); });
initCloudSettingsAutoSync({ reloadSeqtaPages }); initCloudSettingsAutoSync({ reloadSeqtaPages });
+28 -14
View File
@@ -3,8 +3,10 @@ import {
applyDownloadedEnvelope, applyDownloadedEnvelope,
buildUploadPayload, buildUploadPayload,
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
isKeyIncludedInCloudUploadPayload, isKeyIncludedInCloudUploadPayload,
resolveThemeIdForPostSyncDownload,
setKnownRemoteUpdatedAt, setKnownRemoteUpdatedAt,
} from "@/seqta/utils/cloudSettingsSync"; } from "@/seqta/utils/cloudSettingsSync";
@@ -13,9 +15,9 @@ export const CLOUD_SUMMARY_URL = `${ACCOUNTS_BASE}/api/user/cloud-summary`;
const CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`; const CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`;
const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`; const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`;
const ALARM_NAME = "bsplus_cloud_settings_auto_sync";
const PERIOD_MINUTES = 60;
const UPLOAD_DEBOUNCE_MS = 2000; const UPLOAD_DEBOUNCE_MS = 2000;
const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000;
const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll";
type CloudSummaryResponse = { type CloudSummaryResponse = {
desqta?: unknown; desqta?: unknown;
@@ -220,7 +222,15 @@ async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
error: data?.error ?? `Download failed (${r.status})`, error: data?.error ?? `Download failed (${r.status})`,
}; };
} }
const themeIdToEnsure = resolveThemeIdForPostSyncDownload(data);
await applyDownloadedEnvelope(data); await applyDownloadedEnvelope(data);
if (themeIdToEnsure) {
await browser.storage.local.set({
[BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY]: themeIdToEnsure,
});
} else {
await browser.storage.local.remove(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
}
reloadSeqtaPagesFn?.(); reloadSeqtaPagesFn?.();
const updated_at = data?.updated_at as string | undefined; const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at); await setKnownRemoteUpdatedAt(updated_at);
@@ -323,6 +333,9 @@ export function runCloudSettingsPoll(): Promise<void> {
if (pollInFlight) return pollInFlight; if (pollInFlight) return pollInFlight;
pollInFlight = (async () => { pollInFlight = (async () => {
try { 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 browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() });
await runCloudSettingsPollInner(); await runCloudSettingsPollInner();
} catch (e) { } catch (e) {
console.error("[BS+ cloud sync] Poll error:", e); console.error("[BS+ cloud sync] Poll error:", e);
@@ -349,6 +362,17 @@ function scheduleDebouncedUpload(): void {
}, UPLOAD_DEBOUNCE_MS); }, UPLOAD_DEBOUNCE_MS);
} }
/** Call after store theme install (and similar) so cloud upload runs even if storage events are flaky. */
export function requestCloudSettingsDebouncedUpload(): void {
void (async () => {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
if (suppressAutoUploadDuringRestore) return;
if (!(await getAccessToken())) return;
scheduleDebouncedUpload();
})();
}
async function runDebouncedUploadJob(): Promise<void> { async function runDebouncedUploadJob(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>; const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return; if (!isAutoCloudSyncEnabled(all)) return;
@@ -360,14 +384,11 @@ async function runDebouncedUploadJob(): Promise<void> {
} }
} }
async function syncAlarmWithStorage(): Promise<void> { async function syncAutoUploadWithStorage(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>; const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) { if (!isAutoCloudSyncEnabled(all)) {
await browser.alarms.clear(ALARM_NAME);
clearUploadDebounce(); clearUploadDebounce();
return;
} }
await browser.alarms.create(ALARM_NAME, { periodInMinutes: PERIOD_MINUTES });
} }
function onStorageChanged( function onStorageChanged(
@@ -377,7 +398,7 @@ function onStorageChanged(
if (area !== "local") return; if (area !== "local") return;
if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) { if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) {
void syncAlarmWithStorage(); void syncAutoUploadWithStorage();
} }
const keys = Object.keys(changes); const keys = Object.keys(changes);
@@ -392,15 +413,8 @@ function onStorageChanged(
})(); })();
} }
function onAlarm(alarm: browser.Alarms.Alarm): void {
if (alarm.name !== ALARM_NAME) return;
void runCloudSettingsPoll();
}
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void { export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
reloadSeqtaPagesFn = deps.reloadSeqtaPages; reloadSeqtaPagesFn = deps.reloadSeqtaPages;
browser.alarms.onAlarm.addListener(onAlarm);
browser.storage.onChanged.addListener(onStorageChanged); browser.storage.onChanged.addListener(onStorageChanged);
void syncAlarmWithStorage();
} }
+3 -2
View File
@@ -37,8 +37,9 @@
@layer base, override; @layer base, override;
@layer override { @layer override {
* { .legacy-root,
font-family: Rubik, sans-serif !important; .legacy-root * {
font-family: var(--betterseqta-font-family, Rubik), sans-serif !important;
} }
.iconFamily, .iconFamily,
+386 -27
View File
@@ -1,3 +1,4 @@
@use "sass:meta"; @use "sass:meta";
@import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,600,700"); @import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,600,700");
@@ -118,7 +119,8 @@ select option {
#container { #container {
background: var(--auto-background) !important; background: var(--auto-background) !important;
} }
:root * { .legacy-root,
.legacy-root * {
font-family: Rubik, sans-serif !important; font-family: Rubik, sans-serif !important;
--theme-fg-parts: white; --theme-fg-parts: white;
} }
@@ -454,6 +456,58 @@ ul.magicDelete > li.deleting {
top: 71.5px; top: 71.5px;
margin-top: -2px; 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 { #menu section > label {
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
@@ -2325,6 +2379,10 @@ blurred {
height: 64px; height: 64px;
cursor: pointer; 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 { .uiSlidePane > .pane > .header button {
color: var(--text-color) !important; color: var(--text-color) !important;
} }
@@ -2609,7 +2667,7 @@ body {
[class*="MessageList__unread___"] { [class*="MessageList__unread___"] {
position: relative; position: relative;
background: rgb(228 225 225); background: var(--background-secondary, rgb(228 225 225));
} }
.dark [class*="MessageList__unread___"] { .dark [class*="MessageList__unread___"] {
@@ -2735,7 +2793,7 @@ body {
[class*="MessageList__MessageList___"] [class*="MessageList__MessageList___"]
> ol > ol
> li[class*="MessageList__selected___"] { > li[class*="MessageList__selected___"] {
background: rgb(228 225 225); background: var(--background-secondary, rgb(228 225 225));
color: var(--text-primary); color: var(--text-primary);
box-shadow: none !important; box-shadow: none !important;
position: relative; position: relative;
@@ -3501,6 +3559,32 @@ div.day-empty {
color: var(--text-primary); color: var(--text-primary);
transform-origin: center center; 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 { .whatsnewTextContainer.privacyStatement p {
margin-bottom: 1.5ex; margin-bottom: 1.5ex;
@@ -3725,6 +3809,256 @@ div.day-empty {
color: var(--text-primary); color: var(--text-primary);
} }
.themeOfTheMonthCard {
position: fixed;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
z-index: 48;
width: min(360px, calc(100vw - 36px));
overflow: visible;
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);
animation: themeOfTheMonthCardIn 0.24s ease-out;
}
.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);
}
.themeOfTheMonthCardImage {
display: block;
width: 100%;
height: 150px;
margin: 0;
border-radius: 20px 20px 0 0;
object-fit: cover;
}
.themeOfTheMonthCardBody {
padding: 14px 16px 16px;
}
.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 {
display: -webkit-box;
margin: 8px 0 14px;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
font-size: 0.92rem;
line-height: 1.45;
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
}
.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;
transform: translateY(18px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes themeOfTheMonthCardOut {
to {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
}
@media (max-width: 900px) {
.themeOfTheMonthCard {
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 { .popup-media-fullscreenable {
cursor: pointer; cursor: pointer;
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
@@ -4374,38 +4708,63 @@ h2.home-subtitle {
.bsplus-toast { .bsplus-toast {
position: fixed; position: fixed;
bottom: 24px; right: max(18px, env(safe-area-inset-right));
right: 24px; bottom: max(18px, env(safe-area-inset-bottom));
z-index: 10000; z-index: 10000;
display: flex; width: min(360px, calc(100vw - 36px));
align-items: flex-start; padding: 14px 16px 16px;
gap: 12px; border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
max-width: 380px; border-radius: 20px;
padding: 16px 18px; background: var(--background-primary, #fff);
border-radius: 12px;
background: var(--background-secondary, #fff);
color: var(--text-primary, #1a1a1a); color: var(--text-primary, #1a1a1a);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18); box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.5; line-height: 1.45;
} }
.bsplus-toast-content p { .bsplus-toast-eyebrow {
margin: 6px 0 0; margin: 0 0 6px !important;
opacity: 0.8; font-size: 0.72rem !important;
font-size: 0.85rem; font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, #ea580c 82%, var(--text-primary) 18%);
opacity: 1 !important;
}
.dark .bsplus-toast-eyebrow {
color: color-mix(in srgb, #fb923c 82%, var(--text-primary) 18%);
}
.bsplus-toast-content strong {
display: block;
padding-right: 34px;
font-size: 1.2rem;
line-height: 1.2;
}
.bsplus-toast-content p:not(.bsplus-toast-eyebrow) {
display: -webkit-box;
margin: 8px 0 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
font-size: 0.92rem;
line-height: 1.45;
} }
.bsplus-toast-close { .bsplus-toast-close {
flex-shrink: 0; position: absolute !important;
background: none; top: 4px !important;
border: none; right: 4px !important;
color: var(--text-primary, #1a1a1a); z-index: 2;
font-size: 1.3rem; width: 32px;
height: 32px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 16px !important;
background: rgba(0, 0, 0, 0.42);
color: white;
cursor: pointer; cursor: pointer;
padding: 0 2px; font-size: 1.35rem;
line-height: 1; line-height: 1;
opacity: 0.5; transition: filter 0.15s ease;
transition: opacity 0.15s;
} }
.bsplus-toast-close:hover { .bsplus-toast-close:hover {
opacity: 1; filter: brightness(1.08);
} }
@@ -0,0 +1,141 @@
<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>
@@ -0,0 +1,311 @@
/* 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,20 +1,27 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { Theme } from '@/interface/types/Theme'; import type { ThemeCoverSlide } from '@/interface/types/Theme';
import emblaCarouselSvelte from 'embla-carousel-svelte'; import emblaCarouselSvelte from 'embla-carousel-svelte';
import Autoplay from 'embla-carousel-autoplay'; import Autoplay from 'embla-carousel-autoplay';
let { coverThemes, setDisplayTheme } = $props<{ coverThemes: Theme[], setDisplayTheme: (theme: Theme) => void }>(); let { slides, setDisplayTheme } = $props<{
slides: ThemeCoverSlide[];
setDisplayTheme: (theme: import('@/interface/types/Theme').Theme) => void;
}>();
let emblaApi = $state(); let emblaApi = $state();
const options = { loop: true }; const options = $derived({ loop: slides.length > 1 });
const plugins = [ const plugins = $derived(
slides.length > 1
? [
Autoplay({ Autoplay({
delay: 5000, delay: 5000,
stopOnInteraction: false, stopOnInteraction: false,
stopOnMouseEnter: true stopOnMouseEnter: true,
}) }),
]; ]
: [],
);
function onInit(event: CustomEvent) { function onInit(event: CustomEvent) {
emblaApi = event.detail; emblaApi = event.detail;
@@ -26,7 +33,7 @@
const slideNext = () => emblaApi?.scrollNext(); const slideNext = () => emblaApi?.scrollNext();
</script> </script>
{#if coverThemes.length > 0} {#if slides.length > 0}
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade> <div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
<div <div
class="w-full aspect-[5/1] max-h-[500px]" class="w-full aspect-[5/1] max-h-[500px]"
@@ -34,49 +41,69 @@
onemblaInit={onInit} onemblaInit={onInit}
> >
<div class="flex"> <div class="flex">
{#each coverThemes as theme} {#each slides as slide (slide.imageUrl + slide.title + (slide.subtitle ?? ''))}
<div <div
class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip" class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip"
role="button" role="button"
tabindex="0" tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }} onkeydown={(e) => {
onclick={() => setDisplayTheme(theme)} if (e.key === 'Enter') setDisplayTheme(slide.openTheme);
}}
onclick={() => setDisplayTheme(slide.openTheme)}
> >
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-full" /> <img src={slide.imageUrl} alt="" class="object-cover w-full h-full" />
{#if theme.featured === true} {#if slide.badgeFeatured === true}
<div class="absolute top-4 left-4 z-[2] pointer-events-none"> <div class="absolute top-4 left-4 z-[2] pointer-events-none">
<span <span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm" class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm"
aria-label="Featured theme" aria-label="Featured theme"
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" /> <path
fill-rule="evenodd"
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
clip-rule="evenodd"
/>
</svg> </svg>
Featured Featured
</span> </span>
</div> </div>
{/if} {/if}
<div class='absolute bottom-0 left-0 p-8 z-[1]'> <div class="absolute bottom-0 left-0 p-8 z-[1]">
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2> <h2 class="text-4xl font-bold text-white">{slide.title}</h2>
{#if theme.author} {#if slide.subtitle}
<p class="text-sm text-white/90 mt-1 mb-1 line-clamp-1">By {theme.author}</p> <p class="text-lg font-medium text-white/95 mt-1 line-clamp-2">{slide.subtitle}</p>
{/if}
{#if slide.openTheme.author && !slide.subtitle}
<p class="text-sm text-white/90 mt-1 mb-1 line-clamp-1">By {slide.openTheme.author}</p>
{/if}
{#if slide.openTheme.description && !slide.subtitle}
<p class="text-lg text-white line-clamp-3">{slide.openTheme.description}</p>
{/if} {/if}
<p class='text-lg text-white'>{theme.description}</p>
</div> </div>
<div class='absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80'></div> <div class="absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80"></div>
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
<!-- Navigation buttons --> <div class="flex absolute right-2 bottom-2 z-10 gap-2">
<div class='flex absolute right-2 bottom-2 z-10 gap-2'> <button
<button aria-label="Previous" onclick={slidePrev} class='flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800'> aria-label="Previous"
onclick={slidePrev}
type="button"
class="flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800 transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
</svg> </svg>
</button> </button>
<button aria-label="Next" onclick={slideNext} class='flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800'> <button
aria-label="Next"
onclick={slideNext}
type="button"
class="flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800 transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg> </svg>
@@ -1,14 +1,54 @@
<script lang="ts"> <script lang="ts">
import type { Theme } from '@/interface/types/Theme' import type { Theme } from '@/interface/types/Theme'
import {
masterGridDisplayDownloadCount,
gridCardPreviewImageUrls,
} from '@/interface/utils/themeStoreFlavours'
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{ import emblaCarouselSvelte from 'embla-carousel-svelte';
import Autoplay from 'embla-carousel-autoplay';
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn, allStoreThemeRows } = $props<{
theme: Theme; theme: Theme;
onClick: () => void; onClick: () => void;
toggleFavorite: (theme: Theme) => void; toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean; isLoggedIn: boolean;
onRequestSignIn?: () => void; onRequestSignIn?: () => void;
/** Raw API themes (includes hidden slaves) for aggregated master download totals */
allStoreThemeRows?: Theme[];
}>(); }>();
const displayDownloadCount = $derived(
allStoreThemeRows != null
? masterGridDisplayDownloadCount(theme, allStoreThemeRows)
: (theme.download_count ?? 0),
);
const gridRotatorUrls = $derived(gridCardPreviewImageUrls(theme, allStoreThemeRows));
/** Mirrors CoverSwiper (featured bar): horizontal slides + autoplay */
function prefersReducedMotion(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/** Read once synchronously where `window` exists so reduced-motion doesnt briefly mount carousel */
let allowSlideAutoplay = $state(!prefersReducedMotion());
const gridEmblaKey = $derived(gridRotatorUrls.join('|'));
const gridEmblaOptions = $derived({ loop: gridRotatorUrls.length > 1 });
const gridEmblaPlugins = $derived.by(() => {
if (!allowSlideAutoplay || gridRotatorUrls.length <= 1) return [];
return [
Autoplay({
delay: 2000,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
];
});
let menuOpen = $state(false); let menuOpen = $state(false);
let menuRef: HTMLDivElement; let menuRef: HTMLDivElement;
@@ -111,7 +151,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg> </svg>
{(theme.download_count ?? 0).toLocaleString()} {displayDownloadCount.toLocaleString()}
</span> </span>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
@@ -122,8 +162,40 @@
</div> </div>
</div> </div>
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div> <div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
<div class='w-full'> {#if gridRotatorUrls.length === 0}
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" /> <div class="relative w-full h-48 overflow-hidden rounded-md bg-zinc-200 dark:bg-zinc-700" aria-hidden="true"></div>
{:else if !allowSlideAutoplay || gridRotatorUrls.length === 1}
<div class="relative w-full h-48 overflow-hidden rounded-md">
<img
src={gridRotatorUrls[0] ?? theme.marqueeImage ?? theme.coverImage}
alt=""
class="object-cover w-full h-full"
draggable="false"
/>
</div>
{:else}
{#key gridEmblaKey}
<div
class="relative w-full h-48 overflow-hidden rounded-md"
use:emblaCarouselSvelte={{
options: gridEmblaOptions,
plugins: gridEmblaPlugins,
}}
>
<div class="flex h-full">
{#each gridRotatorUrls as url (url)}
<div class="relative flex-[0_0_100%] min-w-0 h-full shrink-0">
<img
src={url}
alt=""
class="object-cover w-full h-full select-none"
draggable="false"
/>
</div>
{/each}
</div> </div>
</div> </div>
{/key}
{/if}
</div>
</div> </div>
@@ -2,18 +2,31 @@
import type { Theme } from '@/interface/types/Theme' import type { Theme } from '@/interface/types/Theme'
import ThemeCard from './ThemeCard.svelte'; import ThemeCard from './ThemeCard.svelte';
let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{ let {
themes,
searchTerm,
setDisplayTheme,
toggleFavorite,
isLoggedIn,
onRequestSignIn,
allStoreThemeRows,
} = $props<{
themes: Theme[]; themes: Theme[];
searchTerm: string; searchTerm: string;
setDisplayTheme: (theme: Theme) => void; setDisplayTheme: (theme: Theme) => void;
toggleFavorite: (theme: Theme) => void; toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean; isLoggedIn: boolean;
onRequestSignIn?: () => void; onRequestSignIn?: () => void;
/** Raw API list (includes `slave` rows) for master download aggregation */
allStoreThemeRows?: Theme[];
}>(); }>();
let filteredThemes = $derived(themes.filter((theme: Theme) => let filteredThemes = $derived(themes.filter((theme: Theme) => {
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase()) const q = searchTerm.toLowerCase();
)); const name = (theme.name ?? '').toLowerCase();
const description = (theme.description ?? '').toLowerCase();
return name.includes(q) || description.includes(q);
}));
</script> </script>
<div class="relative" > <div class="relative" >
@@ -25,6 +38,7 @@
{toggleFavorite} {toggleFavorite}
{isLoggedIn} {isLoggedIn}
{onRequestSignIn} {onRequestSignIn}
{allStoreThemeRows}
/> />
{/each} {/each}
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -15,6 +15,7 @@
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup"; //import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
import ColourPicker from "../components/ColourPicker.svelte"; import ColourPicker from "../components/ColourPicker.svelte";
import FontPickerModal from "../components/FontPickerModal.svelte";
import CloudPanel from "../components/CloudPanel.svelte"; import CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte"; import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "../hooks/SettingsPopup"; import { settingsPopup } from "../hooks/SettingsPopup";
@@ -47,6 +48,10 @@
showColourPicker = true; showColourPicker = true;
}; };
const openFontPicker = () => {
showFontPicker = true;
};
const openChangelog = () => { const openChangelog = () => {
OpenWhatsNewPopup(); OpenWhatsNewPopup();
closeExtensionPopup(); closeExtensionPopup();
@@ -69,6 +74,7 @@
let { standalone } = $props<{ standalone?: boolean }>(); let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state<boolean>(false); let showColourPicker = $state<boolean>(false);
let showFontPicker = $state<boolean>(false);
let showCloudPanel = $state<boolean>(false); let showCloudPanel = $state<boolean>(false);
const openCloudPanel = () => { const openCloudPanel = () => {
@@ -85,6 +91,7 @@
onMount(() => { onMount(() => {
settingsPopup.addListener(() => { settingsPopup.addListener(() => {
showColourPicker = false; showColourPicker = false;
showFontPicker = false;
showCloudPanel = false; showCloudPanel = false;
}); });
@@ -95,7 +102,7 @@
</script> </script>
<div <div
class="w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode class="relative w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode
? 'dark' ? 'dark'
: ''} {standalone ? 'h-[600px]' : 'h-full rounded-xl'} overflow-clip" : ''} {standalone ? 'h-[600px]' : 'h-full rounded-xl'} overflow-clip"
> >
@@ -293,7 +300,7 @@
{ {
title: "Settings", title: "Settings",
Content: Settings, Content: Settings,
props: { showColourPicker: openColourPicker, showDisclaimer, showCloudPanel: openCloudPanel }, props: { showColourPicker: openColourPicker, showFontPicker: openFontPicker, showDisclaimer, showCloudPanel: openCloudPanel },
}, },
{ title: "Shortcuts", Content: Shortcuts }, { title: "Shortcuts", Content: Shortcuts },
{ title: "Themes", Content: Theme }, { title: "Themes", Content: Theme },
@@ -318,6 +325,14 @@
{/if} {/if}
</div> </div>
{#if showFontPicker}
<FontPickerModal
hidePicker={() => {
showFontPicker = false;
}}
/>
{/if}
{#if showDisclaimerModal && disclaimerCallbacks} {#if showDisclaimerModal && disclaimerCallbacks}
<DisclaimerModal <DisclaimerModal
title={disclaimerTitle} title={disclaimerTitle}
+95 -2
View File
@@ -15,10 +15,37 @@
import CloudHeader from "@/interface/components/store/CloudHeader.svelte" import CloudHeader from "@/interface/components/store/CloudHeader.svelte"
import { cloudAuth } from "@/seqta/utils/CloudAuth" import { cloudAuth } from "@/seqta/utils/CloudAuth"
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification" import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
import { showThemeOfTheMonthPopupNow } from "@/seqta/utils/Openers/OpenThemeOfTheMonthPopup"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup" import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync" import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase"
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 { getAllPluginSettings } from "@/plugins"
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types" import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
// Union type representing all possible settings // Union type representing all possible settings
@@ -53,7 +80,9 @@
settings: Record<string, SettingType>; settings: Record<string, SettingType>;
} }
const pluginSettings = getAllPluginSettings() as Plugin[]; const pluginSettings = getAllPluginSettings().filter(
(plugin) => !(isSeqtaEngageExperience() && plugin.pluginId === "global-search"),
) as Plugin[];
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({}); const pluginSettingsValues = $state<Record<string, Record<string, any>>>({});
let cloudState = $state(cloudAuth.state); let cloudState = $state(cloudAuth.state);
@@ -103,8 +132,9 @@
loadPluginSettings(); loadPluginSettings();
}) })
const { showColourPicker, showDisclaimer, showCloudPanel } = $props<{ const { showColourPicker, showFontPicker, showDisclaimer, showCloudPanel } = $props<{
showColourPicker: () => void; showColourPicker: () => void;
showFontPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void; showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void;
showCloudPanel: () => void; showCloudPanel: () => void;
}>(); }>();
@@ -163,6 +193,16 @@
onClick: showColourPicker 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", title: "Icon Only Sidebar",
description: "Show only icons in the sidebar for a compact layout", description: "Show only icons in the sidebar for a compact layout",
@@ -394,6 +434,18 @@
{/each} {/each}
{/if} {/if}
</div> </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> </div>
{/each} {/each}
@@ -483,6 +535,22 @@
/> />
</div> </div>
</div> </div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">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="flex justify-between items-center px-4 py-3">
<div class="pr-4"> <div class="pr-4">
<h2 class="text-sm font-bold">Export cloud settings JSON</h2> <h2 class="text-sm font-bold">Export cloud settings JSON</h2>
@@ -492,6 +560,31 @@
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" /> <Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
</div> </div>
</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> </div>
{/if} {/if}
</div> </div>
+133 -34
View File
@@ -7,6 +7,7 @@
import SkeletonLoader from '../components/SkeletonLoader.svelte'; import SkeletonLoader from '../components/SkeletonLoader.svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState' import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import type { Theme } from '../types/Theme' import type { Theme } from '../types/Theme'
import { visibleStoreThemes, buildCoverSlidesForThemes, normalizeStoreTheme } from '@/interface/utils/themeStoreFlavours'
import browser from 'webextension-polyfill' import browser from 'webextension-polyfill'
import ThemeModal from '../components/store/ThemeModal.svelte' import ThemeModal from '../components/store/ThemeModal.svelte'
import Header from '../components/store/Header.svelte' import Header from '../components/store/Header.svelte'
@@ -17,6 +18,7 @@
import Backgrounds from '../components/store/Backgrounds.svelte' import Backgrounds from '../components/store/Backgrounds.svelte'
import { cloudAuth } from '@/seqta/utils/CloudAuth' import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte' import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
import { consumePendingHighlightThemeId } from '@/seqta/utils/openThemeStoreWithHighlight'
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn); let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
@@ -26,7 +28,12 @@
// State variables // State variables
let searchTerm = $state(''); let searchTerm = $state('');
let themes = $state<Theme[]>([]); let themes = $state<Theme[]>([]);
let coverThemes = $state<Theme[]>([]);
/** Grid/search/cover: hides flat-listed slaves when API sends them */
let listThemes = $derived(visibleStoreThemes(themes));
/** Cover marquee slides (master + flavour imagery for top masters) */
let coverSlides = $derived(buildCoverSlidesForThemes(listThemes.slice(0, 3)));
let loading = $state(true); let loading = $state(true);
let darkMode = $state(false); let darkMode = $state(false);
let displayTheme = $state<Theme | null>(null); let displayTheme = $state<Theme | null>(null);
@@ -34,9 +41,22 @@
let activeTab = $state('themes'); let activeTab = $state('themes');
let error = $state<string | null>(null); let error = $state<string | null>(null);
let fetchAttempt = $state(0);
let selectedBackground = $state<string | null>(null); let selectedBackground = $state<string | null>(null);
let showSignInOverlay = $state(false); 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 fetchCurrentThemes = async () => {
const themes = await themeManager.getAvailableThemes(); const themes = await themeManager.getAvailableThemes();
currentThemes = themes.filter(theme => theme !== null).map(theme => theme.id); currentThemes = themes.filter(theme => theme !== null).map(theme => theme.id);
@@ -93,48 +113,113 @@
}; };
// Fetch themes via background script (avoids CORS when store runs inside SEQTA page) // Fetch themes via background script (avoids CORS when store runs inside SEQTA page)
const fetchThemes = async () => { const fetchThemes = async (isRetry = false) => {
if (!isRetry) {
fetchAttempt = 0;
error = null;
}
try { try {
const token = await cloudAuth.getStoredToken(); const token = await cloudAuth.getStoredToken();
const data = (await browser.runtime.sendMessage({ const data = await sendMessageWithTimeout<{
success?: boolean;
data?: { themes: unknown[] };
error?: string;
}>({
type: 'fetchThemes', type: 'fetchThemes',
token: token ?? undefined, token: token ?? undefined,
})) as { });
success?: boolean; if (!data?.success || !Array.isArray(data?.data?.themes)) {
data?: { themes: Theme[] };
error?: string;
};
if (!data?.success || !data?.data?.themes) {
throw new Error(data?.error || 'Failed to fetch themes'); throw new Error(data?.error || 'Failed to fetch themes');
} }
themes = [...data.data.themes].sort(compareStoreThemes); themes = data.data.themes
coverThemes = themes.slice(0, 3); .map((row) => normalizeStoreTheme(row as Record<string, unknown>))
.filter((t) => t.id.length > 0)
.sort(compareStoreThemes);
error = null;
loading = false; loading = false;
} catch (err) { } catch (err) {
console.error('Failed to fetch themes', err); console.error('Failed to fetch themes', err);
setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs 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);
} }
}; };
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 // On mount
onMount(async () => { onMount(async () => {
window.addEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
await fetchThemes(); await fetchThemes();
await fetchCurrentThemes(); await fetchCurrentThemes();
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true'; darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
darkMode = $settingsState.DarkMode; 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) // Filter themes (list is already featured-first, then newest; filter preserves order)
let filteredThemes = $derived( let filteredThemes = $derived(
themes.filter( listThemes.filter((theme) => {
(theme) => const q = searchTerm.toLowerCase();
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || const name = (theme.name ?? '').toLowerCase();
theme.description.toLowerCase().includes(searchTerm.toLowerCase()), const description = (theme.description ?? '').toLowerCase();
), return name.includes(q) || description.includes(q);
}),
); );
async function installThemeFromStore(themeId: string, meta: Theme) {
const fullRow = themes.find((x) => x.id === themeId);
if (fullRow) {
await themeManager.downloadTheme(fullRow);
} else {
const flavour = meta.flavours?.find((f) => f.id === themeId);
await themeManager.downloadTheme({
id: themeId,
name: flavour?.name ?? meta.name,
} as Theme);
}
await themeManager.setTheme(themeId);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
void browser.runtime.sendMessage({ type: 'cloudSettingsRequestDebouncedUpload' }).catch(() => {});
}
async function removeThemeFromStore(themeId: string) {
await themeManager.deleteTheme(themeId);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
$effect(() => { $effect(() => {
loadBackground(); loadBackground();
selectedBackground selectedBackground
@@ -166,18 +251,40 @@
<!-- Loading State --> <!-- Loading State -->
{#if loading} {#if loading}
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3"> <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" /> <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>
</div> </div>
{:else} {:else}
<!-- Themes Tab Content --> <!-- Themes Tab Content -->
{#if activeTab === 'themes'} {#if activeTab === 'themes'}
{#if searchTerm === ''} {#if searchTerm === ''}
<CoverSwiper {coverThemes} {setDisplayTheme} /> <CoverSwiper slides={coverSlides} {setDisplayTheme} />
{/if} {/if}
<!-- ThemeGrid to display filtered themes --> <!-- ThemeGrid to display filtered themes -->
<ThemeGrid <ThemeGrid
themes={filteredThemes} themes={filteredThemes}
allStoreThemeRows={themes}
{searchTerm} {searchTerm}
{setDisplayTheme} {setDisplayTheme}
{toggleFavorite} {toggleFavorite}
@@ -188,28 +295,20 @@
{#if displayTheme} {#if displayTheme}
<ThemeModal <ThemeModal
currentThemes={currentThemes} currentThemes={currentThemes}
allThemes={themes} allThemes={listThemes}
allStoreThemeRows={themes}
theme={displayTheme} theme={displayTheme}
{displayTheme} {displayTheme}
{setDisplayTheme} {setDisplayTheme}
{toggleFavorite} {toggleFavorite}
isLoggedIn={cloudLoggedIn} isLoggedIn={cloudLoggedIn}
onRequestSignIn={() => (showSignInOverlay = true)} onRequestSignIn={() => (showSignInOverlay = true)}
onInstall={async () => { onInstall={async (themeId: string) => {
if (displayTheme) { if (displayTheme) await installThemeFromStore(themeId, displayTheme);
await themeManager.downloadTheme(displayTheme);
await themeManager.setTheme(displayTheme.id);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
}} }}
onRemove={async () => { onRemove={async (themeId: string) => {
if (displayTheme?.id) { console.debug('deleting theme', themeId);
console.debug('deleting theme', displayTheme.id); await removeThemeFromStore(themeId);
await themeManager.deleteTheme(displayTheme.id);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
}} }}
/> />
{/if} {/if}
+32
View File
@@ -1,3 +1,17 @@
export type ThemeRole = "standard" | "master" | "slave";
/** List/detail metadata for variants of a master theme (full theme.json fetched at install by id). */
export type ThemeFlavour = {
id: string;
name: string;
/** Mirrors theme.json accent (e.g. defaultColour); used for install picker buttons */
accent_color: string;
cover_image: string;
marquee_image?: string;
/** Per-variant installs when slaves are not returned as flat `theme_role` rows */
download_count?: number;
};
export type Theme = { export type Theme = {
id: string; id: string;
name: string; name: string;
@@ -15,4 +29,22 @@ export type Theme = {
created_at?: number; created_at?: number;
/** Unix seconds — last server update (GET /api/themes). */ /** Unix seconds — last server update (GET /api/themes). */
updated_at?: number; updated_at?: number;
/** Omitted / `standard` — show in grid. `slave` hides from grid. `master` can list `flavours`. */
theme_role?: ThemeRole;
/** Present when `theme_role === "slave"` and API returns a flat list during migration */
master_id?: string;
/** Variants nested on master rows; installs use flavour `id` */
flavours?: ThemeFlavour[];
};
/** One marquee slide (cover hero or modal carousel). */
export type ThemeCoverSlide = {
imageUrl: string;
/** Main line — usually master name */
title: string;
/** Subline — flavour name when applicable */
subtitle?: string;
/** Opening the modal uses this theme (always the grid row / master object) */
openTheme: Theme;
badgeFeatured?: boolean;
}; };
+8 -3
View File
@@ -4,16 +4,21 @@ import type { Action } from "svelte/action";
* Svelte action that moves the element to a different DOM target. * Svelte action that moves the element to a different DOM target.
* Defaults to the nearest ShadowRoot so styles remain intact when the app * Defaults to the nearest ShadowRoot so styles remain intact when the app
* is rendered inside a shadow DOM. Falls back to document.body otherwise. * is rendered inside a shadow DOM. Falls back to document.body otherwise.
* Keeps all Svelte reactivity/events intact while escaping ancestor stacking contexts. * Pass `document.body` to escape transformed/contained settings popups entirely.
*/ */
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 root = node.getRootNode();
const dest = target ?? (root instanceof ShadowRoot ? root : document.body); const dest = target ?? (root instanceof ShadowRoot ? root : document.body);
dest.appendChild(node); dest.appendChild(node);
return { return {
update(newTarget) { update(newTarget) {
(newTarget ?? dest).appendChild(node); const nextDest =
newTarget ?? (root instanceof ShadowRoot ? root : document.body);
nextDest.appendChild(node);
}, },
destroy() { destroy() {
node.remove(); node.remove();
+74
View File
@@ -0,0 +1,74 @@
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"),
);
}
+246
View File
@@ -0,0 +1,246 @@
import type { Theme, ThemeCoverSlide, ThemeFlavour } from "@/interface/types/Theme";
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;
}
function marqueeOrCoverUrl(t: { marqueeImage?: string; coverImage: string }): string {
return t.marqueeImage || t.coverImage;
}
/**
* Builds slides for CoverSwiper: for each top-N master, first master image then each flavour image.
*/
export function buildCoverSlidesForThemes(topThemes: Theme[]): ThemeCoverSlide[] {
const slides: ThemeCoverSlide[] = [];
for (const theme of topThemes) {
const flavours = theme.flavours ?? [];
if (flavours.length === 0) {
slides.push({
imageUrl: marqueeOrCoverUrl(theme),
title: theme.name,
subtitle: theme.author ? `By ${theme.author}` : undefined,
openTheme: theme,
badgeFeatured: theme.featured === true,
});
continue;
}
slides.push({
imageUrl: marqueeOrCoverUrl(theme),
title: theme.name,
subtitle: theme.author ? `By ${theme.author}` : undefined,
openTheme: theme,
badgeFeatured: theme.featured === true,
});
for (const f of flavours) {
slides.push({
imageUrl: f.marquee_image || f.cover_image,
title: theme.name,
subtitle: f.name,
openTheme: theme,
badgeFeatured: theme.featured === true,
});
}
}
return slides;
}
export type ModalHeroSlide = { imageUrl: string; caption: string };
/** Preview image for carousel + flavour picker (matches hero slide order after master slide). */
export function flavourCarouselImageUrl(f: ThemeFlavour): string {
const u = (f.marquee_image || f.cover_image || "").trim();
return u;
}
/** Preview image for master variant tile (modal hero slide 0). */
export function masterCarouselImageUrl(t: Theme): string {
return (marqueeOrCoverUrl(t) || "").trim();
}
/**
* Ordered preview URLs for the store grid card rotator: master first, then each variant.
* Uses nested `flavours` when present; otherwise flat `slave` rows (same order as modal when possible).
*/
export function gridCardPreviewImageUrls(theme: Theme, allStoreRows?: Theme[]): string[] {
const urls: string[] = [];
const push = (raw: string) => {
const u = raw.trim();
if (u && !urls.includes(u)) urls.push(u);
};
push(marqueeOrCoverUrl(theme) || "");
const flavours = theme.flavours ?? [];
if (flavours.length > 0) {
for (const f of flavours) {
push(flavourCarouselImageUrl(f));
}
return urls.length > 0 ? urls : [(theme.coverImage || "").trim()].filter(Boolean);
}
if (allStoreRows) {
const slaves = allStoreRows
.filter((t) => t.theme_role === "slave" && t.master_id === theme.id)
.sort((a, b) => a.id.localeCompare(b.id));
for (const s of slaves) {
push((marqueeOrCoverUrl(s) || "").trim());
}
}
if (urls.length > 0) return urls;
const fallback = (theme.coverImage || "").trim();
return fallback ? [fallback] : [];
}
/**
* Downloads shown on the grid card for a master row: master's count plus each slave
* (flat `theme_role === "slave"` with `master_id`) and any flavour-only `download_count`
* when there is no matching flat slave id (nested-only API shape).
*/
export function masterGridDisplayDownloadCount(master: Theme, allStoreRows: Theme[]): number {
let total = master.download_count ?? 0;
const slaveRows = allStoreRows.filter(
(t) => t.theme_role === "slave" && t.master_id === master.id,
);
const countedIds = new Set(slaveRows.map((r) => r.id));
for (const r of slaveRows) {
total += r.download_count ?? 0;
}
for (const f of master.flavours ?? []) {
if (countedIds.has(f.id)) continue;
total += f.download_count ?? 0;
}
return total;
}
/** Modal hero: master first, then each flavour (plan order). */
export function buildModalHeroSlides(theme: Theme): ModalHeroSlide[] {
const slides: ModalHeroSlide[] = [];
slides.push({
imageUrl: marqueeOrCoverUrl(theme),
caption: theme.name,
});
const flavours = theme.flavours ?? [];
for (const f of flavours) {
slides.push({
imageUrl: f.marquee_image || f.cover_image,
caption: `${theme.name}${f.name}`,
});
}
return slides;
}
/**
* Relative luminance 01 for rgba/rgb/hex-ish strings; fallback 0.5 white text
*/
export function pickContrastingTextColor(backgroundCss: string): "#ffffff" | "#0a0a0a" {
const s = backgroundCss.trim();
const rgba = s.match(
/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i,
);
if (rgba) {
const r = Number(rgba[1]) / 255;
const g = Number(rgba[2]) / 255;
const b = Number(rgba[3]) / 255;
const a = rgba[4] !== undefined ? Number(rgba[4]) : 1;
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const effective = lum * a + 0.05 * (1 - a);
return effective > 0.45 ? "#0a0a0a" : "#ffffff";
}
const hex = s.match(/^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
if (hex) {
const r = parseInt(hex[1], 16) / 255;
const g = parseInt(hex[2], 16) / 255;
const b = parseInt(hex[3], 16) / 255;
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return lum > 0.45 ? "#0a0a0a" : "#ffffff";
}
return "#ffffff";
}
+1 -1
View File
@@ -15,7 +15,7 @@
"64": "resources/icons/icon-64.png" "64": "resources/icons/icon-64.png"
} }
}, },
"permissions": ["tabs", "notifications", "storage", "alarms"], "permissions": ["tabs", "notifications", "storage"],
"host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"], "host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"],
"background": { "background": {
"service_worker": "background.ts" "service_worker": "background.ts"
@@ -0,0 +1,46 @@
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)}`;
}
@@ -7,19 +7,21 @@ import {
import { type Plugin } from "@/plugins/core/types"; import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML"; import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import { import {
clearStuck, clearStuck,
getClassByPattern, getClassByPattern,
initStorage, initStorage,
injectWeightingsTab,
letterToNumber, letterToNumber,
parseAssessments, parseAssessments,
processAssessments, processAssessments,
} from "./utils.ts"; } from "./utils.ts";
import { injectRubricCopyButtons } from "./rubricCopy.ts";
interface weightingsStorage { interface weightingsStorage {
weightings: Record<string, string>; weightings: Record<string, string>;
assessments: Record<string, string>; assessments: Record<string, string>;
weightingOverrides: Record<string, string>;
} }
const settings = defineSettings({ const settings = defineSettings({
@@ -37,6 +39,8 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
const instance = new AssessmentsAveragePluginClass(); const instance = new AssessmentsAveragePluginClass();
let overrideListenerController: AbortController | null = null;
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = { const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
id: "assessments-average", id: "assessments-average",
name: "Assessment Averages", name: "Assessment Averages",
@@ -58,17 +62,57 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
); );
await parseAssessments(api); await parseAssessments(api);
await renderSubjectAverage(api);
overrideListenerController?.abort();
overrideListenerController = new AbortController();
document.addEventListener(
"betterseqta:overrideChanged",
() => renderSubjectAverage(api),
{ signal: overrideListenerController.signal },
);
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
const observer = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
}
});
api.seqta.onMount("[class*='SelectedAssessment__']", () => {
injectWeightingsTab(api);
injectRubricCopyButtons();
});
},
};
let renderInFlight = false;
async function renderSubjectAverage(api: any) {
if (renderInFlight) return;
renderInFlight = true;
try {
const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
if (!assessmentsList) return;
// Remove existing subject average before re-rendering
Array.from(
assessmentsList.querySelectorAll(`[class*='AssessmentItem__title___']`),
)
.find((el) => el.textContent === "Subject Average")
?.closest("[class*='AssessmentItem__AssessmentItem___']")
?.remove();
const sampleAssessmentItem = document.querySelector( const sampleAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___']", "[class*='AssessmentItem__AssessmentItem___']",
); );
if (!sampleAssessmentItem) return; if (!sampleAssessmentItem) return;
const assessmentItemClass = const assessmentItemClass =
Array.from(sampleAssessmentItem.classList).find((c) => Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"), c.startsWith("AssessmentItem__AssessmentItem___"),
) || ""; ) || "";
const metaContainerClass = getClassByPattern( const metaContainerClass = getClassByPattern(
sampleAssessmentItem, sampleAssessmentItem,
"AssessmentItem__metaContainer___", "AssessmentItem__metaContainer___",
@@ -86,35 +130,6 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
"AssessmentItem__title___", "AssessmentItem__title___",
); );
const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
if (!thermoscoreElement) return;
const thermoscoreClass =
Array.from(thermoscoreElement.classList).find((c) =>
c.startsWith("Thermoscore__Thermoscore___"),
) || "";
const fillClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__fill___",
);
const textClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__text___",
);
const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
if (!assessmentsList) return;
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
if (!marks || !marks.length) return;
const assessmentItems = Array.from( const assessmentItems = Array.from(
assessmentsList.querySelectorAll( assessmentsList.querySelectorAll(
`[class*='AssessmentItem__AssessmentItem___']`, `[class*='AssessmentItem__AssessmentItem___']`,
@@ -128,9 +143,25 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } = const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
await processAssessments(api, assessmentItems); await processAssessments(api, assessmentItems);
if (!count || totalWeight === 0) return; if (!count || totalWeight === 0) return;
const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
if (!thermoscoreElement) return;
const thermoscoreClass =
Array.from(thermoscoreElement.classList).find((c) =>
c.startsWith("Thermoscore__Thermoscore___"),
) || "";
const fillClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__fill___",
);
const textClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__text___",
);
const avg = weightedTotal / totalWeight; const avg = weightedTotal / totalWeight;
const rounded = Math.ceil(avg / 5) * 5; const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce( const numberToLetter = Object.entries(letterToNumber).reduce(
@@ -140,17 +171,8 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
}, },
{} as Record<number, string>, {} as Record<number, string>,
); );
const letterAvg = numberToLetter[rounded] ?? "N/A"; const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
? letterAvg
: `${avg.toFixed(2)}%`;
const existing = assessmentsList.querySelector(
`[class*='AssessmentItem__title___']`,
);
if (existing?.textContent === "Subject Average") return;
let warningHTML = ""; let warningHTML = "";
if (hasInaccurateWeighting) { if (hasInaccurateWeighting) {
warningHTML = /* html */ ` warningHTML = /* html */ `
@@ -159,7 +181,6 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
</div> </div>
`; `;
} }
assessmentsList.insertBefore( assessmentsList.insertBefore(
stringToHTML(/* html */ ` stringToHTML(/* html */ `
<div class="${assessmentItemClass}"> <div class="${assessmentItemClass}">
@@ -180,21 +201,11 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
`).firstChild!, `).firstChild!,
assessmentsList.firstChild, assessmentsList.firstChild,
); );
applySubjectColourToOverallResult(); applySubjectColourToOverallResult();
} finally {
const observer = new MutationObserver(() => { renderInFlight = false;
applySubjectColourToOverallResult(); }
});
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
} }
});
},
};
function applySubjectColourToOverallResult() { function applySubjectColourToOverallResult() {
const selectedAssessmentItem = document.querySelector( const selectedAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']", "[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
@@ -0,0 +1,388 @@
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;
}
+474 -58
View File
@@ -1,5 +1,11 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts"; import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
import ReactFiber from "@/seqta/utils/ReactFiber.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 { import {
ensurePdfjsWorker, ensurePdfjsWorker,
getPdfjsPageContextUrls, getPdfjsPageContextUrls,
@@ -17,6 +23,9 @@ export async function initStorage(api: any) {
if (!api.storage.assessments) { if (!api.storage.assessments) {
api.storage.assessments = {}; api.storage.assessments = {};
} }
if (!api.storage.weightingOverrides) {
api.storage.weightingOverrides = {};
}
} }
export function clearStuck(api: any) { export function clearStuck(api: any) {
@@ -75,56 +84,179 @@ function parseGrade(text: string): number {
return letterToNumber[str] ?? 0; 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,
) {
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";
span.textContent =
weighting && weighting !== "N/A"
? formatWeightDisplay(weighting)
: "N/A";
weightLabel.appendChild(span);
}
function createWeightLabel( function createWeightLabel(
assessmentItem: Element, assessmentItem: Element,
weighting: string | undefined, weighting: string | undefined,
api: any,
) { ) {
const statsContainer = assessmentItem.querySelector( let statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___']`, `[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
) as HTMLElement; ) as HTMLElement | null;
if ( if (!statsContainer) {
!statsContainer || const statsClass = getClassByPattern(document, "AssessmentItem__stats___");
statsContainer.querySelector(".betterseqta-weight-label") statsContainer = document.createElement("div");
) statsContainer.className = statsClass;
return; statsContainer.classList.add("betterseqta-stats-container");
const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`);
const label = statsContainer.querySelector( if (thermoscore) {
`[class*='Label__Label___']`, thermoscore.insertAdjacentElement("afterend", statsContainer);
) as HTMLElement; } else {
assessmentItem.appendChild(statsContainer);
if (!label) return; }
}
const weightLabel = label.cloneNode(true) as HTMLElement;
weightLabel.classList.add("betterseqta-weight-label"); const hasNativeLabel = !!statsContainer.querySelector(
`[class*='Label__Label___']:not(.betterseqta-weight-label)`,
const innerTextDiv = weightLabel.querySelector( );
`[class*='Label__innerText___']`, statsContainer.style.justifyContent = hasNativeLabel
); ? "space-between"
if (innerTextDiv) innerTextDiv.textContent = "Weight"; : "flex-end";
const textNodes = Array.from(weightLabel.childNodes).filter( const title = assessmentItem
(node) => node.nodeType === Node.TEXT_NODE, .querySelector(`[class*='AssessmentItem__title___']`)
); ?.textContent?.trim();
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
if (textNodes.length) {
textNodes[0].textContent = const existingLabel = statsContainer.querySelector(
weighting && weighting !== "processing" ".betterseqta-weight-label",
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%` ) as HTMLElement | null;
: "N/A";
if (existingLabel) {
updateWeightLabelContent(existingLabel, weighting, assessmentID, api);
return;
} }
// Stack weight under Max/native stats — absolute right:0 overlapped the max column (#414).
statsContainer.style.display = "flex"; statsContainer.style.display = "flex";
statsContainer.style.flexDirection = "column"; statsContainer.style.alignItems = "center";
statsContainer.style.alignItems = "flex-end"; statsContainer.style.width = "100%";
statsContainer.style.gap = "2px";
statsContainer.style.justifyContent = "center";
weightLabel.style.position = "relative"; // Try to clone an existing label from the stats container first,
weightLabel.style.inset = "unset"; // fall back to building from scratch if none exists
weightLabel.style.transform = "none"; const existingNativeLabel = statsContainer.querySelector(
`[class*='Label__Label___']`,
) as HTMLElement | null;
const weightLabel = existingNativeLabel
? (existingNativeLabel.cloneNode(true) as HTMLElement)
: (() => {
const labelClass = getClassByPattern(document, "Label__Label___");
const innerTextClass = getClassByPattern(document, "Label__innerText___");
const el = document.createElement("label");
el.className = labelClass;
el.innerHTML = `<div class="${innerTextClass}">Weight</div>`;
return el;
})();
weightLabel.classList.add("betterseqta-weight-label");
weightLabel.style.flex = "none";
weightLabel.style.width = "fit-content";
const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`);
if (innerTextDiv) innerTextDiv.textContent = "Weight";
updateWeightLabelContent(weightLabel, weighting, assessmentID, api);
statsContainer.appendChild(weightLabel); statsContainer.appendChild(weightLabel);
} }
@@ -228,7 +360,8 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
export async function extractPDFText(url: string): Promise<string> { export async function extractPDFText(url: string): Promise<string> {
try { try {
if (isFirefox) { if (isFirefox) {
const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls(); const { lib: pdfLibUrl, worker: pdfWorkerUrl } =
getPdfjsPageContextUrls();
const escJsSingleQuoted = (s: string) => const escJsSingleQuoted = (s: string) =>
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const pdfLibInj = escJsSingleQuoted(pdfLibUrl); const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
@@ -428,8 +561,6 @@ export async function extractPDFText(url: string): Promise<string> {
async function handleWeightings(mark: any, api: any) { async function handleWeightings(mark: any, api: any) {
const assessmentID = mark.id; const assessmentID = mark.id;
const metaclassID = mark.metaclassID; const metaclassID = mark.metaclassID;
const userInfo = await getUserInfo();
const userID = userInfo.id;
const title = mark.title; const title = mark.title;
if ( if (
@@ -450,6 +581,25 @@ async function handleWeightings(mark: any, api: any) {
}; };
try { try {
let pdfUrl: string;
if (isSeqtaEngageExperience()) {
const studentID = getEngageAssessmentStudentId();
if (!studentID) {
throw new Error("Could not resolve Engage student ID from URL or storage");
}
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 = const filename =
"BetterSEQTA-" + "BetterSEQTA-" +
String(Math.floor(Math.random() * 1e15)).padStart(15, "0"); String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
@@ -477,7 +627,8 @@ async function handleWeightings(mark: any, api: any) {
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`; pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
}
if (pdfUrl.startsWith("blob:")) { if (pdfUrl.startsWith("blob:")) {
throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`); throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`);
@@ -519,7 +670,11 @@ export async function parseAssessments(api: any) {
"[class*='AssessmentList__items___']", "[class*='AssessmentList__items___']",
).getState(); ).getState();
const marks = state["marks"]; const marks = [
...(state["marks"] ?? []),
...(state["upcoming"] ?? []),
...(state["pending"] ?? []),
];
if (!marks) return; if (!marks) return;
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api))); await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
@@ -532,15 +687,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
let count = 0; let count = 0;
for (const assessmentItem of assessmentItems) { for (const assessmentItem of assessmentItems) {
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
const titleEl = assessmentItem.querySelector( const titleEl = assessmentItem.querySelector(
`[class*='AssessmentItem__title___']`, `[class*='AssessmentItem__title___']`,
); );
@@ -550,11 +696,22 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
if (!title) continue; if (!title) continue;
const assessmentID = api.storage.assessments?.[title]; const assessmentID = api.storage.assessments?.[title];
const weighting = assessmentID const autoWeighting = assessmentID
? api.storage.weightings?.[assessmentID] ? api.storage.weightings?.[assessmentID]
: undefined; : undefined;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
const weighting = override ?? autoWeighting;
createWeightLabel(assessmentItem, weighting); createWeightLabel(assessmentItem, weighting, api);
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
if ( if (
weighting === null || weighting === null ||
@@ -563,8 +720,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
weighting === "processing" weighting === "processing"
) { ) {
hasInaccurateWeighting = true; hasInaccurateWeighting = true;
weightedTotal += grade; continue
totalWeight += 1;
} else { } else {
const weight = parseFloat(weighting); const weight = parseFloat(weighting);
@@ -587,3 +743,263 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
count, count,
}; };
} }
function resolveTabSetClasses(): Record<string, string> {
const patterns = [
"TabSet__tabsheet___",
"TabSet__hidden___",
"TabSet__selected___",
"TabSet__disappearToLeft___",
"TabSet__disappearToRight___",
"TabSet__appearFromRight___",
"TabSet__appearFromLeft___",
];
const resolved: Record<string, string> = {};
// First pass: scan live DOM elements (fast, covers currently-applied classes)
const allClasses = Array.from(
document.querySelectorAll('[class*="TabSet__"]'),
).flatMap((el) => Array.from(el.classList));
for (const pattern of patterns) {
const found = allClasses.find((c) => c.startsWith(pattern));
if (found) resolved[pattern] = found;
}
// Second pass: scan stylesheets for any classes not yet in the DOM
// (e.g. animation classes that haven't been applied yet)
const missing = patterns.filter((p) => !resolved[p]);
if (missing.length > 0) {
try {
for (const sheet of Array.from(document.styleSheets)) {
if (missing.every((p) => resolved[p])) break;
try {
for (const rule of Array.from(sheet.cssRules ?? [])) {
if (!(rule instanceof CSSStyleRule)) continue;
const selectorClasses =
rule.selectorText.match(/\.([\w-]+)/g) ?? [];
for (const pattern of missing) {
if (!resolved[pattern]) {
const match = selectorClasses.find((c) =>
c.slice(1).startsWith(pattern),
);
if (match) resolved[pattern] = match.slice(1);
}
}
}
} catch {
// Cross-origin stylesheet
}
}
} catch {}
}
// Fallback: use the base pattern as-is so the function doesn't crash,
// though styles won't apply if the hash is truly unknown.
for (const pattern of patterns) {
if (!resolved[pattern]) resolved[pattern] = pattern;
}
return resolved;
}
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 = title ? api.storage.assessments?.[title] : undefined;
const rawWeight = assessmentID
? api.storage.weightings?.[assessmentID]
: undefined;
const weightingUnavailable = rawWeight === "N/A";
const autoWeight =
rawWeight && rawWeight !== "processing" && rawWeight !== "N/A"
? rawWeight
: undefined;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
const statusNote = !assessmentID
? ""
: rawWeight === "processing"
? "Weighting is still being detected."
: weightingUnavailable
? "No weighting was found in the marksheet. Set one manually."
: "Overrides the auto-detected value.";
sheet.innerHTML = `
<style>
#betterseqta-weight-override::placeholder {
opacity: 0.4;
}
</style>
<div style="padding:16px;max-width:360px">
<h2 style="margin:0 0 4px;font-size:15px;font-weight:600">Weighting Override</h2>
<p style="margin:0 0 16px;font-size:12px;opacity:0.6">
Set the weighting for this assessment.
${statusNote}
</p>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<label style="font-size:13px;opacity:0.7;flex-shrink:0">Auto-detected</label>
<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>
<input
id="betterseqta-weight-override"
type="number"
min="0"
step="5"
placeholder="${weightingUnavailable ? "Enter weight" : autoWeight ?? ""}"
value="${override ?? ""}"
${!assessmentID ? "disabled" : ""}
style="
width:90px;
padding:5px 8px;
border-radius:6px;
border:1px solid rgba(128,128,128,0.3);
background:rgba(128,128,128,0.08);
color:inherit;
font-size:13px;
outline:none;
"
/>
</div>
<div style="margin-top:10px;min-height:18px">
<span class="betterseqta-save-status" style="font-size:12px;opacity:0.5"></span>
</div>
${!assessmentID ? `<p style="font-size:12px;color:rgba(255,80,80,0.8);margin-top:8px">Assessment not yet indexed — try refreshing.</p>` : ""}
</div>
`;
if (!assessmentID) return;
const input = sheet.querySelector(
"#betterseqta-weight-override",
) as HTMLInputElement;
const statusEl = sheet.querySelector(
".betterseqta-save-status",
) as 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)";
} else {
const result = saveWeightingOverride(api, assessmentID, raw);
if (!result.ok) {
input.style.borderColor = "rgba(255,80,80,0.6)";
statusEl.textContent = result.error ?? "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)";
}
statusEl.textContent = "Saved";
statusEl.style.color = "";
setTimeout(() => (statusEl.textContent = ""), 2000);
};
input.addEventListener("blur", save);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
input.blur();
save();
}
});
input.addEventListener("input", () => {
input.style.borderColor = "rgba(128,128,128,0.3)";
if (statusEl.textContent === "Invalid. Must be 0 or greater.")
statusEl.textContent = "";
});
}
export function injectWeightingsTab(api: any) {
const tabList = document.querySelector(
'[class*="TabSet__tabs___"]',
) as HTMLElement;
const container = document.querySelector(
'[class*="TabSet__tabContainer___"]',
) as HTMLElement;
if (!tabList || !container) return;
if (tabList.querySelector(".betterseqta-weightings-tab")) return;
const cls = resolveTabSetClasses();
const prefix = (tabList.querySelector("li") as HTMLElement).id.replace(
/-tab-\d+$/,
"",
);
const newIndex = tabList.querySelectorAll("li").length;
const newTab = document.createElement("li");
newTab.id = `${prefix}-tab-${newIndex}`;
newTab.className = "";
newTab.setAttribute("aria-selected", "false");
newTab.setAttribute("aria-controls", `${prefix}-tabsheet-${newIndex}`);
newTab.classList.add("betterseqta-weightings-tab");
newTab.textContent = "Weightings";
tabList.appendChild(newTab);
const newSheet = document.createElement("div");
newSheet.id = `${prefix}-tabsheet-${newIndex}`;
newSheet.setAttribute("aria-labelledby", `${prefix}-tab-${newIndex}`);
newSheet.className = [
cls["TabSet__tabsheet___"],
cls["TabSet__hidden___"],
cls["TabSet__disappearToRight___"],
].join(" ");
container.appendChild(newSheet);
newTab.addEventListener("click", () => {
buildWeightingsTabContent(api, newSheet);
});
const allTabs = Array.from(tabList.querySelectorAll("li"));
const allSheets = Array.from(
container.querySelectorAll('[class*="tabsheet"]'),
);
allTabs.forEach((tab, i) => {
tab.addEventListener("click", () => {
const currentIndex = allTabs.findIndex((t) =>
t.className.includes("TabSet__selected___"),
);
if (i === currentIndex) return;
const goingRight = i > currentIndex;
allTabs.forEach((t) => {
t.className = "";
t.setAttribute("aria-selected", "false");
});
allSheets[currentIndex].className = [
cls["TabSet__tabsheet___"],
cls["TabSet__hidden___"],
goingRight
? cls["TabSet__disappearToLeft___"]
: cls["TabSet__disappearToRight___"],
].join(" ");
allSheets[i].className = [
cls["TabSet__tabsheet___"],
cls["TabSet__selected___"],
goingRight
? cls["TabSet__appearFromRight___"]
: cls["TabSet__appearFromLeft___"],
].join(" ");
tab.className = cls["TabSet__selected___"];
tab.setAttribute("aria-selected", "true");
});
});
}
@@ -1,12 +1,21 @@
<script lang="ts"> <script lang="ts">
import { determineStatus, formatDate, getGradeValue } from "./utils"; import { determineStatus, formatDate, getGradeValue } from "./utils";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; 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"; import confetti from "canvas-confetti";
export let data: any; export let data: any;
interface FilterOptions { interface FilterOptions {
subject: string; subject: string;
student: string;
sortBy: "due" | "grade" | "subject" | "title" | "year"; sortBy: "due" | "grade" | "subject" | "title" | "year";
} }
@@ -38,12 +47,21 @@
let currentFilters: FilterOptions = { let currentFilters: FilterOptions = {
subject: "all", subject: "all",
student: "all",
sortBy: "due", sortBy: "due",
}; };
const isEngage = isSeqtaEngageExperience();
$: showStudentFilter = isEngage && (data?.students?.length ?? 0) > 1;
let filteredAssessments: any[] = []; let filteredAssessments: any[] = [];
let statusGroups: Record<string, any[]> = {}; let statusGroups: Record<string, any[]> = {};
let columns: { key: string; title: string; className: string; icon: string }[] = []; let columns: {
key: string;
title: string;
className: string;
icon: OverviewIconName;
}[] = [];
function getAssessmentYear(a: any): number { function getAssessmentYear(a: any): number {
const dateStr = a.due || a.date || a.dueDate || a.created; const dateStr = a.due || a.date || a.dueDate || a.created;
@@ -82,14 +100,23 @@
return new Date(a.due || a.date || 0).getTime() - new Date(b.due || b.date || 0).getTime(); return new Date(a.due || a.date || 0).getTime() - new Date(b.due || b.date || 0).getTime();
} }
const STATUS_COLUMNS = [ const STATUS_COLUMNS: {
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "📅" }, key: string;
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "⏰" }, title: string;
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "🚨" }, className: string;
{ key: "SUBMITTED", title: "Submitted", className: "column-submitted", icon: "📝" }, icon: OverviewIconName;
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "✅" }, }[] = [
{ 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" },
]; ];
function groupSortIcon(): OverviewIconName {
return GROUP_SORT_ICONS[currentFilters.sortBy] ?? "queue-list";
}
function buildGroupsAndColumns() { function buildGroupsAndColumns() {
if (!data?.assessments) return { filteredAssessments: [], statusGroups: {}, columns: [] }; if (!data?.assessments) return { filteredAssessments: [], statusGroups: {}, columns: [] };
const subjectFilters = settingsState.subjectfilters || {}; const subjectFilters = settingsState.subjectfilters || {};
@@ -100,7 +127,17 @@
const filtered = data.assessments.filter((a: any) => { const filtered = data.assessments.filter((a: any) => {
if (hiddenAssessmentIds.has(String(a.id))) return false; if (hiddenAssessmentIds.has(String(a.id))) return false;
if (subjectFilters[a.code] === false) return false; if (subjectFilters[a.code] === false) return false;
return currentFilters.subject === "all" || a.code === currentFilters.subject; if (currentFilters.subject !== "all" && a.code !== currentFilters.subject) {
return false;
}
if (
isEngage &&
currentFilters.student !== "all" &&
String(a.studentId) !== currentFilters.student
) {
return false;
}
return true;
}); });
const groups: Record<string, any[]> = {}; const groups: Record<string, any[]> = {};
@@ -114,18 +151,19 @@
groups[key].sort(sortCompare); groups[key].sort(sortCompare);
}); });
let cols: { key: string; title: string; className: string; icon: string }[]; let cols: { key: string; title: string; className: string; icon: OverviewIconName }[];
if (currentFilters.sortBy === "due") { if (currentFilters.sortBy === "due") {
cols = STATUS_COLUMNS; cols = STATUS_COLUMNS;
} else { } else {
const keys = Object.keys(groups).filter((k) => groups[k]?.length > 0); const keys = Object.keys(groups).filter((k) => groups[k]?.length > 0);
const sortIcon = groupSortIcon();
if (currentFilters.sortBy === "year") { if (currentFilters.sortBy === "year") {
cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: "📆" })); cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: sortIcon }));
} else if (currentFilters.sortBy === "subject") { } else if (currentFilters.sortBy === "subject") {
const subjectTitles = new Map(data?.subjects?.map((s: any) => [s.code, `${s.code} - ${s.title}`]) || []); 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: "📚" })); cols = keys.sort().map((k) => ({ key: k, title: subjectTitles.get(k) || k, className: "column-custom", icon: sortIcon }));
} else { } else {
cols = keys.sort().map((k) => ({ key: k, title: k, className: "column-custom", icon: "📋" })); cols = keys.sort().map((k) => ({ key: k, title: k, className: "column-custom", icon: sortIcon }));
} }
} }
@@ -309,6 +347,19 @@
if ((event.target as HTMLElement).closest(".card-menu")) { if ((event.target as HTMLElement).closest(".card-menu")) {
return; 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}`; window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`;
} }
@@ -342,16 +393,28 @@
updateAssessments(); updateAssessments();
void currentFilters.sortBy; void currentFilters.sortBy;
void currentFilters.subject; void currentFilters.subject;
void currentFilters.student;
} }
</script> </script>
<svelte:window on:click={closeAllMenus} /> <svelte:window on:click={closeAllMenus} />
<div id="grid-view-container"> <div class="bsplus-overview-page">
<div class="grid-view-header"> <header class="grid-view-header bsplus-overview-animate">
<div class="grid-view-header-text">
<h1 class="grid-view-title">Assessments</h1> <h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters"> <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}
<select class="filter-select" bind:value={currentFilters.subject}> <select class="filter-select" bind:value={currentFilters.subject}>
<option value="all">All Subjects</option> <option value="all">All Subjects</option>
{#each data.subjects as subject} {#each data.subjects as subject}
@@ -372,14 +435,15 @@
on:click={() => (showVisibilityPanel = !showVisibilityPanel)} on:click={() => (showVisibilityPanel = !showVisibilityPanel)}
title="Manage hidden subjects and assessments" title="Manage hidden subjects and assessments"
> >
👁 Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length}) <OverviewIcon name="eye" size={18} />
<span>Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})</span>
</button> </button>
{/if} {/if}
</div> </div>
</div> </header>
{#if showVisibilityPanel && hasHiddenItems} {#if showVisibilityPanel && hasHiddenItems}
<div class="visibility-panel"> <div class="visibility-panel bsplus-overview-animate">
<h4 class="visibility-panel-title">Hidden items</h4> <h4 class="visibility-panel-title">Hidden items</h4>
{#if hiddenSubjects.length > 0} {#if hiddenSubjects.length > 0}
<div class="visibility-section"> <div class="visibility-section">
@@ -410,10 +474,10 @@
</div> </div>
{/if} {/if}
<div id="main-grid-content"> <div id="main-grid-content" class="bsplus-overview-animate bsplus-overview-delay-1">
{#if filteredAssessments.length === 0} {#if filteredAssessments.length === 0}
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon">📋</div> <OverviewIcon name="clipboard-document-list" size={40} class="empty-icon" />
<p>No assessments found matching your filters</p> <p>No assessments found matching your filters</p>
</div> </div>
{:else} {:else}
@@ -424,7 +488,13 @@
<div class="kanban-column {column.className}"> <div class="kanban-column {column.className}">
<div class="column-header"> <div class="column-header">
<div class="column-title"> <div class="column-title">
{column.icon} {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> <span class="column-count">{statusGroups[column.key].length}</span>
</div> </div>
</div> </div>
@@ -445,6 +515,9 @@
on:keydown={(e) => e.key === 'Enter' && handleCardClick(assessment, e)} on:keydown={(e) => e.key === 'Enter' && handleCardClick(assessment, e)}
> >
<div class="card-labels"> <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> <span class="card-label label-subject">{assessment.code}</span>
{#if assessment.submitted} {#if assessment.submitted}
<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span> <span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>
@@ -462,11 +535,7 @@
on:click={(e) => toggleMenu(assessment.id, e)} on:click={(e) => toggleMenu(assessment.id, e)}
aria-label="Open menu" aria-label="Open menu"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <OverviewIcon name="ellipsis-vertical" size={16} />
<circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/>
</svg>
</button> </button>
<div class="menu-dropdown" style="display: {openMenuId === assessment.id ? 'block' : 'none'};"> <div class="menu-dropdown" style="display: {openMenuId === assessment.id ? 'block' : 'none'};">
{#if status !== "MARKS_RELEASED"} {#if status !== "MARKS_RELEASED"}
@@ -493,7 +562,8 @@
{#if !assessment.results && !isCompleted} {#if !assessment.results && !isCompleted}
<div class="assessment-meta"> <div class="assessment-meta">
<div class="due-date {dueDateClass}"> <div class="due-date {dueDateClass}">
📅 {formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)} <OverviewIcon name="calendar-days" size={14} />
{formatDate(assessment.due || assessment.date || assessment.dueDate || "", assessment.submitted)}
</div> </div>
</div> </div>
{/if} {/if}
@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import OverviewIcon from "./OverviewIcon.svelte";
export let error: string; export let error: string;
</script> </script>
<div class="error-container"> <div class="error-container bsplus-overview-animate">
<OverviewIcon name="exclamation-circle" size={40} class="error-icon" />
<p class="error-text">Failed to load assessments</p> <p class="error-text">Failed to load assessments</p>
<p style="color: #94a3b8; font-size: 0.875rem;">{error}</p> <p class="error-detail">{error}</p>
</div> </div>
@@ -0,0 +1,32 @@
<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,7 +1,28 @@
<div id="grid-view-container"> <script lang="ts">
<div class="grid-view-header"> 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> <h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters"> <p class="grid-view-subtitle">Loading your assessment overview…</p>
</div>
<div class="grid-view-filters bsplus-overview-toolbar">
<select class="filter-select" disabled> <select class="filter-select" disabled>
<option value="all">Loading subjects...</option> <option value="all">Loading subjects...</option>
</select> </select>
@@ -9,17 +30,20 @@
<option value="due">Sort by Due Date</option> <option value="due">Sort by Due Date</option>
</select> </select>
</div> </div>
</div> </header>
<div id="main-grid-content"> <div id="main-grid-content" class="bsplus-overview-animate bsplus-overview-delay-1">
<div class="kanban-board"> <div class="kanban-board">
{#each columns as column} {#each columns as column}
<div class="kanban-column-parent"> <div class="kanban-column-parent">
<div class="kanban-column {column.className}"> <div class="kanban-column {column.className}">
<div class="column-header"> <div class="column-header">
<div class="column-title"> <div class="column-title">
{column.icon} {column.title} <span class="column-title-main">
<span class="column-count">...</span> <OverviewIcon name={column.icon} size={18} />
{column.title}
</span>
<span class="column-count"></span>
</div> </div>
</div> </div>
<div class="column-cards" id="{column.key.toLowerCase()}-cards"> <div class="column-cards" id="{column.key.toLowerCase()}-cards">
@@ -43,36 +67,3 @@
</div> </div>
</div> </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>
+61 -28
View File
@@ -1,20 +1,26 @@
interface Subject { import {
code: string; activeSubjectsFromLearnPayload,
programme: number; assessmentBelongsToActiveSubjects,
metaclass: number; filterAssessmentsForActiveSubjects,
title: string; type OverviewSubject,
} } from "./utils";
interface PrefItem { interface PrefItem {
name: string; name: string;
value: string; value: string;
} }
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { getMockAssessmentsData } from "@/seqta/ui/dev/hideSensitiveContent"; import { getMockAssessmentsData } from "@/seqta/ui/dev/hideSensitiveContent";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import {
getEngageAssessmentsData,
} from "./engageApi";
let cache: { time: number; data: any } | null = null; let cache: { time: number; engageAll?: boolean; studentId: number; data: any } | null =
null;
const CACHE_MS = 10 * 60 * 1000; const CACHE_MS = 10 * 60 * 1000;
const student = 69;
async function fetchJSON(url: string, body: any) { async function fetchJSON(url: string, body: any) {
const res = await fetch(`${location.origin}${url}`, { const res = await fetch(`${location.origin}${url}`, {
@@ -26,11 +32,9 @@ async function fetchJSON(url: string, body: any) {
return res.json(); return res.json();
} }
async function loadSubjects() { async function loadSubjects(): Promise<OverviewSubject[]> {
const res = await fetchJSON("/seqta/student/load/subjects?", {}); const res = await fetchJSON("/seqta/student/load/subjects?", {});
return res.payload return activeSubjectsFromLearnPayload(res.payload);
.filter((s: any) => s.active === 1)
.flatMap((s: any) => s.subjects);
} }
async function loadPrefs(student: number) { async function loadPrefs(student: number) {
@@ -56,9 +60,8 @@ async function loadUpcoming(student: number) {
return res.payload; return res.payload;
} }
function normalizeAssessmentDates(t: any, subject: Subject): any { function normalizeAssessmentDates(t: any, subject: OverviewSubject): any {
const normalized = { ...t }; 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)) { if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
normalized.due = t.date || t.dueDate || t.created || t.submittedDate; normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
} }
@@ -68,7 +71,7 @@ function normalizeAssessmentDates(t: any, subject: Subject): any {
return normalized; return normalized;
} }
async function loadPast(student: number, subjects: Subject[]) { async function loadPast(student: number, subjects: OverviewSubject[]) {
const map: Record<number, any> = {}; const map: Record<number, any> = {};
await Promise.all( await Promise.all(
subjects.map(async (s) => { subjects.map(async (s) => {
@@ -128,35 +131,65 @@ async function loadSubmissions(student: number, assessments: any[]) {
return submissionMap; return submissionMap;
} }
export async function getAssessmentsData() { async function getLearnAssessmentsData(studentId: number) {
if (settingsState.mockNotices) {
return getMockAssessmentsData();
}
if (cache && Date.now() - cache.time < CACHE_MS) return cache.data;
const [subjects, colors, upcoming] = await Promise.all([ const [subjects, colors, upcoming] = await Promise.all([
loadSubjects(), loadSubjects(),
loadPrefs(student), loadPrefs(studentId),
loadUpcoming(student), loadUpcoming(studentId),
]); ]);
const pastMap = await loadPast(student, subjects); const pastMap = await loadPast(studentId, subjects);
const map: Record<number, any> = {}; const map: Record<number, any> = {};
upcoming.forEach((a: any) => { upcoming.forEach((a: any) => {
if (assessmentBelongsToActiveSubjects(a, subjects)) {
map[a.id] = { ...a }; map[a.id] = { ...a };
}
}); });
Object.values(pastMap).forEach((t: any) => { Object.values(pastMap).forEach((t: any) => {
if (!assessmentBelongsToActiveSubjects(t, subjects)) return;
if (map[t.id]) Object.assign(map[t.id], t); if (map[t.id]) Object.assign(map[t.id], t);
else map[t.id] = t; else map[t.id] = t;
}); });
const allAssessments = Object.values(map); const allAssessments = filterAssessmentsForActiveSubjects(
const submissions = await loadSubmissions(student, allAssessments); Object.values(map),
subjects,
);
const submissions = await loadSubmissions(studentId, allAssessments);
allAssessments.forEach((assessment: any) => { allAssessments.forEach((assessment: any) => {
assessment.submitted = submissions[assessment.id] || false; assessment.submitted = submissions[assessment.id] || false;
}); });
const data = { assessments: allAssessments, subjects, colors }; return { assessments: allAssessments, subjects, colors, studentId };
cache = { time: Date.now(), data }; }
export async function getAssessmentsData() {
if (settingsState.mockNotices) {
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 };
return data; return data;
} }
@@ -0,0 +1,235 @@
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),
});
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,
};
}
@@ -0,0 +1,65 @@
/** 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,10 +1,50 @@
import type { Plugin } from "../../core/types"; import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api"; import { getAssessmentsData } from "./api";
import { renderErrorState, renderSkeletonLoader } from "./ui"; import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; 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<{}> = { const assessmentsOverviewPlugin: Plugin<{}> = {
id: "assessments-overview", id: "assessments-overview",
@@ -17,35 +57,46 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
styles, styles,
run: async () => { run: async () => {
if (isSeqtaEngageExperience()) return; const menu = await waitForAssessmentsSubmenu();
const menu = (await waitForElm(
'[data-key="assessments"] > .sub > ul',
true,
100,
60,
)) as HTMLElement;
const gridItem = document.createElement("li"); const gridItem = document.createElement("li");
gridItem.className = "item"; gridItem.className = "item";
gridItem.classList.add(OVERVIEW_MENU_CLASS);
const label = document.createElement("label"); const label = document.createElement("label");
label.textContent = "Overview"; label.textContent = "Overview";
gridItem.appendChild(label); gridItem.appendChild(label);
menu.insertBefore(gridItem, menu.children[1] || null); menu.insertBefore(gridItem, menu.firstChild);
if (window.location.hash.includes("/assessments/overview")) { const menuObserver = new MutationObserver(() => {
loadGridView(); ensureOverviewMenuPosition(menu, gridItem);
});
menuObserver.observe(menu, { childList: true });
if (isOverviewRoute()) {
void loadGridView();
} }
const clickHandler = (e: Event) => { const clickHandler = (e: Event) => {
e.preventDefault(); e.preventDefault();
loadGridView(); void loadGridView();
}; };
gridItem.addEventListener("click", clickHandler); gridItem.addEventListener("click", clickHandler);
async function loadGridView() { async function loadGridView() {
await delay(1); 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"); window.history.pushState({}, "", "/#?page=/assessments/overview");
document.title = "Overview ― SEQTA Learn"; document.title = "Overview ― SEQTA Learn";
}
const main = document.getElementById("main"); const main = document.getElementById("main");
if (!main) return; if (!main) return;
@@ -59,7 +110,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
.querySelector('[data-key="assessments"]') .querySelector('[data-key="assessments"]')
?.classList.add("active"); ?.classList.add("active");
main.innerHTML = '<div id="grid-view-container"></div>'; main.innerHTML = '<div id="grid-view-container" class="bsplus-overview-host"></div>';
const container = document.getElementById( const container = document.getElementById(
"grid-view-container", "grid-view-container",
) as HTMLElement; ) as HTMLElement;
@@ -68,7 +119,6 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
try { try {
const data = await getAssessmentsData(); const data = await getAssessmentsData();
const { renderGrid } = await import("./ui");
renderGrid(container, data); renderGrid(container, data);
} catch (err) { } catch (err) {
console.error("Failed to load assessments:", err); console.error("Failed to load assessments:", err);
@@ -80,6 +130,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
} }
return () => { return () => {
menuObserver.disconnect();
gridItem.removeEventListener("click", clickHandler); gridItem.removeEventListener("click", clickHandler);
gridItem.remove(); gridItem.remove();
}; };
File diff suppressed because it is too large Load Diff
+130 -20
View File
@@ -1,45 +1,155 @@
import renderSvelte from "@/interface/main"; import renderSvelte from "@/interface/main";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import AssessmentsOverview from "./AssessmentsOverview.svelte"; import AssessmentsOverview from "./AssessmentsOverview.svelte";
import SkeletonLoader from "./SkeletonLoader.svelte"; import SkeletonLoader from "./SkeletonLoader.svelte";
import ErrorState from "./ErrorState.svelte"; import ErrorState from "./ErrorState.svelte";
import { unmount } from "svelte"; import { unmount } from "svelte";
let currentApp: any = null; let currentApp: any = null;
let themeObserver: MutationObserver | null = null;
type ThemeSettingKey =
| "selectedColor"
| "DarkMode"
| "adaptiveThemeColour"
| "adaptiveThemeGradient"
| "selectedTheme";
export function renderGrid(container: HTMLElement, data: any) { let themeListeners: Array<{ key: ThemeSettingKey; listener: () => void }> = [];
if (currentApp) {
unmount(currentApp); 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;
} }
container.innerHTML = ""; function resolvePageAccentColor(): string {
container.className = ""; 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);
}
export function renderGrid(container: HTMLElement, data: any) {
if (currentApp) unmount(currentApp);
prepareContainer(container);
currentApp = renderSvelte(AssessmentsOverview, container, { data }); currentApp = renderSvelte(AssessmentsOverview, container, { data });
} }
export function renderSkeletonLoader(container: HTMLElement) { export function renderSkeletonLoader(container: HTMLElement) {
if (currentApp) { if (currentApp) unmount(currentApp);
unmount(currentApp); prepareContainer(container);
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(SkeletonLoader, container); currentApp = renderSvelte(SkeletonLoader, container);
} }
export function renderLoadingState(container: HTMLElement) { export function renderLoadingState(container: HTMLElement) {
renderSkeletonLoader(container); renderSkeletonLoader(container);
} }
export function renderErrorState(container: HTMLElement, error: string) { export function renderErrorState(container: HTMLElement, error: string) {
if (currentApp) { if (currentApp) unmount(currentApp);
unmount(currentApp); prepareContainer(container);
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(ErrorState, container, { error }); currentApp = renderSvelte(ErrorState, container, { error });
} }
export function teardownOverviewUi() {
for (const { key, listener } of themeListeners) {
settingsState.unregister(key, listener);
}
themeListeners = [];
themeObserver?.disconnect();
themeObserver = null;
if (currentApp) {
unmount(currentApp);
currentApp = null;
}
}
@@ -1,3 +1,115 @@
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 (term.active !== 1) 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 { export function formatDate(dateStr: string, submitted?: boolean): string {
const d = new Date(dateStr); const d = new Date(dateStr);
const now = new Date(); const now = new Date();
+37 -66
View File
@@ -5,7 +5,9 @@ import {
defineSettings, defineSettings,
hotkeySetting, hotkeySetting,
} from "../../core/settingsHelpers"; } from "../../core/settingsHelpers";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import styles from "./src/core/styles.css?inline"; import styles from "./src/core/styles.css?inline";
import { resetSearchIndexes } from "./src/indexing/resetIndexes";
// Platform-aware default hotkey // Platform-aware default hotkey
const getDefaultHotkey = () => { const getDefaultHotkey = () => {
@@ -34,85 +36,42 @@ const settings = defineSettings({
title: "Index on Page Load", title: "Index on Page Load",
description: "Run content indexing when SEQTA loads", 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({ resetIndex: buttonSetting({
title: "Reset Index", title: "Reset Index",
description: "Reset the search index and storage", description: "Reset the search index and storage",
trigger: async () => { trigger: async () => {
const confirmed = confirm("Are you sure you want to reset the search index and storage?"); const confirmed = confirm(
"Reset the search index and all stored Global Search data?\n\nAfter this, reload this SEQTA tab so indexing can run again and rebuild the index.",
if (confirmed) { );
try { if (!confirmed) return;
// 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 { try {
await deleteDb("embeddiaDB"); // `resetSearchIndexes` is a tiny statically-imported helper: no
await deleteDb("betterseqta-index"); // dynamic chunks to chase, so the button keeps working even when
alert("Search index and storage have been reset successfully."); // 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) { } catch (e) {
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again."); alert(
} "Failed to reset index: " +
} catch (e) { String(e) +
alert("Failed to reset index: " + String(e)); "\n\nTry closing other browser tabs and try again.",
} );
} }
}, },
}), }),
}); });
// Create the lazy plugin definition - this loads immediately but doesn't import heavy dependencies // Create the lazy plugin definition - this loads immediately but doesn't import heavy dependencies
export default defineLazyPlugin({ const globalSearchPlugin = defineLazyPlugin({
id: "global-search", id: "global-search",
name: "Global Search", name: "Global Search",
description: "Quick search for everything in SEQTA", description: "Quick search for everything in SEQTA",
@@ -125,3 +84,15 @@ export default defineLazyPlugin({
// Lazy loader - only imports the heavy plugin when actually needed // Lazy loader - only imports the heavy plugin when actually needed
loader: () => import("./src/core/index") loader: () => import("./src/core/index")
}); });
const runGlobalSearch = globalSearchPlugin.run!;
globalSearchPlugin.run = async (api) => {
if (isSeqtaEngageExperience()) {
return () => {};
}
return runGlobalSearch(api);
};
export default globalSearchPlugin;
@@ -48,6 +48,13 @@
let calculatorResult = $state<string | null>(null); let calculatorResult = $state<string | null>(null);
let resultsList = $state<HTMLUListElement>(); 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) => { const updateCalculatorState = (hasResult: string | null) => {
calculatorResult = hasResult; calculatorResult = hasResult;
}; };
@@ -166,9 +173,10 @@
}); });
const term = searchTerm.trim().toLowerCase(); const term = searchTerm.trim().toLowerCase();
const requestId = ++searchRequestId;
if (commandsFuse && dynamicContentFuse) { if (commandsFuse && dynamicContentFuse) {
combinedResults = await doSearch( const results = await doSearch(
term, term,
commandsFuse, commandsFuse,
commandIdToItemMap, commandIdToItemMap,
@@ -176,7 +184,16 @@
dynamicIdToItemMap, dynamicIdToItemMap,
true, // sortByRecent 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 { } else {
if (requestId !== searchRequestId) return;
combinedResults = []; combinedResults = [];
} }
@@ -0,0 +1,89 @@
<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 { try {
const response = await fetch(`${location.origin}/seqta/student/load/timetable?`, { const response = await fetch(`${location.origin}/seqta/student/load/timetable?`, {
method: "POST", method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
from: todayFormatted, from: todayFormatted,
until: todayFormatted, until: todayFormatted,
student: 69,
}), }),
}); });
@@ -15,6 +15,10 @@ import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
import { IndexedDbManager } from "embeddia"; import { IndexedDbManager } from "embeddia";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager"; import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { checkAndHandleUpdate } from "../utils/versionCheck"; import { checkAndHandleUpdate } from "../utils/versionCheck";
import {
getStoredPassiveItems,
installPassiveObserver,
} from "../indexing/passiveObserver";
// Platform-aware default hotkey // Platform-aware default hotkey
const getDefaultHotkey = () => { const getDefaultHotkey = () => {
@@ -43,11 +47,19 @@ const settings = defineSettings({
title: "Index on Page Load", title: "Index on Page Load",
description: "Run content indexing when SEQTA loads", 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({ resetIndex: buttonSetting({
title: "Reset Index", title: "Reset Index",
description: "Reset the search index and storage", description: "Reset the search index and storage",
trigger: async () => { trigger: async () => {
const confirmed = confirm("Are you sure you want to reset the search index and storage?"); 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) { if (confirmed) {
try { try {
@@ -106,7 +118,9 @@ const settings = defineSettings({
try { try {
await deleteDb("embeddiaDB"); await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index"); await deleteDb("betterseqta-index");
alert("Search index and storage have been reset successfully."); alert(
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
);
} catch (e) { } catch (e) {
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again."); alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
} }
@@ -131,6 +145,9 @@ class GlobalSearchPlugin extends BasePlugin<typeof settings> {
@Setting(settings.runIndexingOnLoad) @Setting(settings.runIndexingOnLoad)
runIndexingOnLoad!: boolean; runIndexingOnLoad!: boolean;
@Setting(settings.passiveIndexing)
passiveIndexing!: boolean;
@Setting(settings.resetIndex) @Setting(settings.resetIndex)
resetIndex!: () => void; resetIndex!: () => void;
} }
@@ -150,26 +167,35 @@ const globalSearchPlugin: Plugin<typeof settings> = {
run: async (api) => { run: async (api) => {
const appRef = { current: null }; const appRef = { current: null };
// Check for extension updates and clear caches if needed // Run the version check BEFORE we open any IndexedDB connections.
// Use a timeout to avoid blocking initialization // On a normal load (no version change) this is just a string compare
setTimeout(async () => { // and a manifest read, so the cost is negligible. On a real update,
// we want the database wipe to complete before `IndexedDbManager`
// grabs a handle on `embeddiaDB`, otherwise the delete request comes
// back blocked.
try { try {
const wasUpdated = await checkAndHandleUpdate(); const wasUpdated = await checkAndHandleUpdate();
if (wasUpdated) { if (wasUpdated) {
console.log("[Global Search] Extension updated - caches cleared"); console.log(
"[Global Search] Extension updated — search index reset; the next indexing pass will repopulate.",
);
} }
} catch (error: any) { } catch (error: any) {
// Handle CSS preload errors and other failures gracefully // Firefox sometimes refuses CSS preloads or asset reads; we never
// These can happen in Firefox or when assets aren't available // want this path to take the whole plugin down.
if (error?.message?.includes("preload CSS") || if (
error?.message?.includes("preload CSS") ||
error?.message?.includes("MIME type") || error?.message?.includes("MIME type") ||
error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")) { error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")
console.debug("[Global Search] Version check skipped due to asset loading restrictions:", error.message); ) {
console.debug(
"[Global Search] Version check skipped due to asset loading restrictions:",
error.message,
);
} else { } else {
console.warn("[Global Search] Failed to check for updates:", error); console.warn("[Global Search] Failed to check for updates:", error);
} }
} }
}, 100);
try { try {
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", { await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
@@ -210,6 +236,17 @@ const globalSearchPlugin: Plugin<typeof settings> = {
const workerManager = VectorWorkerManager.getInstance(); const workerManager = VectorWorkerManager.getInstance();
console.log("Streaming active:", workerManager.isStreamingActive()); 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 () => { checkIndexedDBSize: async () => {
try { try {
const estimate = await navigator.storage.estimate(); const estimate = await navigator.storage.estimate();
@@ -232,6 +269,14 @@ 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) { if (api.settings.runIndexingOnLoad) {
setTimeout(async () => { setTimeout(async () => {
await runIndexing(); await runIndexing();
@@ -8,7 +8,12 @@ import browser from "webextension-polyfill";
export function mountSearchBar( export function mountSearchBar(
titleElement: Element, titleElement: Element,
api: any, api: any,
appRef: { current: any; storageChangeHandler?: any; progressHandler?: any }, appRef: {
current: any;
storageChangeHandler?: any;
progressHandler?: any;
clearDoneFlashTimer?: () => void;
},
) { ) {
if (titleElement.querySelector(".search-trigger")) { if (titleElement.querySelector(".search-trigger")) {
return; return;
@@ -18,74 +23,215 @@ export function mountSearchBar(
let currentHotkey = isValidHotkey(api.settings.searchHotkey) ? api.settings.searchHotkey : "ctrl+k"; let currentHotkey = isValidHotkey(api.settings.searchHotkey) ? api.settings.searchHotkey : "ctrl+k";
let hotkeyDisplay = formatHotkeyForDisplay(currentHotkey); 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"); const searchButton = document.createElement("div");
searchButton.className = "search-trigger"; searchButton.className = "search-trigger";
// 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"); const progressBarWrapper = document.createElement("div");
progressBarWrapper.className = "search-progress-bar-wrapper"; progressBarWrapper.className = "search-progress-bar-wrapper";
progressBarWrapper.style.cssText = "flex: 1; height: 4px; background: rgba(0, 0, 0, 0.1); border-radius: 2px; overflow: hidden; display: none;";
const progressTrack = document.createElement("div");
progressTrack.className = "search-progress-track";
const progressBar = document.createElement("div"); const progressBar = document.createElement("div");
progressBar.className = "search-progress-bar"; progressBar.className = "search-progress-bar";
progressBar.style.cssText = "height: 100%; background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6); transition: width 0.3s ease-out; width: 0%; position: relative;"; progressTrack.appendChild(progressBar);
progressBarWrapper.appendChild(progressTrack);
// Add shimmer effect // Use a block-level <div> so the label reliably participates in flex
const shimmer = document.createElement("div"); // layout. A <span> defaults to `display: inline`, which silently ignores
shimmer.style.cssText = "position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); animation: shimmer 2s infinite;"; // `max-width`, `overflow`, and `text-overflow: ellipsis`, and was the
progressBar.appendChild(shimmer); // reason the label appeared blank when the bar was visible.
progressBarWrapper.appendChild(progressBar); const progressText = document.createElement("div");
// Create progress text
const progressText = document.createElement("span");
progressText.className = "search-progress-text"; progressText.className = "search-progress-text";
progressText.style.cssText = "font-size: 11px; color: #666; white-space: nowrap; display: none;"; progressText.setAttribute("aria-live", "polite");
progressContainer.appendChild(progressBarWrapper); searchAnchor.appendChild(searchButton);
progressContainer.appendChild(progressText); searchAnchor.appendChild(progressBarWrapper);
searchWrapper.appendChild(searchAnchor);
searchWrapper.appendChild(progressText);
// Indexing state // Indexing state
let isIndexing = false; let isIndexing = false;
/** True while indexing has run until it finishes/fails — used for Done! flash only */
let ranIndexingCycle = false;
let completedJobs = 0; let completedJobs = 0;
let totalJobs = 0; let totalJobs = 0;
let indexingStatus: string | null = null; 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 updateProgressDisplay = () => {
if (isIndexing && totalJobs > 0) { 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",
);
const percentage = Math.round((completedJobs / totalJobs) * 100); const percentage = Math.round((completedJobs / totalJobs) * 100);
progressBar.style.width = `${Math.max(2, percentage)}%`; progressBar.style.width = `${Math.max(2, percentage)}%`;
progressBarWrapper.style.display = "block"; progressBarWrapper.classList.add("is-active");
searchAnchor.classList.add("is-indexing");
searchButton.classList.add("is-indexing");
if (indexingStatus) { if (indexingStatus) {
progressText.textContent = indexingStatus.length > 20 ? indexingStatus.substring(0, 20) + "..." : indexingStatus; progressText.textContent = `${truncateStatus(indexingStatus)} · ${percentage}%`;
progressText.style.display = "block";
} else { } else {
progressText.textContent = `${completedJobs}/${totalJobs} (${percentage}%)`; progressText.textContent = `Indexing ${completedJobs}/${totalJobs} (${percentage}%)`;
progressText.style.display = "block";
} }
progressText.classList.add("is-active");
return;
}
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 { } else {
progressBarWrapper.style.display = "none"; progressText.classList.remove("is-rough");
progressText.style.display = "none"; 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 // Listen for indexing progress events
const progressHandler = (event: CustomEvent) => { const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing, status } = event.detail; const { completed, total, indexing, status } = event.detail as {
completedJobs = completed || 0; completed?: number;
totalJobs = total || 0; total?: number;
isIndexing = indexing || false; indexing?: boolean;
indexingStatus = status || null; 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;
}
updateProgressDisplay(); updateProgressDisplay();
}; };
window.addEventListener('indexing-progress', progressHandler as EventListener); window.addEventListener('indexing-progress', progressHandler as EventListener);
appRef.progressHandler = progressHandler; appRef.progressHandler = progressHandler;
appRef.clearDoneFlashTimer = clearDoneFlashTimer;
const updateSearchButtonDisplay = () => { const updateSearchButtonDisplay = () => {
searchButton.innerHTML = /* html */ ` searchButton.innerHTML = /* html */ `
@@ -99,8 +245,7 @@ export function mountSearchBar(
}; };
updateSearchButtonDisplay(); updateSearchButtonDisplay();
titleElement.appendChild(searchButton); titleElement.appendChild(searchWrapper);
titleElement.appendChild(progressContainer);
// Listen for hotkey setting changes // Listen for hotkey setting changes
const handleStorageChange = (changes: any, area: string) => { const handleStorageChange = (changes: any, area: string) => {
@@ -139,7 +284,12 @@ export function mountSearchBar(
} }
} }
export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any; progressHandler?: any }) { export function cleanupSearchBar(appRef: {
current: any;
storageChangeHandler?: any;
progressHandler?: any;
clearDoneFlashTimer?: () => void;
}) {
if (appRef.current) { if (appRef.current) {
try { try {
unmount(appRef.current); unmount(appRef.current);
@@ -149,23 +299,29 @@ export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?:
} }
} }
try {
appRef.clearDoneFlashTimer?.();
} catch {
/* ignore */
}
appRef.clearDoneFlashTimer = undefined;
// Remove progress event listener // Remove progress event listener
if (appRef.progressHandler) { if (appRef.progressHandler) {
window.removeEventListener('indexing-progress', appRef.progressHandler as EventListener); window.removeEventListener('indexing-progress', appRef.progressHandler as EventListener);
appRef.progressHandler = null; appRef.progressHandler = null;
} }
// Remove search trigger button // Remove search trigger wrapper (which contains the button and progress UI)
const searchTrigger = document.querySelector(".search-trigger"); const searchWrapper = document.querySelector(".search-trigger-wrapper");
if (searchTrigger) { if (searchWrapper) {
searchTrigger.remove(); searchWrapper.remove();
} }
// Remove progress container // Defensive cleanup for older mounts that may have left the trigger or
const progressContainer = document.querySelector(".search-progress-container"); // progress container as direct children of the topbar.
if (progressContainer) { document.querySelector(".search-trigger")?.remove();
progressContainer.remove(); document.querySelector(".search-progress-container")?.remove();
}
// Remove search root // Remove search root
const searchRoot = document.querySelector("div[data-search-root]"); const searchRoot = document.querySelector("div[data-search-root]");
@@ -1,15 +1,72 @@
/*
* 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 { .search-trigger {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: none;
height: 32px; height: 32px;
margin-left: 10px; border-radius: 0;
border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition:
margin-right: auto !important; background-color 0.2s ease,
border-color 0.2s ease;
padding: 3px 12px; padding: 3px 12px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); box-shadow: none;
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
user-select: none; user-select: none;
@@ -28,10 +85,12 @@
} }
} }
/* Light mode styles */ /* Light mode chip */
.search-trigger { .search-trigger {
background-color: rgba(248, 250, 252, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.1) !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; color: #555 !important;
p { p {
@@ -44,8 +103,10 @@
} }
.dark .search-trigger { .dark .search-trigger {
background-color: rgba(0, 0, 0, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !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; color: #aaa !important;
p { p {
@@ -57,7 +118,17 @@
} }
} }
/*
* 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 { .highlight {
background-color: rgba(255, 213, 0, 0.3); background-color: rgba(255, 213, 0, 0.3);
font-weight: 500; font-weight: 500;
@@ -83,57 +154,139 @@
animation: shimmer 2s infinite; animation: shimmer 2s infinite;
} }
/* Progress indicator next to search trigger */ /*
.search-progress-container { * Thin track flush under `.search-trigger` same width as chip, shared
display: flex; * `overflow:hidden` rounding on `.search-trigger-anchor`.
align-items: center; */
gap: 8px;
margin-left: 8px;
min-width: 120px;
max-width: 200px;
height: 32px;
}
.search-progress-bar-wrapper { .search-progress-bar-wrapper {
flex: 1; flex: none;
height: 4px; height: 0;
background: rgba(0, 0, 0, 0.1); min-height: 0;
border-radius: 2px; border: none;
background: transparent;
border-radius: 0;
overflow: hidden; overflow: hidden;
display: none; opacity: 1;
min-width: 60px; transform: none;
pointer-events: none;
transition: height 0.22s cubic-bezier(0.2, 0.7, 0.3, 1);
} }
.dark .search-progress-bar-wrapper { .search-progress-bar-wrapper.is-active {
background: rgba(255, 255, 255, 0.1); height: 4px;
}
.search-progress-track {
box-sizing: border-box;
height: 100%;
width: 100%;
position: relative;
overflow: hidden;
background: rgba(15, 23, 42, 0.08);
}
.dark .search-progress-track {
background: rgba(248, 250, 252, 0.1);
} }
.search-progress-bar { .search-progress-bar {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6);
transition: width 0.3s ease-out;
width: 0%;
position: relative; position: relative;
border-radius: 2px; height: 100%;
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);
} }
.search-progress-bar::after { .search-progress-bar::after {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.28),
transparent
);
animation: shimmer 2s infinite; 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;
white-space: nowrap;
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;
} }
.search-progress-text { /* While indexing: same neutral label colour as default (only “Done!” is green). */
font-size: 11px; .search-progress-text.is-active {
color: #666; opacity: 1;
white-space: nowrap; transform: translateX(0);
display: none; color: #475569;
font-weight: 500; }
/* 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 { .dark .search-progress-text {
color: #999; 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;
} }
@@ -0,0 +1,161 @@
/**
* 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,6 +28,40 @@ interface AssessmentMetadata {
type ActionHandler<T = any> = (item: IndexItem & { metadata: T }) => void; 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>> = { export const actionMap: Record<string, ActionHandler<any>> = {
message: (async (item: IndexItem & { metadata: MessageMetadata }) => { message: (async (item: IndexItem & { metadata: MessageMetadata }) => {
window.location.hash = `#?page=/messages`; window.location.hash = `#?page=/messages`;
@@ -81,32 +115,34 @@ export const actionMap: Record<string, ActionHandler<any>> = {
} }
} }
// Try to extract metadata values using multiple methods to handle XrayWrapper // 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.
const getMetadataValue = (key: string, altKey?: string): any => { const getMetadataValue = (key: string, altKey?: string): any => {
const bag = metadata as unknown as Record<string, any>;
try { try {
// Try direct access first const value = bag[key];
const value = metadata[key];
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
return value; return value;
} }
if (altKey) { if (altKey) {
const altValue = metadata[altKey]; const altValue = bag[altKey];
if (altValue !== undefined && altValue !== null) { if (altValue !== undefined && altValue !== null) {
return altValue; return altValue;
} }
} }
// Try accessing via Object.keys iteration (works around XrayWrapper)
try { try {
const keys = Object.keys(metadata); const keys = Object.keys(bag);
for (const k of keys) { for (const k of keys) {
if (k === key || k === altKey) { if (k === key || k === altKey) {
const val = metadata[k]; const val = bag[k];
if (val !== undefined && val !== null) { if (val !== undefined && val !== null) {
return val; return val;
} }
} }
} }
} catch (e) { } catch {
// Object.keys might fail on XrayWrapper, that's okay // Object.keys might fail on XrayWrapper, that's okay
} }
return undefined; return undefined;
@@ -189,14 +225,218 @@ export const actionMap: Record<string, ActionHandler<any>> = {
}) as ActionHandler<any>, }) as ActionHandler<any>,
subjectassessment: ((item: IndexItem) => { subjectassessment: ((item: IndexItem) => {
window.location.href = `/#?page=/assessments/${item.metadata.programme}:${item.metadata.subjectId}`; navigateToHashRoute(
`/assessments/${item.metadata.programme}:${item.metadata.subjectId}`,
);
}) as ActionHandler<any>, }) as ActionHandler<any>,
subjectcourse: ((item: IndexItem) => { subjectcourse: ((item: IndexItem) => {
window.location.href = `/#?page=/courses/${item.metadata.programme}:${item.metadata.subjectId}`; navigateToHashRoute(
`/courses/${item.metadata.programme}:${item.metadata.subjectId}`,
);
}) as ActionHandler<any>, }) as ActionHandler<any>,
forum: ((item: IndexItem) => { forum: ((item: IndexItem) => {
window.location.href = `/#?page=/forums/${item.metadata.forumId}`; 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);
}) as ActionHandler<any>, }) as ActionHandler<any>,
}; };
@@ -0,0 +1,386 @@
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;
}
}
@@ -0,0 +1,303 @@
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,10 +1,11 @@
import { clear, get, getAll, put, remove } from "./db"; import { clear, get, getAll, put, remove, resetDatabase } from "./db";
import { jobs } from "./jobs"; import { jobs } from "./jobs";
import { renderComponentMap } from "./renderComponents"; import { renderComponentMap } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types"; import type { IndexItem, Job, JobContext } from "./types";
import { VectorWorkerManager } from "./worker/vectorWorkerManager"; import { VectorWorkerManager } from "./worker/vectorWorkerManager";
import { loadDynamicItems } from "../utils/dynamicItems"; import { loadDynamicItems } from "../utils/dynamicItems";
import { getVectorizedItemIds } from "./utils"; import { getVectorizedItemIds } from "./utils";
import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion";
const META_STORE = "meta"; const META_STORE = "meta";
const LOCK_KEY = "bsq-indexer-lock"; const LOCK_KEY = "bsq-indexer-lock";
@@ -12,6 +13,50 @@ const HEARTBEAT_INTERVAL = 10000;
const LOCK_TIMEOUT = 20000; const LOCK_TIMEOUT = 20000;
const LOCK_ACQUIRE_TIMEOUT = 5000; 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 ─────────── */ /* ─────────── Progressmeta helpers ─────────── */
async function loadProgress<T = any>(jobId: string): Promise<T | undefined> { async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
const rec = await get(META_STORE, `progress:${jobId}`); const rec = await get(META_STORE, `progress:${jobId}`);
@@ -162,6 +207,8 @@ export async function loadAllStoredItems(): Promise<IndexItem[]> {
} }
export async function runIndexing(): Promise<void> { export async function runIndexing(): Promise<void> {
await ensureSchemaCurrent();
if (!(await acquireLock())) { if (!(await acquireLock())) {
console.debug( console.debug(
"%c[Indexer] Could not acquire lock - another tab is indexing or this tab is already indexing", "%c[Indexer] Could not acquire lock - another tab is indexing or this tab is already indexing",
@@ -178,8 +225,6 @@ export async function runIndexing(): Promise<void> {
const totalSteps = jobIds.length + 1; const totalSteps = jobIds.length + 1;
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs"); dispatchProgress(completedJobs, totalSteps, true, "Starting jobs");
let hasStreamingJobs = false;
for (const jobId of jobIds) { for (const jobId of jobIds) {
dispatchProgress( dispatchProgress(
completedJobs, completedJobs,
@@ -255,10 +300,6 @@ export async function runIndexing(): Promise<void> {
await setStoredItems(merged); await setStoredItems(merged);
await updateLastRunMeta(jobId); await updateLastRunMeta(jobId);
if (jobId === 'messages' || jobId === 'notifications') {
hasStreamingJobs = true;
}
console.debug( console.debug(
`%c[Indexer] ${job.label}: ${newItemsRaw.length} new items reported by run, ${merged.length} total items now in '${jobId}' store.`, `%c[Indexer] ${job.label}: ${newItemsRaw.length} new items reported by run, ${merged.length} total items now in '${jobId}' store.`,
"color: #00c46f", "color: #00c46f",
@@ -4,6 +4,14 @@ import { notificationsJob } from "./jobs/notifications";
import { forumsJob } from "./jobs/forums"; import { forumsJob } from "./jobs/forums";
import { subjectsJob } from "./jobs/subjects"; import { subjectsJob } from "./jobs/subjects";
import { assignmentsJob } from "./jobs/assignments"; 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> = { export const jobs: Record<string, Job> = {
messages: messagesJob, messages: messagesJob,
@@ -11,4 +19,12 @@ export const jobs: Record<string, Job> = {
forums: forumsJob, forums: forumsJob,
subjects: subjectsJob, subjects: subjectsJob,
assignments: assignmentsJob, assignments: assignmentsJob,
courses: coursesJob,
notices: noticesJob,
documents: documentsJob,
folio: folioJob,
portals: portalsJob,
reports: reportsJob,
goals: goalsJob,
passive: passiveJob,
}; };
@@ -0,0 +1,179 @@
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,
};
@@ -0,0 +1,139 @@
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,
};
@@ -0,0 +1,134 @@
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,
};
@@ -0,0 +1,109 @@
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,
};
@@ -0,0 +1,218 @@
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);
},
};
@@ -0,0 +1,49 @@
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;
},
};
@@ -0,0 +1,90 @@
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,
};
@@ -0,0 +1,97 @@
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,
};
@@ -0,0 +1,632 @@
import type { IndexItem } from "./types";
import { put, getAll } from "./db";
import {
buildIndexItem,
extractTextFromValue,
pickId,
pickTitle,
} from "./extract";
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
import { loadAllStoredItems } from "./indexer";
import { loadDynamicItems } from "../utils/dynamicItems";
import { renderComponentMap } from "./renderComponents";
import { jobs } from "./jobs";
/**
* 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;
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);
} 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> {
try {
const all = await loadAllStoredItems();
const decorated = all.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];
}
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch {
return { ...item, renderComponent };
}
} catch {
return item;
}
});
loadDynamicItems(decorated);
window.dispatchEvent(
new CustomEvent("dynamic-items-updated", {
detail: {
incremental: true,
jobId: STORE_ID,
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,10 +2,23 @@ import type { SvelteComponent } from "svelte";
import AssessmentItem from "../components/items/AssessmentItem.svelte"; import AssessmentItem from "../components/items/AssessmentItem.svelte";
import ForumItem from "../components/items/ForumItem.svelte"; import ForumItem from "../components/items/ForumItem.svelte";
import SubjectItem from "../components/items/SubjectItem.svelte"; import SubjectItem from "../components/items/SubjectItem.svelte";
import GenericItem from "../components/items/GenericItem.svelte";
export const renderComponentMap: Record<string, typeof SvelteComponent> = { export const renderComponentMap: Record<string, typeof SvelteComponent> = {
assessment: AssessmentItem as unknown as typeof SvelteComponent, assessment: AssessmentItem as unknown as typeof SvelteComponent,
message: AssessmentItem as unknown as typeof SvelteComponent, message: AssessmentItem as unknown as typeof SvelteComponent,
forum: ForumItem as unknown as typeof SvelteComponent, forum: ForumItem as unknown as typeof SvelteComponent,
subject: SubjectItem 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,
}; };
@@ -0,0 +1,112 @@
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 */
}
}
@@ -0,0 +1,16 @@
/**
* 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";
@@ -0,0 +1,328 @@
import {
isSensitiveKey,
looksLikeSecretValue,
redactSensitive,
extractTextFromValue,
pickTitle,
pickId,
buildIndexItem,
} from "./extract";
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
import {
coursesPayload,
documentsPayload,
folioEntryPayload,
noticesPayload,
portalsPayload,
settingsPayload,
subjectsListPayload,
} from "./__fixtures__/seqtaResponses";
/**
* Lightweight in-process self-tests for the global-search overhaul.
*
* The repository does not (yet) ship with a test runner, so we instead
* expose a deterministic suite of assertions over the pure helpers that
* back active jobs and the passive observer. This is intentionally
* dependency-free so it can run inside the extension page (`window.
* globalSearchDebug.runSelfTests()`) and from any future Vitest harness
* without modification.
*/
interface TestCase {
name: string;
run: () => void | Promise<void>;
}
class AssertionError extends Error {
constructor(message: string) {
super(message);
this.name = "AssertionError";
}
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new AssertionError(message);
}
function assertEqual<T>(actual: T, expected: T, label: string) {
if (actual !== expected) {
throw new AssertionError(
`${label}: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`,
);
}
}
function assertContains(haystack: string, needle: string, label: string) {
if (!haystack.includes(needle)) {
throw new AssertionError(
`${label}: expected "${haystack}" to contain "${needle}"`,
);
}
}
function assertNotContains(haystack: string, needle: string, label: string) {
if (haystack.includes(needle)) {
throw new AssertionError(
`${label}: expected "${haystack}" NOT to contain "${needle}"`,
);
}
}
const cases: TestCase[] = [
{
name: "normalizeSeqtaPath strips query tokens",
run: () => {
assertEqual(
normalizeSeqtaPath("/seqta/student/load/messages?mokx3qef"),
"/seqta/student/load/messages",
"trailing token",
);
assertEqual(
normalizeSeqtaPath(
"https://learn.example.com/seqta/student/load/courses?abc123",
),
"/seqta/student/load/courses",
"absolute URL",
);
},
},
{
name: "isSensitiveSeqtaPath catches credential routes",
run: () => {
assert(
isSensitiveSeqtaPath("/seqta/student/login?xyz"),
"login is sensitive",
);
assert(
isSensitiveSeqtaPath("/seqta/student/save/message"),
"save/* is sensitive",
);
assert(
isSensitiveSeqtaPath("/seqta/student/load/settings"),
"settings is sensitive",
);
assert(
isSensitiveSeqtaPath("/seqta/student/load/prefs?z=1"),
"prefs is sensitive",
);
assert(
isSensitiveSeqtaPath("/seqta/ta/masquerade"),
"masquerade is sensitive",
);
assert(
!isSensitiveSeqtaPath("/seqta/student/load/messages"),
"messages is NOT sensitive",
);
assert(
!isSensitiveSeqtaPath("/seqta/student/load/courses"),
"courses is NOT sensitive",
);
},
},
{
name: "isSensitiveKey covers the credential vocabulary",
run: () => {
for (const key of [
"password",
"Password",
"client_secret",
"apiKey",
"X-API-Token",
"jwtSession",
"oauth_signature",
]) {
assert(isSensitiveKey(key), `expected ${key} to be sensitive`);
}
for (const key of ["title", "subject", "uuid", "metaclass"]) {
assert(!isSensitiveKey(key), `expected ${key} to be safe`);
}
},
},
{
name: "looksLikeSecretValue catches token-shaped strings",
run: () => {
assert(
looksLikeSecretValue(
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.abc123def456",
),
"JWT looks secret",
);
assert(
looksLikeSecretValue("a".repeat(40) + "b".repeat(40)),
"long base64-ish string looks secret",
);
assert(
!looksLikeSecretValue("Hello world"),
"short readable text is safe",
);
assert(
!looksLikeSecretValue("https://example.com/foo/bar"),
"URLs are not secrets",
);
assert(
!looksLikeSecretValue("3162189c-2052-4f83-ad83-a66c57460ea2"),
"UUIDs are useful and not secret",
);
},
},
{
name: "redactSensitive scrubs settings payloads",
run: () => {
const cleaned = redactSensitive(settingsPayload);
const json = JSON.stringify(cleaned);
assertNotContains(json, "global.dropbox.api.key", "dropbox key dropped");
assertNotContains(json, "xxx-do-not-index", "secret value dropped");
},
},
{
name: "extractTextFromValue distills HTML and skips secrets",
run: () => {
const text = extractTextFromValue({
title: "Hello",
body: "<p>Some <strong>HTML</strong> body.</p>",
password: "should-not-appear",
nested: { token: "leak-me-please" },
});
assertContains(text, "Hello", "title preserved");
assertContains(text, "HTML body", "html flattened");
assertNotContains(text, "should-not-appear", "password redacted");
assertNotContains(text, "leak-me-please", "nested token redacted");
},
},
{
name: "pickTitle / pickId prefer common SEQTA fields",
run: () => {
assertEqual(
pickTitle({ title: "Hello", name: "Other" }),
"Hello",
"title wins over name",
);
assertEqual(
pickTitle({ filename: "doc.pdf" }),
"doc.pdf",
"filename fallback",
);
assertEqual(pickId({ id: 42 }), "42", "numeric id stringified");
assertEqual(pickId({ uuid: "abc" }), "abc", "uuid id");
},
},
{
name: "buildIndexItem produces redacted, well-formed records",
run: () => {
const item = buildIndexItem({
id: "x-1",
text: "Test",
category: "passive",
rawForContent: {
title: "Test",
body: "<p>Hello</p>",
token: "should-be-stripped",
},
metadata: { route: "/seqta/student/load/whatever", apiKey: "drop" },
actionId: "passive",
renderComponentId: "passive",
});
assertEqual(item.id, "x-1", "id propagated");
assertContains(item.content, "Hello", "html distilled");
assertNotContains(item.content, "should-be-stripped", "token stripped");
assert(
!("apiKey" in (item.metadata as Record<string, unknown>)),
"apiKey metadata stripped",
);
assertEqual(item.category, "passive", "category passes through");
},
},
{
name: "courses fixture flattens lesson HTML",
run: () => {
// Verify that the structural shape we depend on still matches.
assert(Array.isArray(coursesPayload.w), "lesson grid present");
const lessonHtml = (coursesPayload.w[0]?.[1] as { l?: string })?.l ?? "";
assertContains(lessonHtml, "ed.ted.com", "lesson html link present");
},
},
{
name: "subjects fixture exposes programme/metaclass",
run: () => {
const subject = subjectsListPayload[0]?.subjects[0];
assert(subject, "fixture has at least one subject");
assert(
Number.isFinite(subject!.programme) &&
Number.isFinite(subject!.metaclass),
"programme & metaclass numeric",
);
},
},
{
name: "documents fixture exposes uuid + filename",
run: () => {
const doc = documentsPayload[0]?.docs[0];
assert(doc?.uuid && doc?.filename, "uuid + filename present");
},
},
{
name: "notices fixture is HTML-bearing",
run: () => {
assertContains(
noticesPayload[0]?.contents ?? "",
"<p>",
"notice html present",
);
},
},
{
name: "portals fixture has external url",
run: () => {
assert(portalsPayload[0]?.url?.includes("mathletics"), "portal url");
},
},
{
name: "folio entry contents passes html-flattening",
run: () => {
const distilled = extractTextFromValue(folioEntryPayload, {
maxChars: 4000,
});
assertContains(distilled, "reflection", "folio body extracted");
},
},
];
export interface SelfTestReport {
passed: number;
failed: number;
failures: Array<{ name: string; error: string }>;
}
/**
* Runs every assertion case and resolves with a summary. Never throws.
*
* Designed to be invoked from `window.globalSearchDebug.runSelfTests()`
* by maintainers who want to validate the indexing pipeline against a
* real SEQTA tab.
*/
export async function runGlobalSearchSelfTests(): Promise<SelfTestReport> {
const report: SelfTestReport = { passed: 0, failed: 0, failures: [] };
for (const test of cases) {
try {
await test.run();
report.passed++;
} catch (e) {
report.failed++;
const error =
e instanceof Error ? `${e.name}: ${e.message}` : String(e);
report.failures.push({ name: test.name, error });
}
}
if (report.failed > 0) {
console.warn(
`[Global Search Self-Tests] ${report.failed} failed / ${report.passed} passed`,
report.failures,
);
} else {
console.info(
`[Global Search Self-Tests] All ${report.passed} cases passed`,
);
}
return report;
}
@@ -19,6 +19,8 @@ export class VectorWorkerManager {
private initializationMutex = false; private initializationMutex = false;
private idleTimer: NodeJS.Timeout | null = null; private idleTimer: NodeJS.Timeout | null = null;
private unloadTimer: NodeJS.Timeout | null = null; private unloadTimer: NodeJS.Timeout | null = null;
/** Non-streaming `process` jobs must not hit the idle shutdown mid-flight. */
private vectorizationLockCount = 0;
private streamingSession: { private streamingSession: {
isActive: boolean; isActive: boolean;
@@ -92,6 +94,12 @@ export class VectorWorkerManager {
break; break;
case "progress": case "progress":
if (
data.status === "processing" ||
data.status === "started"
) {
this.bumpActivityDuringVectorization();
}
if (this.progressCallback) { if (this.progressCallback) {
this.progressCallback(data); this.progressCallback(data);
@@ -120,6 +128,7 @@ export class VectorWorkerManager {
break; break;
case "streamingProgress": case "streamingProgress":
this.bumpActivityDuringVectorization();
if (this.progressCallback && this.streamingSession?.isActive) { if (this.progressCallback && this.streamingSession?.isActive) {
const { processed } = data; const { processed } = data;
this.progressCallback({ this.progressCallback({
@@ -150,6 +159,7 @@ export class VectorWorkerManager {
this.readyPromise = null; this.readyPromise = null;
this.progressCallback = null; this.progressCallback = null;
this.initializationMutex = false; this.initializationMutex = false;
this.vectorizationLockCount = 0;
this.clearIdleTimer(); this.clearIdleTimer();
this.clearUnloadTimer(); this.clearUnloadTimer();
if (this.streamingSession?.isActive) { if (this.streamingSession?.isActive) {
@@ -158,15 +168,27 @@ export class VectorWorkerManager {
} }
private startIdleTimer() { private startIdleTimer() {
if (this.vectorizationLockCount > 0 || this.streamingSession?.isActive) {
return;
}
this.clearIdleTimer(); this.clearIdleTimer();
this.idleTimer = setTimeout(() => { this.idleTimer = setTimeout(() => {
if (!this.streamingSession?.isActive && this.isInitialized) { if (this.vectorizationLockCount > 0) return;
if (this.streamingSession?.isActive) return;
if (!this.isInitialized) return;
console.debug("[VectorWorker] Auto-shutting down due to 2 minutes of inactivity"); console.debug("[VectorWorker] Auto-shutting down due to 2 minutes of inactivity");
this.resetWorkerState(); this.resetWorkerState();
}
}, 120000); // 2 minutes }, 120000); // 2 minutes
} }
/** Extends idle deadline while embeddings run; cheap if no idle timer is scheduled. */
private bumpActivityDuringVectorization() {
if (this.vectorizationLockCount > 0 || this.streamingSession?.isActive) {
this.clearIdleTimer();
}
this.updateActivity();
}
private clearIdleTimer() { private clearIdleTimer() {
if (this.idleTimer) { if (this.idleTimer) {
clearTimeout(this.idleTimer); clearTimeout(this.idleTimer);
@@ -184,6 +206,7 @@ export class VectorWorkerManager {
private scheduleUnload(delay: number = 10000) { private scheduleUnload(delay: number = 10000) {
this.clearUnloadTimer(); this.clearUnloadTimer();
this.unloadTimer = setTimeout(() => { this.unloadTimer = setTimeout(() => {
if (this.vectorizationLockCount > 0) return;
if (!this.streamingSession?.isActive && this.isInitialized) { if (!this.streamingSession?.isActive && this.isInitialized) {
console.debug("[VectorWorker] Auto-unloading after processing complete"); console.debug("[VectorWorker] Auto-unloading after processing complete");
this.resetWorkerState(); this.resetWorkerState();
@@ -193,6 +216,9 @@ export class VectorWorkerManager {
private updateActivity() { private updateActivity() {
this.clearUnloadTimer(); this.clearUnloadTimer();
if (this.vectorizationLockCount > 0 || this.streamingSession?.isActive) {
return;
}
this.startIdleTimer(); this.startIdleTimer();
} }
@@ -298,8 +324,31 @@ export class VectorWorkerManager {
return; return;
} }
this.progressCallback = onProgress || null; // Wait until the worker reports a terminal status. Previously this method
this.updateActivity(); // returned as soon as the job was queued, so indexers.ts continued into
// stopHeartbeat/loadAll/loadDynamicItems on the main thread while
// vectorization was still running — blocking indexing-progress handlers
// and freezing the chip on “Vectorization in progress”.
this.vectorizationLockCount++;
this.clearIdleTimer();
this.clearUnloadTimer();
try {
await new Promise<void>((resolve) => {
let settled = false;
const wrap: ProgressCallback = (data) => {
onProgress?.(data);
if (
!settled &&
(data.status === "complete" ||
data.status === "error" ||
data.status === "cancelled")
) {
settled = true;
resolve();
}
};
this.progressCallback = wrap;
console.debug( console.debug(
`Sending ${uniqueItems.length} unique items to worker for processing.`, `Sending ${uniqueItems.length} unique items to worker for processing.`,
@@ -309,6 +358,16 @@ export class VectorWorkerManager {
type: "process", type: "process",
data: { items: uniqueItems }, data: { items: uniqueItems },
}); });
});
} finally {
this.vectorizationLockCount = Math.max(0, this.vectorizationLockCount - 1);
if (
this.vectorizationLockCount === 0 &&
!this.streamingSession?.isActive
) {
this.startIdleTimer();
}
}
} }
async startStreamingSession( async startStreamingSession(
@@ -0,0 +1,151 @@
import type { CombinedResult } from "../core/types";
import type { IndexItem } from "../indexing/types";
function toFiniteNumber(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;
}
/** Same SPA destination as handlers for `course` / `subjectcourse` / passive `courses`. */
function shouldDedupeAsSameCourseSPA(item: IndexItem): boolean {
if (item.actionId === "subjectassessment") return false;
if (item.metadata?.type === "assessments") return false;
if (item.renderComponentId === "course") return true;
if (item.actionId === "course") return true;
if (item.actionId === "subjectcourse") return true;
if (
item.actionId === "passive" &&
item.metadata?.sourcePage === "/courses"
) {
return true;
}
return false;
}
export function courseDestinationKey(item: IndexItem): string | undefined {
if (!shouldDedupeAsSameCourseSPA(item)) return undefined;
const md = item.metadata ?? {};
const programme = toFiniteNumber(
md.programme ?? md.programmeId ?? md.programmeID,
);
const metaclass = toFiniteNumber(
md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId,
);
if (programme === undefined || metaclass === undefined) return undefined;
return `course:${programme}:${metaclass}`;
}
function isPassiveLike(item: IndexItem): boolean {
return (
item.actionId === "passive" || item.metadata?.source === "passive"
);
}
function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem {
const aP = isPassiveLike(a);
const bP = isPassiveLike(b);
if (aP && !bP) return b;
if (!aP && bP) return a;
// Prefer curated job row (courses store) vs other categories
if (a.category === "courses" && b.category !== "courses") return a;
if (b.category === "courses" && a.category !== "courses") return b;
if (a.renderComponentId === "course" && b.renderComponentId !== "course")
return a;
if (b.renderComponentId === "course" && a.renderComponentId !== "course")
return b;
const ad = typeof a.dateAdded === "number" ? a.dateAdded : 0;
const bd = typeof b.dateAdded === "number" ? b.dateAdded : 0;
return ad >= bd ? a : b;
}
/**
* Collapses multiple index rows that open the same course hash route
* (e.g. `course` job + passive `/load/courses` capture) so search shows one hit.
*/
export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
const winners = new Map<string, IndexItem>();
for (const item of items) {
const key = courseDestinationKey(item);
if (!key) continue;
const prev = winners.get(key);
winners.set(
key,
prev ? pickBetterCourseNavDuplicate(prev, item) : item,
);
}
const seenCanon = new Set<string>();
const out: IndexItem[] = [];
for (const item of items) {
const key = courseDestinationKey(item);
if (!key) {
out.push(item);
continue;
}
if (seenCanon.has(key)) continue;
seenCanon.add(key);
out.push(winners.get(key)!);
}
return out;
}
function dynamicCourseKey(row: CombinedResult): string | undefined {
if (row.type !== "dynamic") return undefined;
return courseDestinationKey(row.item as IndexItem);
}
/**
* Final pass after hybrid expansion: vector-only recall can still surface a
* second row for the same `/courses/P:M` SPA route using a stale passive id.
*/
export function dedupeCombinedResultsByCourseNav(
results: CombinedResult[],
): CombinedResult[] {
const best = new Map<string, CombinedResult>();
for (const r of results) {
const key = dynamicCourseKey(r);
if (!key) continue;
const prev = best.get(key);
if (!prev) {
best.set(key, r);
continue;
}
const aItem = prev.item as IndexItem;
const bItem = r.item as IndexItem;
const winnerItem = pickBetterCourseNavDuplicate(aItem, bItem);
const envelope = winnerItem.id === aItem.id ? prev : r;
best.set(key, {
...envelope,
score: Math.max(prev.score, r.score),
id: winnerItem.id,
item: winnerItem,
});
}
const seenCanon = new Set<string>();
const out: CombinedResult[] = [];
for (const r of results) {
const key = dynamicCourseKey(r);
if (!key) {
out.push(r);
continue;
}
if (seenCanon.has(key)) continue;
seenCanon.add(key);
out.push(best.get(key)!);
}
return out;
}
@@ -2,6 +2,32 @@ import type { IndexItem } from "../indexing/types";
import type { CombinedResult } from "../core/types"; import type { CombinedResult } from "../core/types";
import { searchVectors, type VectorSearchResult } from "./vector/vectorSearch"; import { searchVectors, type VectorSearchResult } from "./vector/vectorSearch";
import { jobs } from "../indexing/jobs"; import { jobs } from "../indexing/jobs";
import {
getLexicalMatchQuality,
isStrongLexicalMatch,
STRONG_LEXICAL_THRESHOLD,
} from "./lexicalMatch";
function isIndexItem(item: CombinedResult["item"]): item is IndexItem {
return (item as IndexItem).dateAdded !== undefined;
}
/**
* Heuristic for "this query is still too short / too sparse for vector
* recall to be reliable". When true we should not promote vector-only
* results above lexical ones.
*
* Note: this is intentionally distinct from the absolute >2 character cut-off
* used for `hybridSearch`. Vector recall on 3-7 character single-token
* queries is noisy enough that we should keep lexical results dominant.
*/
function isWeakSemanticQuery(trimmedQuery: string): boolean {
if (trimmedQuery.length < 8) return true;
const meaningfulTokens = trimmedQuery
.split(/\s+/)
.filter((t) => t.length >= 3);
return meaningfulTokens.length < 2;
}
/** /**
* Hybrid Search Implementation * Hybrid Search Implementation
@@ -36,14 +62,6 @@ const DEFAULT_OPTIONS: Required<HybridSearchOptions> = {
recencyWeight: 0.1, recencyWeight: 0.1,
}; };
/**
* Normalizes a score to 0-1 range
*/
function normalizeScore(score: number, min: number, max: number): number {
if (max === min) return 0.5;
return Math.max(0, Math.min(1, (score - min) / (max - min)));
}
/** /**
* Calculates recency boost based on item age * Calculates recency boost based on item age
*/ */
@@ -55,28 +73,56 @@ function calculateRecencyBoost(item: IndexItem, now: number): number {
} }
/** /**
* Calculates popularity boost (can be extended with click tracking, etc.) * Category-aware popularity / structure boost.
*
* High-confidence curated content (assignments, courses, subjects, forums)
* sits above noisier sources (notices, documents) which sit above the
* passive store. This keeps the most actionable hits at the top while
* still surfacing wide-recall semantic matches when relevant.
*/ */
function calculatePopularityBoost(item: IndexItem): number { function calculatePopularityBoost(item: IndexItem): number {
// For now, boost based on category and metadata
let boost = 0; let boost = 0;
// Boost assignments/assessments switch (item.category) {
if (item.category === "assignments") { case "assignments":
boost += 0.1; boost += 0.12;
break;
case "subjects":
case "courses":
boost += 0.08;
break;
case "forums":
case "messages":
boost += 0.06;
break;
case "notices":
case "folio":
case "reports":
case "goals":
boost += 0.04;
break;
case "documents":
boost += 0.03;
break;
case "portals":
boost += 0.02;
break;
case "passive":
boost -= 0.1;
break;
case "messages-support":
boost -= 0.18;
break;
} }
// Boost upcoming items if (item.metadata?.isUpcoming) boost += 0.12;
if (item.metadata?.isUpcoming) { if (item.metadata?.subjectCode) boost += 0.04;
boost += 0.15; if (item.metadata?.entityType === "course") boost += 0.02;
} if (item.metadata?.source === "passive") boost -= 0.08;
if (item.metadata?.supportRecord) boost -= 0.12;
if (item.metadata?.priority === "low") boost -= 0.05;
// Boost items with subject codes (more structured) return Math.max(-0.2, Math.min(boost, 0.3));
if (item.metadata?.subjectCode) {
boost += 0.05;
}
return Math.min(boost, 0.3); // Cap at 0.3
} }
/** /**
@@ -98,10 +144,6 @@ export async function hybridSearch(
// Limit BM25 results to top K // Limit BM25 results to top K
const topBm25Results = bm25Results.slice(0, opts.bm25TopK); const topBm25Results = bm25Results.slice(0, opts.bm25TopK);
// Get vector search results for reranking
// We'll search the full index and then filter to our BM25 results
let vectorResults: VectorSearchResult[] = [];
if (trimmedQuery.length > 2) { if (trimmedQuery.length > 2) {
try { try {
// Get more vector results than BM25 results to ensure coverage // Get more vector results than BM25 results to ensure coverage
@@ -121,59 +163,59 @@ export async function hybridSearch(
// Now rerank BM25 results with vector scores // Now rerank BM25 results with vector scores
const now = Date.now(); const now = Date.now();
const rerankedResults = topBm25Results.map(result => { const rerankedResults: CombinedResult[] = topBm25Results.map(result => {
const item = result.item; const item = result.item;
// Normalize BM25 score to 0-1 // Static command items don't have dateAdded/metadata/category to score
// Fuse.js scores: lower is better (0 = perfect match) // against — pass them through untouched so palette commands still
// We need to invert: higher score = better match // surface correctly.
// Result.score is typically 0-100, where higher = better if (!isIndexItem(item)) {
// So we normalize it to 0-1 return result;
}
// Normalize BM25 score to 0-1.
// Result.score is typically 0-100, where higher = better, so we
// clamp into the 0..1 range.
const normalizedBm25Score = Math.max(0, Math.min(1, result.score / 100)); const normalizedBm25Score = Math.max(0, Math.min(1, result.score / 100));
// Get vector similarity (0-1, already normalized) // Get vector similarity (0-1, already normalized). If item wasn't in
// If item wasn't in vector results, use a default low score // vector results, use a default mid-low score.
const vectorSimilarity = vectorMap.get(item.id) || 0.3; // Default to 0.3 if not found const vectorSimilarity = vectorMap.get(item.id) || 0.3;
// Calculate recency boost (0-1 range)
const recencyBoost = opts.recencyBoost const recencyBoost = opts.recencyBoost
? calculateRecencyBoost(item, now) * opts.recencyWeight ? calculateRecencyBoost(item, now) * opts.recencyWeight
: 0; : 0;
// Calculate popularity boost (0-1 range)
const popularityBoost = calculatePopularityBoost(item); const popularityBoost = calculatePopularityBoost(item);
// Apply job-specific boost if available
const job = jobs[item.category]; const job = jobs[item.category];
let jobBoost = 0; let jobBoost = 0;
if (job && typeof job.boostCriteria === 'function') { if (job && typeof job.boostCriteria === 'function') {
const boost = job.boostCriteria(item, trimmedQuery); const boost = job.boostCriteria(item, trimmedQuery);
if (boost) { if (boost) {
jobBoost = boost / 100; // Normalize boost to 0-1 jobBoost = boost / 100;
} }
} }
// Combine scores using weighted average // Lexical guardrail: title matches must outweigh fuzzy vector/content
// BM25 and vector are weighted, boosts are additive // overlap so exact titles lead the list.
const lexicalQuality = getLexicalMatchQuality(item, trimmedQuery);
let lexicalBonus = lexicalQuality > 0 ? lexicalQuality / 80 : 0;
if (lexicalQuality >= 12) lexicalBonus += 0.42;
else if (lexicalQuality >= 10) lexicalBonus += 0.24;
else if (lexicalQuality >= 8) lexicalBonus += 0.14;
const hybridScore = const hybridScore =
(normalizedBm25Score * opts.bm25Weight) + (normalizedBm25Score * opts.bm25Weight) +
(vectorSimilarity * opts.vectorWeight) + (vectorSimilarity * opts.vectorWeight) +
recencyBoost + recencyBoost +
popularityBoost + popularityBoost +
jobBoost; jobBoost +
lexicalBonus;
return { return {
...result, ...result,
score: hybridScore * 100, // Scale back to 0-100 for consistency score: hybridScore * 100,
// Store component scores for debugging (optional, can be removed in production)
_hybridScores: {
bm25: normalizedBm25Score,
vector: vectorSimilarity,
recency: recencyBoost,
popularity: popularityBoost,
jobBoost: jobBoost,
final: hybridScore,
},
}; };
}); });
@@ -200,7 +242,7 @@ export async function hybridSearch(
export async function hybridSearchWithExpansion( export async function hybridSearchWithExpansion(
bm25Results: CombinedResult[], bm25Results: CombinedResult[],
query: string, query: string,
allItems: IndexItem[], _allItems: IndexItem[],
options: HybridSearchOptions = {}, options: HybridSearchOptions = {},
): Promise<CombinedResult[]> { ): Promise<CombinedResult[]> {
const opts = { ...DEFAULT_OPTIONS, ...options }; const opts = { ...DEFAULT_OPTIONS, ...options };
@@ -214,6 +256,13 @@ export async function hybridSearchWithExpansion(
return rerankedBm25; return rerankedBm25;
} }
// For short / single-token queries vector expansion brings in too much
// noise (and is the main reason results "flicker" between adjacent
// keystrokes). Keep semantic recall for longer queries.
if (isWeakSemanticQuery(trimmedQuery)) {
return rerankedBm25.slice(0, opts.finalLimit);
}
// Get vector search results // Get vector search results
let vectorResults: VectorSearchResult[] = []; let vectorResults: VectorSearchResult[] = [];
try { try {
@@ -229,8 +278,26 @@ export async function hybridSearchWithExpansion(
const now = Date.now(); const now = Date.now();
// Compute the floor at which a vector-only result is allowed to enter the
// ranking. Strong lexical matches in the BM25 list set this floor — a
// vector-only result must beat the lowest strong lexical match's score by
// a margin to displace it.
let strongLexicalFloor = -Infinity;
for (const r of rerankedBm25) {
if (isIndexItem(r.item) && isStrongLexicalMatch(r.item, trimmedQuery)) {
if (r.score > strongLexicalFloor) {
strongLexicalFloor = r.score;
}
}
}
// Vector-only results may sit at most at this score:
const vectorOnlyCeiling = strongLexicalFloor === -Infinity
? Infinity
: strongLexicalFloor - 1;
vectorResults.forEach(v => { vectorResults.forEach(v => {
if (!bm25Ids.has(v.object.id)) { if (bm25Ids.has(v.object.id)) return;
// This is a semantic match that BM25 missed // This is a semantic match that BM25 missed
const item = v.object; const item = v.object;
@@ -240,8 +307,24 @@ export async function hybridSearchWithExpansion(
: 0; : 0;
const popularityBoost = calculatePopularityBoost(item); const popularityBoost = calculatePopularityBoost(item);
// Penalize vector-only matches that have no lexical content overlap.
// Vector recall on its own is fuzzy — without lexical confirmation we
// should rank these below curated keyword hits.
const lexicalQuality = getLexicalMatchQuality(item, trimmedQuery);
let vectorOnlyPenalty = 0;
if (lexicalQuality === 0) {
vectorOnlyPenalty -= 0.18;
}
// Passive captures without lexical confirmation are demoted further —
// they're often raw API records that should never lead the result list.
if (item.category === "passive" && lexicalQuality < STRONG_LEXICAL_THRESHOLD) {
vectorOnlyPenalty -= 0.12;
}
// Vector-only results get lower base score but high vector similarity // Vector-only results get lower base score but high vector similarity
const vectorScore = v.similarity * opts.vectorWeight + recencyBoost + popularityBoost; const vectorScore =
v.similarity * opts.vectorWeight + recencyBoost + popularityBoost + vectorOnlyPenalty;
// Apply job-specific boost if available // Apply job-specific boost if available
const job = jobs[item.category]; const job = jobs[item.category];
@@ -253,20 +336,15 @@ export async function hybridSearchWithExpansion(
} }
} }
let finalScore = (vectorScore + jobBoost) * 100;
if (finalScore > vectorOnlyCeiling) finalScore = vectorOnlyCeiling;
vectorOnlyResults.push({ vectorOnlyResults.push({
id: item.id, id: item.id,
type: "dynamic" as const, type: "dynamic" as const,
score: (vectorScore + jobBoost) * 100, score: finalScore,
item, item,
_hybridScores: {
bm25: 0,
vector: v.similarity,
recency: recencyBoost,
popularity: popularityBoost,
final: vectorScore + jobBoost,
},
}); });
}
}); });
// Combine reranked BM25 results with vector-only results // Combine reranked BM25 results with vector-only results
@@ -0,0 +1,118 @@
import type { IndexItem } from "../indexing/types";
/**
* Maximum bonus a strong lexical title match can contribute on top of the
* underlying Fuse / hybrid score. Tuned to outweigh small vector reranking
* deltas so a true assessment-title match cannot be displaced by a vector
* neighbour as the user types one more character.
*/
export const LEXICAL_TITLE_BONUS = 12;
/**
* Threshold at or above which a result counts as a "strong lexical match".
* Strong matches must always be surfaced and protected from vector reranking
* displacing them.
*/
export const STRONG_LEXICAL_THRESHOLD = 6;
const WORD_SPLIT_RE = /\s+/;
const NON_WORD_RE = /[^a-z0-9]+/gi;
function normalize(value: string | undefined | null): string {
if (!value) return "";
return String(value).toLowerCase().trim();
}
function tokens(value: string): string[] {
return normalize(value)
.split(WORD_SPLIT_RE)
.map((t) => t.replace(NON_WORD_RE, ""))
.filter(Boolean);
}
/**
* Score how strongly the query lexically matches the title-like fields of an
* IndexItem. Return value is a non-negative number 0 means no useful match.
*
* Tiers (roughly):
* ~12 exact title equality
* ~10 title starts with full query string
* ~8 title contains full query string, on a word boundary
* ~7 ordered token-prefix match (e.g. `world w` vs `World War 2 Essay`)
* ~5 subject / metadata title contains query
* ~3 any token in title starts with query
* ~2 substring anywhere in title
* 0 no lexical signal
*
* The function is intentionally cheap (string ops only, no regex compilation
* per call beyond the constants above) because it is called for every item in
* the candidate pool.
*/
export function getLexicalMatchQuality(item: IndexItem, query: string): number {
const q = normalize(query);
if (!q) return 0;
const title = normalize(item.text);
if (!title) return 0;
if (title === q) return 12;
if (title.startsWith(q + " ") || title.startsWith(q)) return 10;
const queryTokens = tokens(q);
const titleTokens = tokens(title);
if (queryTokens.length > 0 && titleTokens.length >= queryTokens.length) {
let bestStreakStart = -1;
for (let i = 0; i <= titleTokens.length - queryTokens.length; i++) {
let ok = true;
for (let j = 0; j < queryTokens.length; j++) {
const tt = titleTokens[i + j];
const qt = queryTokens[j];
const isLast = j === queryTokens.length - 1;
if (isLast) {
if (!tt.startsWith(qt)) {
ok = false;
break;
}
} else {
if (tt !== qt) {
ok = false;
break;
}
}
}
if (ok) {
bestStreakStart = i;
break;
}
}
if (bestStreakStart === 0) return 9;
if (bestStreakStart > 0) return 7;
}
if (title.includes(" " + q) || title.includes(q + " ")) return 8;
// Token starts-with anywhere
for (const t of titleTokens) {
if (t.startsWith(q)) return 3;
}
// Subject / curated metadata title
const md = (item.metadata ?? {}) as Record<string, unknown>;
const subjectName = normalize(
typeof md.subjectName === "string" ? md.subjectName : "",
);
const subjectCode = normalize(
typeof md.subjectCode === "string" ? md.subjectCode : "",
);
if (subjectName && (subjectName === q || subjectName.startsWith(q))) return 5;
if (subjectCode && (subjectCode === q || subjectCode.startsWith(q))) return 5;
if (title.includes(q)) return 2;
return 0;
}
export function isStrongLexicalMatch(item: IndexItem, query: string): boolean {
return getLexicalMatchQuality(item, query) >= STRONG_LEXICAL_THRESHOLD;
}
@@ -3,10 +3,64 @@ import { getStaticCommands, type StaticCommandItem } from "../core/commands";
import { getDynamicItems } from "../utils/dynamicItems"; import { getDynamicItems } from "../utils/dynamicItems";
import type { CombinedResult } from "../core/types"; import type { CombinedResult } from "../core/types";
import type { IndexItem } from "../indexing/types"; import type { IndexItem } from "../indexing/types";
import { searchVectors } from "./vector/vectorSearch"; import { dedupeCombinedResultsByCourseNav, dedupeIndexItemsForSearch } from "./dedupeIndexItems";
import type { VectorSearchResult } from "./vector/vectorTypes";
import { jobs } from "../indexing/jobs";
import { hybridSearchWithExpansion } from "./hybridSearch"; import { hybridSearchWithExpansion } from "./hybridSearch";
import {
getLexicalMatchQuality,
isStrongLexicalMatch,
STRONG_LEXICAL_THRESHOLD,
} from "./lexicalMatch";
/** Same normalization as lexical matching (trim + lowercase). */
function normSearchKey(s: string): string {
return s.trim().toLowerCase();
}
/**
* Exact title tiers so palette navigation (e.g. "Home", "Assessments") always
* wins over hybrid-scored body matches. Higher = sort earlier.
*/
function exactTitleSortTier(r: CombinedResult, queryNorm: string): number {
if (!queryNorm) return 0;
if (r.type === "command") {
const cmd = r.item as StaticCommandItem;
if (normSearchKey(cmd.text) !== queryNorm) return 0;
return cmd.category === "navigation" ? 3 : 2;
}
const ix = r.item as IndexItem;
if (normSearchKey(ix.text) === queryNorm) return 1;
return 0;
}
function compareCombinedSearchResults(
a: CombinedResult,
b: CombinedResult,
queryNorm: string,
): number {
const tierDiff = exactTitleSortTier(b, queryNorm) - exactTitleSortTier(a, queryNorm);
if (tierDiff !== 0) return tierDiff;
if (a.type === "command" && b.type === "dynamic") {
return b.score - a.score - 10;
}
if (a.type === "dynamic" && b.type === "command") {
return b.score - a.score + 10;
}
return b.score - a.score;
}
function syntheticIndexFromCommand(cmd: StaticCommandItem): IndexItem {
return {
id: cmd.id,
text: cmd.text,
category: cmd.category,
content: "",
dateAdded: 0,
metadata: {},
actionId: "",
renderComponentId: "",
};
}
// Search result cache for better performance // Search result cache for better performance
const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>(); const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>();
@@ -25,8 +79,10 @@ function setCachedResults(query: string, results: CombinedResult[]) {
// Limit cache size // Limit cache size
if (searchCache.size >= MAX_CACHE_SIZE) { if (searchCache.size >= MAX_CACHE_SIZE) {
const firstKey = searchCache.keys().next().value; const firstKey = searchCache.keys().next().value;
if (firstKey !== undefined) {
searchCache.delete(firstKey); searchCache.delete(firstKey);
} }
}
searchCache.set(query, { results, timestamp: Date.now() }); searchCache.set(query, { results, timestamp: Date.now() });
} }
@@ -46,8 +102,9 @@ if (typeof window !== 'undefined') {
} }
export function createSearchIndexes() { export function createSearchIndexes() {
clearSearchCache();
const commands = getStaticCommands(); const commands = getStaticCommands();
const dynamicItems = getDynamicItems(); const dynamicItems = dedupeIndexItemsForSearch(getDynamicItems());
// Optimized command search options // Optimized command search options
const commandOptions = { const commandOptions = {
@@ -61,23 +118,40 @@ export function createSearchIndexes() {
findAllMatches: false, // Performance optimization findAllMatches: false, // Performance optimization
}; };
// Optimized dynamic content search options // Optimized dynamic content search options.
// The expanded corpus mixes structured entities (assessments, subjects)
// with free-form text (course content, notices, folio bodies, passive
// captures) so we list a broad set of metadata keys while keeping titles
// dominant in the ranking.
// NOTE: metadata.route is intentionally excluded. Raw API paths like
// `/seqta/student/load/message/people` should never influence ranking — they
// historically caused passive-capture support records to bubble up above
// real assessments when the user typed substrings that happened to appear in
// the path.
const dynamicOptions = { const dynamicOptions = {
keys: [ keys: [
{ name: "text", weight: 3 }, // Increased weight for title matches { name: "text", weight: 3 }, // Title is king
{ name: "content", weight: 1 }, { name: "content", weight: 1 },
{ name: "category", weight: 0.5 }, // Lower weight for category { name: "category", weight: 0.4 },
{ name: "metadata.subjectName", weight: 1.5 }, // Boost subject name matches { name: "metadata.subjectName", weight: 1.6 },
{ name: "metadata.subjectCode", weight: 1.5 }, // Boost subject code matches { name: "metadata.subjectCode", weight: 1.6 },
{ name: "metadata.subject", weight: 1.4 },
{ name: "metadata.courseCode", weight: 1.2 },
{ name: "metadata.filename", weight: 1.2 },
{ name: "metadata.author", weight: 0.8 },
{ name: "metadata.authorName", weight: 0.8 },
{ name: "metadata.label", weight: 0.6 },
{ name: "metadata.categoryName", weight: 0.6 },
{ name: "metadata.entityType", weight: 0.4 },
], ],
includeScore: true, includeScore: true,
includeMatches: true, includeMatches: true,
threshold: 0.5, // More permissive for better partial word matching (increased from 0.4) threshold: 0.5,
minMatchCharLength: 2, // Minimum 2 characters for Fuse.js matches (substring fallback handles shorter queries) minMatchCharLength: 2,
distance: 100, // Increased to allow matches across longer strings distance: 100,
useExtendedSearch: true, useExtendedSearch: true,
ignoreLocation: true, // Allow matches anywhere in the string for better partial word matching ignoreLocation: true,
findAllMatches: true, // Enable to find all matches for better partial word support findAllMatches: true,
shouldSort: true, shouldSort: true,
}; };
@@ -117,7 +191,19 @@ export function searchCommands(
return searchResults.map((result: FuseResult<StaticCommandItem>) => { return searchResults.map((result: FuseResult<StaticCommandItem>) => {
const item = result.item; const item = result.item;
const fuseScore = 15 * (1 - (result.score || 0.5)); const fuseScore = 15 * (1 - (result.score || 0.5));
const score = fuseScore + (item.priority ?? 0); let score = fuseScore + (item.priority ?? 0);
// Static palette titles share the same lexical tiers as index titles, but
// Fuse scores are tiny versus hybrid dynamic scores — scale title matches
// up so "Assessments" / prefix matches stay competitive with body hits.
const titleLex = getLexicalMatchQuality(syntheticIndexFromCommand(item), query);
if (titleLex >= 12) score += 240;
else if (titleLex >= 10) score += 195;
else if (titleLex >= 9) score += 165;
else if (titleLex >= 8) score += 140;
else if (titleLex >= 7) score += 120;
else if (titleLex >= 6) score += 100;
else if (titleLex > 0) score += titleLex * 14;
return { return {
id: item.id, id: item.id,
@@ -197,15 +283,24 @@ export function searchDynamicItems(
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0; const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
score += recencyBoost; score += recencyBoost;
// Boost for exact text matches (especially at the start) // Lexical title bonus — sticky across adjacent keystrokes so a strong
const textLower = item.text.toLowerCase(); // title prefix match like `world wa` doesn't disappear from the top once
if (textLower.startsWith(queryLower)) { // vector reranking kicks in.
score += 5; // Strong boost for prefix matches const lexicalQuality = getLexicalMatchQuality(item, queryLower);
} else if (textLower.includes(queryLower)) { if (lexicalQuality > 0) {
score += 2; // Boost for substring matches score += lexicalQuality;
// Curated-content boost: assessments and assignments with a strong
// title match should be elevated further, since they are the items
// users are most often hunting for.
if (
lexicalQuality >= STRONG_LEXICAL_THRESHOLD &&
(item.category === "assignments" || item.category === "assessments")
) {
score += 4;
}
} }
// Boost for category matches // Category match (small nudge)
if (item.category.toLowerCase().includes(queryLower)) { if (item.category.toLowerCase().includes(queryLower)) {
score += 1; score += 1;
} }
@@ -221,17 +316,12 @@ export function searchDynamicItems(
// Add additional matches from simple substring search // Add additional matches from simple substring search
additionalMatches.forEach((item) => { additionalMatches.forEach((item) => {
// Check if already in results
if (!results.find(r => r.id === item.id)) { if (!results.find(r => r.id === item.id)) {
const textLower = item.text.toLowerCase();
let score = 5; // Base score for substring matches let score = 5; // Base score for substring matches
// Boost for prefix matches const lexicalQuality = getLexicalMatchQuality(item, queryLower);
if (textLower.startsWith(queryLower)) { score += lexicalQuality;
score += 5;
}
// Recency boost
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24); const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0; const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
score += recencyBoost; score += recencyBoost;
@@ -241,6 +331,7 @@ export function searchDynamicItems(
type: "dynamic" as const, type: "dynamic" as const,
score, score,
item, item,
matches: undefined,
}); });
} }
}); });
@@ -249,6 +340,7 @@ export function searchDynamicItems(
return results.sort((a, b) => b.score - a.score).slice(0, limit); return results.sort((a, b) => b.score - a.score).slice(0, limit);
} }
export async function performSearch( export async function performSearch(
query: string, query: string,
commandsFuse: Fuse<StaticCommandItem>, commandsFuse: Fuse<StaticCommandItem>,
@@ -286,12 +378,37 @@ export async function performSearch(
sortByRecent, sortByRecent,
); );
// Step 2b: Always include strong lexical title matches, even if Fuse
// missed them with the current threshold. This is the safety net that
// stops `world wa` from dropping a `World War 2 Essay` assessment that
// `world w` happily showed.
const allItems = Array.from(dynamicIdToItemMap.values());
const seen = new Set(bm25Results.map((r) => r.id));
const lexicalAdds: CombinedResult[] = [];
for (const item of allItems) {
if (seen.has(item.id)) continue;
if (!isStrongLexicalMatch(item, trimmedQuery)) continue;
const quality = getLexicalMatchQuality(item, trimmedQuery);
let score = 6 + quality;
if (item.category === "assignments" || item.category === "assessments") {
score += 4;
}
lexicalAdds.push({
id: item.id,
type: "dynamic" as const,
score,
item,
matches: undefined,
});
}
if (lexicalAdds.length > 0) {
bm25Results.push(...lexicalAdds);
bm25Results.sort((a, b) => b.score - a.score);
}
// Step 3: Apply hybrid search (BM25 + Vector reranking + boosting) // Step 3: Apply hybrid search (BM25 + Vector reranking + boosting)
if (trimmedQuery.length > 2 && bm25Results.length > 0) { if (trimmedQuery.length > 2 && bm25Results.length > 0) {
try { try {
// Get all items for expansion
const allItems = Array.from(dynamicIdToItemMap.values());
// Apply hybrid search with expansion // Apply hybrid search with expansion
dynamicResults = await hybridSearchWithExpansion( dynamicResults = await hybridSearchWithExpansion(
bm25Results, bm25Results,
@@ -320,22 +437,19 @@ export async function performSearch(
// Step 4: Combine command and dynamic results // Step 4: Combine command and dynamic results
const allResults = [...commandResults, ...dynamicResults]; const allResults = [...commandResults, ...dynamicResults];
// Sort by score (commands typically have higher priority) allResults.sort((a, b) =>
allResults.sort((a, b) => { compareCombinedSearchResults(a, b, trimmedQuery),
// Commands always come first if scores are similar );
if (a.type === "command" && b.type === "dynamic") {
return b.score - a.score - 10; // Commands get +10 boost const dedupedResults = dedupeCombinedResultsByCourseNav(allResults);
} dedupedResults.sort((a, b) =>
if (a.type === "dynamic" && b.type === "command") { compareCombinedSearchResults(a, b, trimmedQuery),
return b.score - a.score + 10; // Commands get +10 boost );
}
return b.score - a.score;
});
// Cache results for queries longer than 2 chars // Cache results for queries longer than 2 chars
if (trimmedQuery.length > 2) { if (trimmedQuery.length > 2) {
setCachedResults(trimmedQuery, allResults); setCachedResults(trimmedQuery, dedupedResults);
} }
return allResults; return dedupedResults;
} }
@@ -40,7 +40,6 @@ export interface VectorSearchResult extends SearchResult {
// Cache for query embeddings to avoid recomputing // Cache for query embeddings to avoid recomputing
const embeddingCache = new Map<string, number[]>(); const embeddingCache = new Map<string, number[]>();
const EMBEDDING_CACHE_TTL = 1000 * 60 * 30; // 30 minutes
const MAX_EMBEDDING_CACHE_SIZE = 50; const MAX_EMBEDDING_CACHE_SIZE = 50;
function getCachedEmbedding(query: string): number[] | null { function getCachedEmbedding(query: string): number[] | null {
@@ -55,8 +54,10 @@ function setCachedEmbedding(query: string, embedding: number[]) {
// Limit cache size // Limit cache size
if (embeddingCache.size >= MAX_EMBEDDING_CACHE_SIZE) { if (embeddingCache.size >= MAX_EMBEDDING_CACHE_SIZE) {
const firstKey = embeddingCache.keys().next().value; const firstKey = embeddingCache.keys().next().value;
if (firstKey !== undefined) {
embeddingCache.delete(firstKey); embeddingCache.delete(firstKey);
} }
}
embeddingCache.set(query, embedding); embeddingCache.set(query, embedding);
} }
@@ -1,4 +1,5 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { resetSearchIndexes } from "../indexing/resetIndexes";
const VERSION_STORAGE_KEY = "betterseqta-global-search-version"; const VERSION_STORAGE_KEY = "betterseqta-global-search-version";
const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version"; const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version";
@@ -40,32 +41,51 @@ export function storeVersion(version: string): void {
} }
/** /**
* Checks if the extension has been updated and clears caches if needed * Checks if the extension has been updated and clears caches + resets the
* Returns true if an update was detected * search index if needed.
*
* The reset is intentionally aggressive: every manifest version bump
* triggers a full IndexedDB wipe so changes to indexer extraction logic,
* job sets, or item shape can never serve stale results from an older
* build. The next indexing pass will repopulate from scratch in the
* background. Re-population is bounded by the per-job rate limits in
* `api.ts` so it can't hammer SEQTA after an update.
*
* Returns true if an update was detected.
*/ */
export async function checkAndHandleUpdate(): Promise<boolean> { export async function checkAndHandleUpdate(): Promise<boolean> {
const currentVersion = getCurrentVersion(); const currentVersion = getCurrentVersion();
const storedVersion = getStoredVersion(); const storedVersion = getStoredVersion();
// If no stored version, this is first run - store current version // First run: just remember the version, don't reset (the user likely
// just installed the extension; the index is already empty).
if (!storedVersion) { if (!storedVersion) {
console.debug(`[Version Check] First run detected, storing version ${currentVersion}`); console.debug(
`[Version Check] First run detected, storing version ${currentVersion}`,
);
storeVersion(currentVersion); storeVersion(currentVersion);
return false; return false;
} }
// If versions match, no update
if (storedVersion === currentVersion) { if (storedVersion === currentVersion) {
return false; return false;
} }
// Version mismatch detected - extension was updated console.log(
console.log(`[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, clearing caches...`); `[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, resetting search index...`,
);
// Clear all caches
await clearAllCaches(); await clearAllCaches();
// Store new version try {
await resetSearchIndexes();
console.log(
"[Version Check] Search index reset; next indexing pass will repopulate from scratch.",
);
} catch (e) {
console.warn("[Version Check] resetSearchIndexes failed:", e);
}
storeVersion(currentVersion); storeVersion(currentVersion);
return true; return true;
@@ -0,0 +1,388 @@
<script lang="ts">
import * as Chart from "./chart/index";
import { scaleUtc, scaleLinear } from "d3-scale";
import { Area, AreaChart, ChartClipPath } from "layerchart";
import { curveNatural } from "d3-shape";
import { cubicInOut } from "svelte/easing";
import type { Assessment } from "./types";
import {
buildGradeTrendChart,
getTimeRangeLabel,
type TimeRange,
} from "./timeRange";
interface Props {
data: Assessment[];
timeRange: TimeRange;
showSubjectTrends?: boolean;
}
let { data, timeRange, showSubjectTrends = false }: Props = $props();
const chartUid = `area-${Math.random().toString(36).slice(2, 9)}`;
const chartResult = $derived(() =>
buildGradeTrendChart(data, timeRange, {
showPerSubject: showSubjectTrends,
}),
);
const filteredData = $derived(() => chartResult().points);
const chartSeries = $derived(() => chartResult().series);
const accentColor = $derived(() => chartResult().accentColor);
const chartConfig = $derived(() => {
const config: Chart.ChartConfig = {};
for (const s of chartSeries()) {
config[s.key] = { label: s.label, color: s.color };
}
return config;
});
const yScale = $derived.by(() => {
const points = filteredData();
const series = chartSeries();
if (!points.length) return scaleLinear().domain([0, 100]);
const values: number[] = [];
for (const p of points) {
for (const s of series) {
const v = p[s.key];
if (typeof v === "number" && !Number.isNaN(v)) values.push(v);
}
}
if (!values.length) return scaleLinear().domain([0, 100]);
const min = Math.max(0, Math.min(...values) - 8);
const max = Math.min(100, Math.max(...values) + 8);
return scaleLinear().domain([min, max]).nice();
});
const trend = $derived(() => {
const points = filteredData();
if (points.length < 2) return { percentage: "0", direction: "neutral" as const };
const recent = points.slice(-2);
const change = recent[1].average - recent[0].average;
return {
percentage: Math.abs(change).toFixed(1),
direction: change > 0 ? ("up" as const) : change < 0 ? ("down" as const) : ("neutral" as const),
};
});
const areaSeries = $derived(() =>
chartSeries().map((s) => ({
key: s.key,
label: s.label,
color: s.color,
})),
);
</script>
<article class="bsplus-analytics-card">
<header class="bsplus-analytics-card-header">
<div>
<h3 class="bsplus-analytics-card-title">Grade trends</h3>
<p class="bsplus-analytics-card-desc">
{#if showSubjectTrends}
Overall and per-subject averages · {getTimeRangeLabel(timeRange)}
{:else}
Average grades over time · {getTimeRangeLabel(timeRange)}
{/if}
</p>
</div>
</header>
<div class="bsplus-analytics-card-body">
{#if filteredData().length > 0}
<Chart.Container config={chartConfig()} class="bsplus-chart-surface w-full">
<AreaChart
legend
data={filteredData()}
x="date"
xScale={scaleUtc()}
yScale={yScale()}
series={areaSeries()}
props={{
area: {
curve: curveNatural,
"fill-opacity": showSubjectTrends ? 0.12 : 0.35,
line: { class: "stroke-2" },
motion: "tween",
},
xAxis: {
ticks: timeRange === "7d" ? 7 : undefined,
format: (v: Date) =>
v.toLocaleDateString("en-US", {
month: "short",
day: timeRange === "7d" ? "numeric" : undefined,
}),
},
yAxis: {
format: (v: number) => `${v.toFixed(0)}%`,
},
}}
>
{#snippet marks({ series, getAreaProps })}
<defs>
<linearGradient id={chartUid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color={accentColor()} stop-opacity="0.55" />
<stop offset="100%" stop-color={accentColor()} stop-opacity="0.04" />
</linearGradient>
</defs>
<ChartClipPath
initialWidth={0}
motion={{
width: { type: "tween", duration: 900, easing: cubicInOut },
}}
>
{#each series as s, i (s.key)}
{@const meta = chartSeries().find((c) => c.key === s.key)}
{@const isOverall = meta?.isOverall ?? s.key === "average"}
<Area
{...getAreaProps(s, i)}
fill={isOverall && !showSubjectTrends
? `url(#${chartUid})`
: isOverall
? accentColor()
: "transparent"}
fill-opacity={isOverall ? (showSubjectTrends ? 0.08 : 0.35) : 0}
stroke={meta?.color ?? s.color}
style={`stroke: ${meta?.color ?? s.color}`}
/>
{/each}
</ChartClipPath>
{/snippet}
{#snippet tooltip()}
<Chart.Tooltip
labelFormatter={(v: Date) =>
v.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
indicator="line"
/>
{/snippet}
</AreaChart>
</Chart.Container>
{:else}
<div class="bsplus-analytics-card-empty">
<strong>No grade data for this range</strong>
<span>Complete assessments with released marks to see trends.</span>
</div>
{/if}
</div>
<footer class="bsplus-analytics-card-footer">
{#if trend().direction === "up"}
<span class="bsplus-analytics-trend-up"
>Trending up · {trend().percentage}% vs previous period</span
>
{:else if trend().direction === "down"}
<span class="bsplus-analytics-trend-down"
>Trending down · {trend().percentage}% vs previous period</span
>
{:else}
<span>Grades remain stable across this period</span>
{/if}
<br />
<span>
{filteredData().length} data points · {getTimeRangeLabel(timeRange)}
{#if showSubjectTrends && chartSeries().length > 1}
· {chartSeries().length - 1} subject{chartSeries().length - 1 === 1 ? "" : "s"}
{/if}
</span>
</footer>
</article>
@@ -0,0 +1,408 @@
<script lang="ts">
import { onMount } from "svelte";
import { scaleBand, scaleLinear } from "d3-scale";
import { BarChart } from "layerchart";
import * as Chart from "./chart/index";
import { cubicInOut } from "svelte/easing";
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
import type { Assessment } from "./types";
import { getTimeRangeLabel, type TimeRange } from "./timeRange";
import {
buildGradeDistribution,
DISTRIBUTION_MODE_OPTIONS,
type DistributionMode,
} from "./gradeDistribution";
import { loadDistributionMode, saveDistributionMode } from "./storage";
interface Props {
data: Assessment[];
timeRange: TimeRange;
}
let { data, timeRange }: Props = $props();
let distributionMode: DistributionMode = $state("auto");
let modeReady = $state(false);
let studentId: number | null = $state(null);
const accentColor =
"var(--bsplus-analytics-accent, var(--better-main, #007bff))";
const distribution = $derived(() =>
buildGradeDistribution(data, distributionMode),
);
const chartData = $derived(() =>
distribution().buckets.map((b) => ({
grade: b.label,
count: b.count,
minPercent: b.minPercent,
maxPercent: b.maxPercent,
})),
);
const useLetterScaleLabels = $derived(() => distribution().modeUsed === "letter");
function formatXTick(label: string): string {
if (!useLetterScaleLabels()) return label;
const row = chartData().find((d) => d.grade === label);
if (
row?.minPercent !== undefined &&
row?.maxPercent !== undefined &&
!(row.minPercent === 0 && row.maxPercent === 100)
) {
return `${label}\n${Math.round(row.minPercent)}${Math.round(row.maxPercent)}%`;
}
return label;
}
const chartConfig = $derived(() => {
const config: Chart.ChartConfig = {
count: { label: "Assessments", color: accentColor },
};
return config;
});
const yMax = $derived(Math.max(1, ...chartData().map((d) => d.count)));
const yScale = $derived(scaleLinear().domain([0, yMax]).nice());
const totalAssessments = $derived(distribution().gradedCount);
const modeOptionLabel = $derived(
DISTRIBUTION_MODE_OPTIONS.find((o) => o.value === distributionMode)?.label ??
"Auto",
);
const subtitle = $derived(() => {
const d = distribution();
if (d.modeUsed === "letter") {
return `Assessments per letter grade · ${getTimeRangeLabel(timeRange)}`;
}
return `Assessments per grade band · ${getTimeRangeLabel(timeRange)}`;
});
onMount(async () => {
try {
const info = await getUserInfo();
if (info?.id) {
studentId = info.id;
const saved = await loadDistributionMode(location.origin, info.id);
if (saved) distributionMode = saved;
}
} catch {
/* use default */
} finally {
modeReady = true;
}
});
async function onModeChange(next: DistributionMode) {
distributionMode = next;
if (studentId != null) {
await saveDistributionMode(location.origin, studentId, next);
}
}
</script>
<article class="bsplus-analytics-card">
<header class="bsplus-analytics-card-header bsplus-analytics-card-header-split">
<div>
<h3 class="bsplus-analytics-card-title">Grade distribution</h3>
<p class="bsplus-analytics-card-desc">{subtitle()}</p>
</div>
<div class="bsplus-analytics-card-controls">
<label class="bsplus-analytics-card-control">
<span class="bsplus-analytics-field-label">Grouping</span>
<select
class="bsplus-analytics-select bsplus-analytics-select-compact"
value={distributionMode}
disabled={!modeReady}
aria-label="Grade distribution grouping"
onchange={(e) => onModeChange(e.currentTarget.value as DistributionMode)}
>
{#each DISTRIBUTION_MODE_OPTIONS as option}
<option value={option.value} title={option.description}>{option.label}</option>
{/each}
</select>
</label>
</div>
</header>
<div class="bsplus-analytics-card-body">
{#if totalAssessments > 0 && chartData().length > 0}
<Chart.Container config={chartConfig()} class="bsplus-chart-surface bsplus-chart-surface-bar w-full">
<BarChart
data={chartData()}
xScale={scaleBand().padding(distribution().modeUsed === "letter" ? 0.22 : 0.28)}
yScale={yScale()}
x="grade"
y="count"
axis={true}
grid={true}
series={[
{
key: "count",
label: "Assessments",
color: accentColor,
},
]}
props={{
bars: {
stroke: "none",
fill: accentColor,
rounded: "all",
radius: 10,
insets: { top: 4, bottom: 0, left: 4, right: 4 },
motion: {
y: { type: "tween", duration: 600, easing: cubicInOut },
height: { type: "tween", duration: 600, easing: cubicInOut },
},
},
highlight: { area: { fill: "none" } },
xAxis: {
format: (d: string) => formatXTick(d),
tickMultiline: useLetterScaleLabels(),
tickLabelProps: useLetterScaleLabels()
? { class: "bsplus-bar-tick-label" }
: undefined,
},
yAxis: {
label: "Assessments",
format: (d: number) => (Number.isInteger(d) ? String(d) : ""),
ticks: 5,
},
}}
>
{#snippet tooltip()}
<Chart.Tooltip hideLabel />
{/snippet}
</BarChart>
</Chart.Container>
{#if distribution().modeUsed === "letter"}
<p class="bsplus-analytics-scale-hint">{distribution().scaleLabel}</p>
{/if}
{:else}
<div class="bsplus-analytics-card-empty">
<strong>No graded assessments</strong>
<span>for {getTimeRangeLabel(timeRange).toLowerCase()}</span>
</div>
{/if}
</div>
<footer class="bsplus-analytics-card-footer">
{#if distribution().averagePercent !== null}
Average <strong>{distribution().averagePercent}%</strong>
{:else}
Average <strong></strong>
{/if}
across {totalAssessments} assessment{totalAssessments === 1 ? "" : "s"}
{#if distributionMode === "auto" && distribution().modeUsed === "letter"}
<span class="bsplus-analytics-footer-muted"> · letter scale detected</span>
{:else if distributionMode !== "auto"}
<span class="bsplus-analytics-footer-muted"> · {modeOptionLabel} grouping</span>
{/if}
</footer>
</article>
@@ -0,0 +1,152 @@
<script lang="ts">
import type { Assessment } from "./types";
interface Props {
data: Assessment[];
}
let { data }: Props = $props();
let currentPage = $state(0);
let itemsPerPage = $state(10);
let sortColumn = $state<keyof Assessment | null>("due");
let sortDirection = $state<"asc" | "desc">("desc");
const sortedData = $derived.by(() => {
const list = [...data];
if (!sortColumn) return list;
list.sort((a, b) => {
const av = a[sortColumn!];
const bv = b[sortColumn!];
if (av === bv) return 0;
if (av == null) return 1;
if (bv == null) return -1;
const cmp = av < bv ? -1 : 1;
return sortDirection === "asc" ? cmp : -cmp;
});
return list;
});
const pageCount = $derived(Math.max(1, Math.ceil(sortedData.length / itemsPerPage)));
const pageData = $derived(
sortedData.slice(
currentPage * itemsPerPage,
(currentPage + 1) * itemsPerPage,
),
);
function toggleSort(column: keyof Assessment) {
if (sortColumn === column) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
} else {
sortColumn = column;
sortDirection = "asc";
}
currentPage = 0;
}
function formatStatus(status: string) {
return status.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
}
function gradeDisplay(a: Assessment) {
if (a.finalGrade !== undefined) {
return a.letterGrade
? `${a.finalGrade}% (${a.letterGrade})`
: `${a.finalGrade}%`;
}
return a.letterGrade ?? "—";
}
</script>
<section class="bsplus-analytics-table-wrap">
<header class="bsplus-analytics-table-header">
<h2>Assessment history</h2>
</header>
<div class="bsplus-analytics-table-scroll">
<table class="bsplus-analytics-table">
<thead>
<tr>
{#each [
["title", "Title"],
["subject", "Subject"],
["due", "Due"],
["status", "Status"],
["finalGrade", "Grade"],
] as [col, label]}
<th>
<button type="button" onclick={() => toggleSort(col as keyof Assessment)}>
{label}
{#if sortColumn === col}
{sortDirection === "asc" ? " ↑" : " ↓"}
{/if}
</button>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each pageData as row (row.id)}
<tr>
<td class="cell-title" title={row.title}>{row.title}</td>
<td>{row.subject}</td>
<td style="white-space: nowrap">
{new Date(row.due).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
})}
</td>
<td>{formatStatus(row.status)}</td>
<td>
{#if row.finalGrade !== undefined}
<span class="bsplus-analytics-grade-pill">{gradeDisplay(row)}</span>
{:else}
{gradeDisplay(row)}
{/if}
</td>
</tr>
{:else}
<tr>
<td colspan="5" style="text-align: center; padding: 2rem; color: var(--bsplus-analytics-muted)">
No assessments match your filters
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<footer class="bsplus-analytics-table-footer">
<label>
Rows per page
<select bind:value={itemsPerPage} onchange={() => (currentPage = 0)}>
{#each [5, 10, 20, 50] as n}
<option value={n}>{n}</option>
{/each}
</select>
</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<button
type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-ghost"
style="padding: 0.4rem 0.85rem; font-size: 0.8125rem;"
disabled={currentPage === 0}
onclick={() => currentPage--}
>
Previous
</button>
<span>Page {currentPage + 1} of {pageCount}</span>
<button
type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-ghost"
style="padding: 0.4rem 0.85rem; font-size: 0.8125rem;"
disabled={currentPage >= pageCount - 1}
onclick={() => currentPage++}
>
Next
</button>
</div>
</footer>
</section>
@@ -0,0 +1,441 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { fade } from "svelte/transition";
import type { Assessment } from "./types";
import {
loadGradeAnalytics,
syncGradeAnalytics,
getCacheTtlMs,
} from "./api";
import AnalyticsAreaChart from "./AnalyticsAreaChart.svelte";
import AnalyticsBarChart from "./AnalyticsBarChart.svelte";
import AssessmentTable from "./AssessmentTable.svelte";
import GradeRangeSlider from "./GradeRangeSlider.svelte";
import {
filterAssessmentsByTimeRange,
getTimeRangeLabel,
TIME_RANGE_OPTIONS,
type TimeRange,
} from "./timeRange";
import { openAnalyticsPrivacyPopup } from "./openAnalyticsPrivacyPopup";
let analyticsData: Assessment[] | null = $state(null);
let loading = $state(true);
let syncing = $state(false);
let lastUpdated: Date | null = $state(null);
let timestampRefresh = $state(0);
let error: string | null = $state(null);
let filterSubjects: string[] = $state([]);
let filterSearch = $state("");
let gradeRange = $state([0, 100]);
let showSubjectsDropdown = $state(false);
let showTimeRangeDropdown = $state(false);
let timeRange: TimeRange = $state("all");
let showSubjectTrends = $state(false);
let timestampInterval: ReturnType<typeof setInterval> | null = null;
const formattedTimestamp = $derived(() => {
if (!lastUpdated) return "";
timestampRefresh;
return formatLastUpdated(lastUpdated);
});
const uniqueSubjects = $derived(() => {
if (!analyticsData) return [];
return [...new Set(analyticsData.map((a) => a.subject))].sort();
});
const filteredData = $derived(() => {
if (!analyticsData) return [];
const [minG, maxG] = gradeRange;
return analyticsData.filter((a) => {
if (filterSubjects.length && !filterSubjects.includes(a.subject)) return false;
const grade = a.finalGrade ?? -1;
if (grade < minG || grade > maxG) return false;
if (
filterSearch &&
!a.title.toLowerCase().includes(filterSearch.toLowerCase()) &&
!a.subject.toLowerCase().includes(filterSearch.toLowerCase())
) {
return false;
}
return true;
});
});
const timeScopedData = $derived(() =>
filterAssessmentsByTimeRange(filteredData(), timeRange),
);
const gradedFiltered = $derived(() =>
timeScopedData().filter((a) => a.finalGrade !== undefined),
);
const statsAverage = $derived.by(() => {
const graded = gradedFiltered();
if (!graded.length) return null;
const sum = graded.reduce((acc, a) => acc + (a.finalGrade ?? 0), 0);
return Math.round((sum / graded.length) * 10) / 10;
});
const statsSubjectCount = $derived(
new Set(timeScopedData().map((a) => a.subject)).size,
);
function formatLastUpdated(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? "" : "s"} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
return date.toLocaleString();
}
async function runSync() {
syncing = true;
error = null;
try {
const result = await syncGradeAnalytics();
analyticsData = result.assessments;
lastUpdated = new Date(result.updatedAt);
} catch (e) {
console.error("[BetterSEQTA+] Analytics sync failed:", e);
error =
"Failed to sync analytics data. Showing cached data if available.";
} finally {
syncing = false;
}
}
function clearFilters() {
filterSubjects = [];
filterSearch = "";
gradeRange = [0, 100];
}
function hasActiveFilters() {
return !!(
filterSubjects.length ||
filterSearch ||
gradeRange[0] !== 0 ||
gradeRange[1] !== 100
);
}
function toggleSubject(subject: string) {
if (filterSubjects.includes(subject)) {
filterSubjects = filterSubjects.filter((s) => s !== subject);
} else {
filterSubjects = [...filterSubjects, subject];
}
}
const timeRangeLabel = $derived(() => getTimeRangeLabel(timeRange));
function closeToolbarDropdowns() {
showSubjectsDropdown = false;
showTimeRangeDropdown = false;
}
/** Shadow DOM retargets `event.target`; use the full composed path for outside-click. */
function isInsideToolbarDropdown(event: Event): boolean {
return event.composedPath().some((node) => {
if (!(node instanceof Element)) return false;
return node.closest("[data-analytics-dropdown]") !== null;
});
}
function selectTimeRange(value: TimeRange) {
timeRange = value;
showTimeRangeDropdown = false;
}
onMount(async () => {
timestampInterval = setInterval(() => {
timestampRefresh = Date.now();
}, 60000);
try {
const result = await loadGradeAnalytics();
analyticsData = result.assessments;
lastUpdated = result.updatedAt ? new Date(result.updatedAt) : null;
} catch (e) {
console.error("[BetterSEQTA+] Failed to load analytics:", e);
analyticsData = [];
} finally {
loading = false;
}
const ttl = getCacheTtlMs(24);
const needsSync =
!lastUpdated || Date.now() - lastUpdated.getTime() > ttl;
if (needsSync) {
void runSync();
}
});
onDestroy(() => {
if (timestampInterval) clearInterval(timestampInterval);
});
</script>
<svelte:window
onclick={(e) => {
if (!isInsideToolbarDropdown(e)) {
closeToolbarDropdowns();
}
}}
/>
<div class="bsplus-analytics-root">
<header class="bsplus-analytics-header bsplus-analytics-animate">
<div class="bsplus-analytics-header-text">
<h1>
Analytics
{#if syncing}
<span class="bsplus-analytics-badge">
<span class="bsplus-analytics-badge-dot" aria-hidden="true"></span>
Syncing
</span>
{/if}
</h1>
<p>Track your academic performance and progress over time</p>
{#if lastUpdated && analyticsData && analyticsData.length > 0}
<p class="bsplus-analytics-meta">Last updated: {formattedTimestamp()}</p>
{/if}
</div>
<div class="bsplus-analytics-header-actions">
<button
type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-privacy"
onclick={() => openAnalyticsPrivacyPopup()}
>
Privacy notice
</button>
<button
type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-primary"
disabled={syncing}
onclick={() => runSync()}
>
{syncing ? "Syncing…" : "Refresh data"}
</button>
</div>
</header>
{#if error}
<p class="bsplus-analytics-alert bsplus-analytics-animate" role="alert" transition:fade={{ duration: 200 }}>
{error}
</p>
{/if}
{#if loading}
<div class="bsplus-analytics-loading bsplus-analytics-animate">
<div class="bsplus-analytics-spinner" aria-label="Loading analytics"></div>
</div>
{:else if analyticsData && analyticsData.length > 0}
<section
class="bsplus-analytics-stats bsplus-analytics-animate bsplus-analytics-delay-1"
aria-label="Summary statistics"
>
<div class="bsplus-analytics-stat">
<div class="bsplus-analytics-stat-label">Average grade</div>
<div class="bsplus-analytics-stat-value bsplus-analytics-stat-value-accent">
{statsAverage !== null ? `${statsAverage}%` : "—"}
</div>
</div>
<div class="bsplus-analytics-stat">
<div class="bsplus-analytics-stat-label">Graded shown</div>
<div class="bsplus-analytics-stat-value">{gradedFiltered().length}</div>
</div>
<div class="bsplus-analytics-stat">
<div class="bsplus-analytics-stat-label">Subjects</div>
<div class="bsplus-analytics-stat-value">{statsSubjectCount}</div>
</div>
</section>
<div class="bsplus-analytics-toolbar bsplus-analytics-animate bsplus-analytics-delay-2">
<div
class="bsplus-analytics-field bsplus-analytics-toolbar-dropdown-field"
data-analytics-dropdown
>
<span class="bsplus-analytics-field-label">Time period</span>
<div class="bsplus-analytics-dropdown" data-analytics-dropdown>
<button
type="button"
class="bsplus-analytics-dropdown-trigger"
onclick={(e) => {
e.stopPropagation();
showSubjectsDropdown = false;
showTimeRangeDropdown = !showTimeRangeDropdown;
}}
aria-expanded={showTimeRangeDropdown}
aria-haspopup="listbox"
aria-label="Time period for analytics"
>
{timeRangeLabel()}
</button>
{#if showTimeRangeDropdown}
<div class="bsplus-analytics-dropdown-menu" role="listbox">
{#each TIME_RANGE_OPTIONS as option (option.value)}
{@const selected = timeRange === option.value}
<button
type="button"
class="bsplus-analytics-dropdown-item"
class:is-selected={selected}
role="option"
aria-selected={selected}
onclick={() => selectTimeRange(option.value)}
>
<span class="bsplus-analytics-dropdown-check"
>{selected ? "✓" : ""}</span
>
<span>{option.label}</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
<div
class="bsplus-analytics-field bsplus-analytics-toolbar-dropdown-field"
data-analytics-dropdown
>
<span class="bsplus-analytics-field-label">Subjects</span>
<div class="bsplus-analytics-dropdown" data-analytics-dropdown>
<button
type="button"
class="bsplus-analytics-dropdown-trigger"
onclick={(e) => {
e.stopPropagation();
showTimeRangeDropdown = false;
showSubjectsDropdown = !showSubjectsDropdown;
}}
aria-expanded={showSubjectsDropdown}
aria-haspopup="listbox"
>
{#if filterSubjects.length === 0}
All subjects
{:else if filterSubjects.length === 1}
{filterSubjects[0]}
{:else}
{filterSubjects.length} selected
{/if}
</button>
{#if showSubjectsDropdown}
<div class="bsplus-analytics-dropdown-menu" role="listbox">
<button
type="button"
class="bsplus-analytics-dropdown-item"
class:is-selected={filterSubjects.length === 0}
onclick={() => {
filterSubjects = [];
showSubjectsDropdown = false;
}}
>
<span class="bsplus-analytics-dropdown-check"
>{filterSubjects.length === 0 ? "✓" : ""}</span
>
All subjects
</button>
{#each uniqueSubjects() as subject}
{@const selected = filterSubjects.includes(subject)}
<button
type="button"
class="bsplus-analytics-dropdown-item"
class:is-selected={selected}
onclick={() => toggleSubject(subject)}
>
<span class="bsplus-analytics-dropdown-check"
>{selected ? "✓" : ""}</span
>
<span style="overflow:hidden;text-overflow:ellipsis">{subject}</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="bsplus-analytics-field bsplus-analytics-grade-range">
<span class="bsplus-analytics-field-label">Grade range</span>
<GradeRangeSlider bind:value={gradeRange} />
</div>
<div class="bsplus-analytics-field bsplus-analytics-toolbar-search">
<span class="bsplus-analytics-field-label">Search</span>
<input
type="search"
class="bsplus-analytics-input"
bind:value={filterSearch}
placeholder="Search assessments…"
/>
</div>
<label class="bsplus-analytics-checkbox">
<input type="checkbox" bind:checked={showSubjectTrends} />
<span>Show per-subject trends on chart</span>
</label>
</div>
<div class="bsplus-analytics-charts">
{#key filteredData().length + "-" + gradeRange.join(",") + filterSearch + filterSubjects.join("|") + timeRange + String(showSubjectTrends)}
<div class="bsplus-analytics-animate bsplus-analytics-delay-3">
<AnalyticsAreaChart
data={gradedFiltered()}
{timeRange}
showSubjectTrends={showSubjectTrends}
/>
</div>
<div class="bsplus-analytics-animate bsplus-analytics-delay-4">
<AnalyticsBarChart data={gradedFiltered()} {timeRange} />
</div>
{/key}
</div>
<div class="bsplus-analytics-animate bsplus-analytics-delay-4" style="animation-delay: 400ms;">
<AssessmentTable data={timeScopedData()} />
</div>
<footer class="bsplus-analytics-footer">
<span>
{timeScopedData().length} of {analyticsData.length} assessments shown
{#if gradedFiltered().length !== timeScopedData().length}
({gradedFiltered().length} with grades)
{/if}
</span>
{#if hasActiveFilters()}
<button
type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-ghost"
onclick={clearFilters}
>
Clear filters
</button>
{/if}
</footer>
{:else}
<div class="bsplus-analytics-empty bsplus-analytics-animate" transition:fade={{ duration: 300 }}>
<h2>No analytics data yet</h2>
<p>
Data syncs when you visit this page. Assessments with released marks will
appear here with trends and grade breakdowns.
</p>
<button
type="button"
class="bsplus-analytics-btn bsplus-analytics-btn-primary"
disabled={syncing}
onclick={() => runSync()}
>
Sync now
</button>
</div>
{/if}
</div>
@@ -0,0 +1,209 @@
<script lang="ts">
let {
value = $bindable<[number, number]>([0, 100]),
min = 0,
max = 100,
step = 1,
} = $props<{
value?: [number, number];
min?: number;
max?: number;
step?: number;
}>();
let dragging: "min" | "max" | null = $state(null);
const span = $derived(max - min || 1);
const minPercent = $derived(((value[0] - min) / span) * 100);
const maxPercent = $derived(((value[1] - min) / span) * 100);
const minZ = $derived(
dragging === "min" ? 5 : dragging === "max" ? 2 : value[0] > (min + max) / 2 ? 4 : 3,
);
const maxZ = $derived(
dragging === "max" ? 5 : dragging === "min" ? 2 : value[1] <= (min + max) / 2 ? 4 : 3,
);
function onMinInput(e: Event) {
const raw = Number((e.currentTarget as HTMLInputElement).value);
if (raw > value[1]) {
value = [value[1], raw];
} else {
value = [raw, value[1]];
}
}
function onMaxInput(e: Event) {
const raw = Number((e.currentTarget as HTMLInputElement).value);
if (raw < value[0]) {
value = [raw, value[0]];
} else {
value = [value[0], raw];
}
}
</script>
<div class="bsplus-grade-range-slider">
<div class="bsplus-grade-range-slider-track-wrap">
<div class="bsplus-grade-range-slider-track" aria-hidden="true">
<div class="bsplus-grade-range-slider-rail"></div>
<div
class="bsplus-grade-range-slider-fill"
style:left="{minPercent}%"
style:width="{maxPercent - minPercent}%"
></div>
</div>
<input
type="range"
class="bsplus-grade-range-slider-input"
{min}
{max}
{step}
value={value[0]}
oninput={onMinInput}
onpointerdown={() => (dragging = "min")}
onpointerup={() => (dragging = null)}
onpointercancel={() => (dragging = null)}
onblur={() => {
if (dragging === "min") dragging = null;
}}
style:z-index={minZ}
aria-label="Minimum grade"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value[0]}
/>
<input
type="range"
class="bsplus-grade-range-slider-input"
{min}
{max}
{step}
value={value[1]}
oninput={onMaxInput}
onpointerdown={() => (dragging = "max")}
onpointerup={() => (dragging = null)}
onpointercancel={() => (dragging = null)}
onblur={() => {
if (dragging === "max") dragging = null;
}}
style:z-index={maxZ}
aria-label="Maximum grade"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value[1]}
/>
</div>
<span class="bsplus-analytics-range-value" aria-live="polite">
{value[0]}% {value[1]}%
</span>
</div>
<style>
.bsplus-grade-range-slider {
display: flex;
align-items: center;
gap: 0.65rem;
width: 100%;
min-width: 0;
}
.bsplus-grade-range-slider-track-wrap {
position: relative;
flex: 1;
height: 1.5rem;
display: flex;
align-items: center;
}
.bsplus-grade-range-slider-track {
position: absolute;
left: 0;
right: 0;
height: 0.35rem;
pointer-events: none;
}
.bsplus-grade-range-slider-rail {
position: absolute;
inset: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--bsplus-analytics-muted) 28%, transparent);
}
.bsplus-grade-range-slider-fill {
position: absolute;
top: 0;
bottom: 0;
border-radius: 999px;
background: var(--bsplus-analytics-accent);
}
.bsplus-grade-range-slider-input {
position: absolute;
left: 0;
width: 100%;
margin: 0;
height: 1.5rem;
background: transparent;
pointer-events: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.bsplus-grade-range-slider-input::-webkit-slider-runnable-track {
-webkit-appearance: none;
height: 0.35rem;
background: transparent;
border: none;
}
.bsplus-grade-range-slider-input::-moz-range-track {
height: 0.35rem;
background: transparent;
border: none;
}
.bsplus-grade-range-slider-input::-webkit-slider-thumb {
-webkit-appearance: none;
pointer-events: all;
width: 1rem;
height: 1rem;
margin-top: -0.325rem;
border-radius: 50%;
border: 2px solid var(--bsplus-analytics-accent);
background: var(--bsplus-analytics-surface, #fff);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
cursor: grab;
transition:
transform 0.12s var(--bsplus-analytics-ease, ease),
box-shadow 0.12s var(--bsplus-analytics-ease, ease);
}
.bsplus-grade-range-slider-input:active::-webkit-slider-thumb {
cursor: grabbing;
transform: scale(1.08);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.22);
}
.bsplus-grade-range-slider-input::-moz-range-thumb {
pointer-events: all;
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 2px solid var(--bsplus-analytics-accent);
background: var(--bsplus-analytics-surface, #fff);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
cursor: grab;
}
.bsplus-grade-range-slider-input:active::-moz-range-thumb {
cursor: grabbing;
transform: scale(1.08);
}
.bsplus-grade-range-slider :global(.bsplus-analytics-range-value) {
flex-shrink: 0;
}
</style>
+354
View File
@@ -0,0 +1,354 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { getMockGradeAnalyticsData } from "@/seqta/ui/dev/hideSensitiveContent";
import {
extractLetterGradeStringFromPayload,
resolveNumericGradeFromAssessmentPayload,
} from "./letterGradeScale";
import { loadAnalyticsCache, saveAnalyticsCache } from "./storage";
import type { Assessment, AssessmentStatus } from "./types";
const PAST_FETCH_CONCURRENCY = 8;
const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
interface Subject {
code: string;
programme: number;
metaclass: number;
}
async function fetchJSON(url: string, body: Record<string, unknown>) {
const res = await fetch(`${location.origin}${url}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
});
return res.json();
}
function isValidDate(dateStr: string): boolean {
const date = new Date(dateStr);
return date instanceof Date && !isNaN(date.getTime());
}
export function parseAssessment(data: unknown): Assessment | null {
try {
if (!data || typeof data !== "object") return null;
const raw = data as Record<string, unknown>;
const letterGrade = extractLetterGradeStringFromPayload(
raw as Parameters<typeof extractLetterGradeStringFromPayload>[0],
);
let finalGrade = resolveNumericGradeFromAssessmentPayload(
raw as Parameters<typeof resolveNumericGradeFromAssessmentPayload>[0],
);
if (
finalGrade !== undefined &&
(typeof finalGrade !== "number" || isNaN(finalGrade))
) {
finalGrade = undefined;
}
const assessment: Assessment = {
id: Number(raw.id),
title: String(raw.title || ""),
subject: String(raw.subject || raw.code || ""),
status: String(raw.status || "PENDING") as AssessmentStatus,
due: String(raw.due || raw.date || raw.dueDate || ""),
code: String(raw.code || raw.subject || ""),
metaclassID: Number(raw.metaclassID ?? raw.metaclass ?? 0),
programmeID: Number(raw.programmeID ?? raw.programme ?? 0),
graded: Boolean(raw.graded),
overdue: Boolean(raw.overdue),
hasFeedback: Boolean(raw.hasFeedback),
reflectionsEnabled: Boolean(raw.reflectionsEnabled),
reflectionsCompleted: Boolean(raw.reflectionsCompleted),
expectationsEnabled: Boolean(raw.expectationsEnabled),
expectationsCompleted: Boolean(raw.expectationsCompleted),
availability: String(raw.availability || ""),
finalGrade,
letterGrade,
};
if (
!assessment.id ||
!assessment.title ||
!assessment.subject ||
!isValidDate(assessment.due)
) {
return null;
}
return assessment;
} catch {
return null;
}
}
function jsonGradeToString(grade: unknown): string | undefined {
if (typeof grade === "string") return grade.trim() || undefined;
if (typeof grade === "number") return String(grade);
return undefined;
}
function extractFinalGrade(assessment: Record<string, unknown>): number | undefined {
if (assessment.status !== "MARKS_RELEASED") return undefined;
const criteria = assessment.criteria as
| { results?: { percentage?: unknown } }[]
| undefined;
if (criteria?.[0]?.results?.percentage !== undefined) {
const n = Number(criteria[0].results!.percentage);
if (!isNaN(n)) return n;
}
const results = assessment.results as { percentage?: unknown } | undefined;
if (results?.percentage !== undefined) {
const n = Number(results.percentage);
if (!isNaN(n)) return n;
}
if (assessment.finalGrade !== undefined && assessment.finalGrade !== null) {
const n = Number(assessment.finalGrade);
if (!isNaN(n)) return n;
}
const letter = extractLetterGradeStringFromPayload(
assessment as Parameters<typeof extractLetterGradeStringFromPayload>[0],
);
if (letter) {
const approx = resolveNumericGradeFromAssessmentPayload({
status: "MARKS_RELEASED",
letterGrade: letter,
});
if (approx !== undefined) return approx;
}
return undefined;
}
function extractLetterGrade(
assessment: Record<string, unknown>,
): string | undefined {
if (assessment.status !== "MARKS_RELEASED") return undefined;
const criteria = assessment.criteria as
| { results?: { grade?: unknown } }[]
| undefined;
const c0 = criteria?.[0]?.results?.grade;
const fromCriteria = jsonGradeToString(c0);
if (fromCriteria) return fromCriteria;
const results = assessment.results as { grade?: unknown } | undefined;
const fromResults = jsonGradeToString(results?.grade);
if (fromResults) return fromResults;
return extractLetterGradeStringFromPayload(
assessment as Parameters<typeof extractLetterGradeStringFromPayload>[0],
);
}
/** All programme years / folders from SEQTA (active and inactive), matching DesQTA analytics. */
function flattenSubjectFolders(payload: unknown): Subject[] {
if (!Array.isArray(payload)) return [];
const subjects: Subject[] = [];
for (const folder of payload) {
if (!folder || typeof folder !== "object") continue;
const list = (folder as { subjects?: Subject[] }).subjects;
if (!Array.isArray(list)) continue;
for (const raw of list) {
if (!raw || typeof raw !== "object") continue;
const programme = Number(
(raw as Subject).programme ?? (raw as { programmeID?: number }).programmeID,
);
const metaclass = Number(
(raw as Subject).metaclass ?? (raw as { metaclassID?: number }).metaclassID,
);
if (!programme || !metaclass || isNaN(programme) || isNaN(metaclass)) continue;
subjects.push({
code: String((raw as Subject).code ?? (raw as { subject?: string }).subject ?? ""),
programme,
metaclass,
});
}
}
return subjects;
}
/** Subjects implied by cached assessments (covers metaclasses no longer listed). */
function subjectsFromAssessments(assessments: Assessment[]): Subject[] {
const map = new Map<string, Subject>();
for (const a of assessments) {
if (!a.programmeID || !a.metaclassID) continue;
const key = `${a.programmeID}-${a.metaclassID}`;
if (!map.has(key)) {
map.set(key, {
code: a.code || a.subject,
programme: a.programmeID,
metaclass: a.metaclassID,
});
}
}
return Array.from(map.values());
}
function dedupeSubjects(subjects: Subject[]): Subject[] {
const map = new Map<string, Subject>();
for (const s of subjects) {
map.set(`${s.programme}-${s.metaclass}`, s);
}
return Array.from(map.values());
}
async function loadAllSubjects(existingAssessments: Assessment[] = []): Promise<Subject[]> {
const res = await fetchJSON("/seqta/student/load/subjects?", {});
const fromFolders = flattenSubjectFolders(res.payload);
return dedupeSubjects([...fromFolders, ...subjectsFromAssessments(existingAssessments)]);
}
async function loadUpcoming(studentId: number): Promise<Record<string, unknown>[]> {
const res = await fetchJSON("/seqta/student/assessment/list/upcoming?", {
student: studentId,
});
return Array.isArray(res.payload) ? res.payload : [];
}
async function loadPastForSubject(
studentId: number,
subject: Subject,
): Promise<Record<string, unknown>[]> {
const res = await fetchJSON("/seqta/student/assessment/list/past?", {
programme: subject.programme,
metaclass: subject.metaclass,
student: studentId,
});
const items: Record<string, unknown>[] = [];
const process = (assessment: unknown) => {
if (!assessment || typeof assessment !== "object") return;
const a = assessment as Record<string, unknown>;
if (!a.id) return;
items.push({
...a,
programmeID: a.programmeID ?? a.programme ?? subject.programme,
metaclassID: a.metaclassID ?? a.metaclass ?? subject.metaclass,
code: a.code ?? a.subject ?? subject.code,
});
};
if (Array.isArray(res.payload?.pending)) {
res.payload.pending.forEach(process);
}
if (Array.isArray(res.payload?.tasks)) {
res.payload.tasks.forEach(process);
}
return items;
}
async function loadAllPast(
studentId: number,
subjects: Subject[],
): Promise<Record<string, unknown>[]> {
const results: Record<string, unknown>[][] = [];
for (let i = 0; i < subjects.length; i += PAST_FETCH_CONCURRENCY) {
const batch = subjects.slice(i, i + PAST_FETCH_CONCURRENCY);
const batchResults = await Promise.all(
batch.map((s) => loadPastForSubject(studentId, s)),
);
results.push(...batchResults);
}
return results.flat();
}
function mergeRawAssessments(
existing: Assessment[],
rawItems: Record<string, unknown>[],
): Assessment[] {
const existingMap = new Map<number, Assessment>();
for (const a of existing) {
existingMap.set(a.id, a);
}
for (const raw of rawItems) {
const id = Number(raw.id);
if (!id) continue;
const finalGrade = extractFinalGrade(raw);
const letterGrade = extractLetterGrade(raw);
if (finalGrade !== undefined) raw.finalGrade = finalGrade;
if (letterGrade !== undefined) raw.letterGrade = letterGrade;
const existingItem = existingMap.get(id);
if (existingItem?.finalGrade !== undefined && finalGrade === undefined) {
continue;
}
const parsed = parseAssessment(raw);
if (parsed) existingMap.set(id, parsed);
}
return Array.from(existingMap.values()).sort(
(a, b) => new Date(b.due).getTime() - new Date(a.due).getTime(),
);
}
export async function getStudentId(): Promise<number> {
const info = await getUserInfo();
const id = Number(info?.id);
if (!id || isNaN(id)) throw new Error("Could not resolve student ID");
return id;
}
export function getCacheTtlMs(cacheTtlHours = 24): number {
return cacheTtlHours * 60 * 60 * 1000;
}
export async function loadGradeAnalytics(
cacheTtlMs = getCacheTtlMs(),
): Promise<{ assessments: Assessment[]; updatedAt: number | null; fromCache: boolean }> {
if (settingsState.hideSensitiveContent) {
const mock = getMockGradeAnalyticsData();
return { assessments: mock, updatedAt: Date.now(), fromCache: false };
}
const studentId = await getStudentId();
const cached = await loadAnalyticsCache(location.origin, studentId);
if (cached) {
const stale = Date.now() - cached.updatedAt > cacheTtlMs;
return {
assessments: cached.assessments,
updatedAt: cached.updatedAt,
fromCache: !stale,
};
}
return { assessments: [], updatedAt: null, fromCache: false };
}
export async function syncGradeAnalytics(): Promise<{
assessments: Assessment[];
updatedAt: number;
}> {
if (settingsState.hideSensitiveContent) {
const mock = getMockGradeAnalyticsData();
return { assessments: mock, updatedAt: Date.now() };
}
const studentId = await getStudentId();
const cached = await loadAnalyticsCache(location.origin, studentId);
const existing = cached?.assessments ?? [];
const subjectList = await loadAllSubjects(existing);
const [upcoming, past] = await Promise.all([
loadUpcoming(studentId),
loadAllPast(studentId, subjectList),
]);
const merged = mergeRawAssessments(existing, [...upcoming, ...past]);
await saveAnalyticsCache(location.origin, studentId, merged);
return { assessments: merged, updatedAt: Date.now() };
}
@@ -0,0 +1,39 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import ChartStyle from "./chart-style.svelte";
import { setChartContext, type ChartConfig } from "./chart-utils";
const uid = $props.id();
let {
ref = $bindable(null),
id = uid,
class: className = "",
children,
config,
...restProps
}: HTMLAttributes<HTMLElement> & {
ref?: HTMLElement | null;
config: ChartConfig;
class?: string;
} = $props();
const chartId = $derived(`chart-${id || uid.replace(/:/g, "")}`);
setChartContext({
get config() {
return config;
},
});
</script>
<div
bind:this={ref}
data-chart={chartId}
data-slot="chart"
class="bsplus-chart-host {className}"
{...restProps}
>
<ChartStyle id={chartId} {config} />
{@render children?.()}
</div>
@@ -0,0 +1,36 @@
<script lang="ts">
import { THEMES, type ChartConfig } from "./chart-utils";
let { id, config }: { id: string; config: ChartConfig } = $props();
const colorConfig = $derived(
config
? Object.entries(config).filter(([, c]) => c.theme || c.color)
: null,
);
const themeContents = $derived.by(() => {
if (!colorConfig?.length) return;
const themeContents: string[] = [];
for (const [_theme, prefix] of Object.entries(THEMES)) {
let content = `${prefix} [data-chart=${id}] {\n`;
const color = colorConfig.map(([key, itemConfig]) => {
const theme = _theme as keyof typeof itemConfig.theme;
const c = itemConfig.theme?.[theme] || itemConfig.color;
return c ? `\t--color-${key}: ${c};` : null;
});
content += color.filter(Boolean).join("\n") + "\n}";
themeContents.push(content);
}
return themeContents.join("\n");
});
</script>
{#if themeContents}
{#key id}
<svelte:element this={"style"}>
{themeContents}
</svelte:element>
{/key}
{/if}
@@ -0,0 +1,157 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
import { getTooltipContext, Tooltip as TooltipPrimitive } from "layerchart";
import { cn } from "../utils/cn";
import {
getPayloadConfigFromPayload,
useChart,
type TooltipPayload,
} from "./chart-utils";
function defaultFormatter(value: unknown) {
return `${value}`;
}
let {
class: className,
hideLabel = false,
indicator = "dot",
hideIndicator = false,
labelKey,
label,
labelFormatter = defaultFormatter,
labelClassName,
formatter,
nameKey,
color,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
hideLabel?: boolean;
label?: string;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
hideIndicator?: boolean;
labelClassName?: string;
labelFormatter?: (
value: unknown,
payload: TooltipPayload[],
) => string | number | Snippet;
formatter?: Snippet<
[
{
value: unknown;
name: string;
item: TooltipPayload;
index: number;
payload: TooltipPayload[];
},
]
>;
} = $props();
const chart = useChart();
const tooltipCtx = getTooltipContext();
const formattedLabel = $derived.by(() => {
if (hideLabel || !tooltipCtx.payload?.length) return null;
const [item] = tooltipCtx.payload;
const key = labelKey ?? item?.label ?? item?.name ?? "value";
const itemConfig = getPayloadConfigFromPayload(chart.config, item, key);
const value =
!labelKey && typeof label === "string"
? (chart.config[label as keyof typeof chart.config]?.label ?? label)
: (itemConfig?.label ?? item.label);
if (value === undefined) return null;
if (!labelFormatter) return value;
return labelFormatter(value, tooltipCtx.payload);
});
const nestLabel = $derived(
tooltipCtx.payload.length === 1 && indicator !== "dot",
);
</script>
{#snippet TooltipLabel()}
{#if formattedLabel}
<div class={cn("font-medium text-zinc-900 dark:text-white", labelClassName)}>
{#if typeof formattedLabel === "function"}
{@render formattedLabel()}
{:else}
{formattedLabel}
{/if}
</div>
{/if}
{/snippet}
<TooltipPrimitive.Root variant="none">
<div
class={cn(
"grid min-w-[9rem] items-start gap-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-2.5 py-1.5 text-xs shadow-xl text-zinc-900 dark:text-white",
className,
)}
{...restProps}
>
{#if !nestLabel}
{@render TooltipLabel()}
{/if}
<div class="grid gap-1.5">
{#each tooltipCtx.payload as item, i (item.key + i)}
{@const key = `${nameKey || item.key || item.name || "value"}`}
{@const itemConfig = getPayloadConfigFromPayload(chart.config, item, key)}
{@const indicatorColor = color || item.payload?.color || item.color}
<div
class={cn(
"flex w-full flex-wrap items-stretch gap-2",
indicator === "dot" && "items-center",
)}
>
{#if formatter && item.value !== undefined && item.name}
{@render formatter({
value: item.value,
name: item.name,
item,
index: i,
payload: tooltipCtx.payload,
})}
{:else}
{#if !hideIndicator}
<div
style="background: {indicatorColor}; border-color: {indicatorColor};"
class={cn("shrink-0 rounded-[2px] border", {
"size-2.5": indicator === "dot",
"h-full w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
})}
></div>
{/if}
<div
class={cn(
"flex flex-1 shrink-0 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div class="grid gap-1.5">
{#if nestLabel}
{@render TooltipLabel()}
{/if}
<span class="text-zinc-500 dark:text-zinc-400">
{itemConfig?.label || item.name}
</span>
</div>
{#if item.value !== undefined}
<span class="font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
</TooltipPrimitive.Root>
@@ -0,0 +1,80 @@
import type { Tooltip } from "layerchart";
import {
getContext,
setContext,
type Component,
type ComponentProps,
type Snippet,
} from "svelte";
export const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: string;
icon?: Component;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
export type ExtractSnippetParams<T> = T extends Snippet<[infer P]> ? P : never;
export type TooltipPayload = ExtractSnippetParams<
ComponentProps<typeof Tooltip.Root>["children"]
>["payload"][number];
export function getPayloadConfigFromPayload(
config: ChartConfig,
payload: TooltipPayload,
key: string,
) {
if (typeof payload !== "object" || payload === null) return undefined;
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (payload.key === key) {
configLabelKey = payload.key;
} else if (payload.name === key) {
configLabelKey = payload.name;
} else if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload !== undefined &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
type ChartContextValue = {
config: ChartConfig;
};
const chartContextKey = Symbol("chart-context");
export function setChartContext(value: ChartContextValue) {
return setContext(chartContextKey, value);
}
export function useChart() {
return getContext<ChartContextValue>(chartContextKey);
}
@@ -0,0 +1,10 @@
import ChartContainer from "./chart-container.svelte";
import ChartTooltip from "./chart-tooltip.svelte";
export { getPayloadConfigFromPayload, type ChartConfig } from "./chart-utils";
export {
ChartContainer,
ChartTooltip,
ChartContainer as Container,
ChartTooltip as Tooltip,
};
@@ -0,0 +1,75 @@
import type { Plugin } from "@/plugins/core/types";
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { processMenuItemNode } from "@/seqta/utils/sidebarMenuIcons";
import { loadAnalyticsPage } from "../loadAnalyticsPage";
import styles from "../styles.css?inline";
const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics;
const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item";
const gradeAnalyticsPlugin: Plugin<{}> = {
id: "grade-analytics",
name: "Grade Analytics",
description:
"Adds an analytics page with grade trends, distribution charts, and assessment history",
version: "1.0.0",
settings: {},
disableToggle: false,
styles,
run: async () => {
if (isSeqtaEngageExperience()) {
return () => {};
}
const menuList = (await waitForElm("#menu > ul, #menu ul", true, 100, 60)) as HTMLElement;
const analyticsItem = document.createElement("li");
analyticsItem.className = "item";
analyticsItem.classList.add(ANALYTICS_MENU_CLASS);
analyticsItem.id = "analyticsbutton";
analyticsItem.dataset.key = "analytics";
analyticsItem.dataset.path = "/analytics";
analyticsItem.dataset.betterseqta = "true";
analyticsItem.innerHTML = `<label>${ANALYTICS_MENU_ICON}<span>Analytics</span></label>`;
const homeButton = document.getElementById("homebutton");
if (homeButton?.parentElement === menuList) {
homeButton.insertAdjacentElement("afterend", analyticsItem);
} else {
menuList.insertBefore(analyticsItem, menuList.firstChild);
}
processMenuItemNode(analyticsItem);
const menuObserver = new MutationObserver(() => {
if (!menuList.contains(analyticsItem)) {
if (homeButton?.parentElement === menuList) {
homeButton.insertAdjacentElement("afterend", analyticsItem);
} else {
menuList.insertBefore(analyticsItem, menuList.firstChild);
}
processMenuItemNode(analyticsItem);
}
});
menuObserver.observe(menuList, { childList: true });
const onClick = (e: Event) => {
e.preventDefault();
window.history.pushState({}, "", "/#?page=/analytics");
void loadAnalyticsPage();
};
analyticsItem.addEventListener("click", onClick);
return () => {
menuObserver.disconnect();
analyticsItem.removeEventListener("click", onClick);
analyticsItem.remove();
};
},
};
export default gradeAnalyticsPlugin;
@@ -0,0 +1,475 @@
import { approximatePercentFromLetterGrade } from "./letterGradeScale";
import type { Assessment } from "./types";
export type DistributionMode = "auto" | "letter" | "percent";
export const DISTRIBUTION_MODE_OPTIONS: {
value: DistributionMode;
label: string;
description: string;
}[] = [
{
value: "auto",
label: "Auto",
description: "Letter grades when your school uses them, otherwise percentages",
},
{
value: "letter",
label: "Letter grades",
description: "Group by letter band (school scale or standard AF)",
},
{
value: "percent",
label: "Percentage bands",
description: "Group by score ranges (90100, 8089, …)",
},
];
export type DistributionBucket = {
label: string;
count: number;
minPercent?: number;
maxPercent?: number;
};
export type GradeDistributionResult = {
buckets: DistributionBucket[];
modeUsed: "letter" | "percent";
scaleSource: "inferred" | "standard" | "percent";
scaleLabel: string;
gradedCount: number;
averagePercent: number | null;
letterGradeCoverage: number;
};
const PERCENT_BUCKETS: { label: string; min: number; max: number }[] = [
{ label: "90100", min: 90, max: 100 },
{ label: "8089", min: 80, max: 89 },
{ label: "7079", min: 70, max: 79 },
{ label: "6069", min: 60, max: 69 },
{ label: "5059", min: 50, max: 59 },
{ label: "049", min: 0, max: 49 },
];
/** Standard AF (+ modifiers) ordering when school scale cannot be inferred. */
const STANDARD_LETTER_ORDER = [
"A+",
"A",
"A-",
"B+",
"B",
"B-",
"C+",
"C",
"C-",
"D+",
"D",
"D-",
"E",
"F",
"HD",
"CR",
"P",
"PS",
"N",
"PASS",
"FAIL",
] as const;
export type InferredLetterBand = {
key: string;
label: string;
medianPercent: number;
minPercent: number;
maxPercent: number;
pairedSamples: number;
totalCount: number;
};
export type InferredLetterScale = {
bands: InferredLetterBand[];
pairedCount: number;
letterAssessmentCount: number;
confidence: "high" | "medium" | "low";
};
function normalizeLetterKey(raw: string): string {
const s = raw.trim().toLowerCase();
const first = s.split(/[\s(/]/)[0] ?? s;
return first.replace(/[^a-z0-9+-]/gi, "") || s;
}
function pickDisplayLabel(variants: string[]): string {
if (!variants.length) return "";
const counts = new Map<string, number>();
for (const v of variants) {
const t = v.trim();
if (!t) continue;
counts.set(t, (counts.get(t) ?? 0) + 1);
}
let best = variants[0].trim();
let bestCount = 0;
for (const [label, count] of counts) {
if (count > bestCount) {
bestCount = count;
best = label;
}
}
return best;
}
export function looksLikeLetterGrade(raw: string | undefined | null): boolean {
if (raw == null) return false;
const t = raw.trim();
if (!t) return false;
if (/^\d+(\.\d+)?%?$/.test(t)) return false;
if (t.length > 12) return false;
const upper = t.toUpperCase();
if (["HD", "CR", "P", "PS", "N", "PASS", "FAIL"].includes(upper)) return true;
return /[a-zA-Z]/.test(t);
}
function isGradedAssessment(a: Assessment): boolean {
return (
a.finalGrade !== undefined ||
(a.letterGrade != null && looksLikeLetterGrade(a.letterGrade))
);
}
function buildStandardLetterScale(): InferredLetterScale {
const bands: InferredLetterBand[] = [];
const seen = new Set<string>();
for (const label of STANDARD_LETTER_ORDER) {
const key = normalizeLetterKey(label);
if (seen.has(key)) continue;
const approx = approximatePercentFromLetterGrade(label);
if (approx === undefined) continue;
seen.add(key);
bands.push({
key,
label,
medianPercent: approx,
minPercent: approx,
maxPercent: approx,
pairedSamples: 0,
totalCount: 0,
});
}
bands.sort((a, b) => b.medianPercent - a.medianPercent);
for (let i = 0; i < bands.length; i++) {
const above = bands[i - 1];
const below = bands[i + 1];
bands[i].maxPercent =
above != null
? (above.medianPercent + bands[i].medianPercent) / 2
: 100;
bands[i].minPercent =
below != null
? (below.medianPercent + bands[i].medianPercent) / 2
: 0;
}
return {
bands,
pairedCount: 0,
letterAssessmentCount: 0,
confidence: "low",
};
}
/**
* Learn letter bands from assessments that report both % and the letter SEQTA assigned.
*/
export function inferLetterGradeScale(
assessments: Assessment[],
): InferredLetterScale | null {
const pairMap = new Map<string, { percents: number[]; labels: string[] }>();
const letterOnlyMap = new Map<string, { labels: string[]; count: number }>();
let pairedCount = 0;
let letterAssessmentCount = 0;
for (const a of assessments) {
if (!isGradedAssessment(a)) continue;
const letterRaw = a.letterGrade?.trim();
const hasLetter = letterRaw && looksLikeLetterGrade(letterRaw);
if (hasLetter) letterAssessmentCount++;
if (hasLetter && a.finalGrade !== undefined) {
const key = normalizeLetterKey(letterRaw);
if (/^\d+(\.\d+)?$/.test(key)) continue;
pairedCount++;
if (!pairMap.has(key)) pairMap.set(key, { percents: [], labels: [] });
const entry = pairMap.get(key)!;
entry.percents.push(a.finalGrade);
entry.labels.push(letterRaw);
} else if (hasLetter) {
const key = normalizeLetterKey(letterRaw);
if (/^\d+(\.\d+)?$/.test(key)) continue;
if (!letterOnlyMap.has(key)) letterOnlyMap.set(key, { labels: [], count: 0 });
const entry = letterOnlyMap.get(key)!;
entry.count++;
entry.labels.push(letterRaw);
}
}
if (letterAssessmentCount < 2 && pairedCount < 2) return null;
const allKeys = new Set([...pairMap.keys(), ...letterOnlyMap.keys()]);
if (allKeys.size < 2 && pairedCount < 2) return null;
const bands: InferredLetterBand[] = [];
for (const key of allKeys) {
const paired = pairMap.get(key);
const letterOnly = letterOnlyMap.get(key);
const labels = [...(paired?.labels ?? []), ...(letterOnly?.labels ?? [])];
const percents = paired?.percents ?? [];
const totalCount = percents.length + (letterOnly?.count ?? 0);
let medianPercent: number;
let minPercent: number;
let maxPercent: number;
if (percents.length > 0) {
const sorted = [...percents].sort((x, y) => x - y);
medianPercent = sorted[Math.floor(sorted.length / 2)]!;
minPercent = sorted[0]!;
maxPercent = sorted[sorted.length - 1]!;
} else {
const approx = approximatePercentFromLetterGrade(pickDisplayLabel(labels));
if (approx === undefined) continue;
medianPercent = approx;
minPercent = approx;
maxPercent = approx;
}
bands.push({
key,
label: pickDisplayLabel(labels),
medianPercent,
minPercent,
maxPercent,
pairedSamples: percents.length,
totalCount,
});
}
if (bands.length < 2) return null;
bands.sort((a, b) => b.medianPercent - a.medianPercent);
for (let i = 0; i < bands.length; i++) {
const above = bands[i - 1];
const below = bands[i + 1];
if (bands[i].pairedSamples > 0 || above?.pairedSamples || below?.pairedSamples) {
bands[i].maxPercent =
above != null
? (above.medianPercent + bands[i].medianPercent) / 2
: 100;
bands[i].minPercent =
below != null
? (below.medianPercent + bands[i].medianPercent) / 2
: 0;
}
}
const confidence: InferredLetterScale["confidence"] =
pairedCount >= 8 || (pairedCount >= 5 && pairedCount / letterAssessmentCount >= 0.4)
? "high"
: pairedCount >= 3 || letterAssessmentCount >= 5
? "medium"
: "low";
return {
bands,
pairedCount,
letterAssessmentCount,
confidence,
};
}
function resolveEffectiveMode(
mode: DistributionMode,
inferred: InferredLetterScale | null,
graded: Assessment[],
): "letter" | "percent" {
if (mode === "percent") return "percent";
if (mode === "letter") return "letter";
if (!inferred) return "percent";
const letterCount = graded.filter(
(a) => a.letterGrade && looksLikeLetterGrade(a.letterGrade),
).length;
if (letterCount === 0) return "percent";
if (inferred.confidence === "high" || inferred.confidence === "medium") {
return "letter";
}
return letterCount / graded.length >= 0.35 ? "letter" : "percent";
}
function assignPercentToBand(
percent: number,
scale: InferredLetterScale,
): string | null {
if (!scale.bands.length) return null;
for (const band of scale.bands) {
if (percent >= band.minPercent) return band.key;
}
return scale.bands[scale.bands.length - 1]!.key;
}
function buildPercentDistribution(graded: Assessment[]): GradeDistributionResult {
const counts = PERCENT_BUCKETS.map((b) => ({ label: b.label, count: 0 }));
let percentSum = 0;
let percentCount = 0;
for (const a of graded) {
let grade = a.finalGrade;
if (grade === undefined && a.letterGrade) {
grade = approximatePercentFromLetterGrade(a.letterGrade);
}
if (grade === undefined) continue;
percentSum += grade;
percentCount++;
const bucket = PERCENT_BUCKETS.find((b) => grade! >= b.min && grade! <= b.max);
if (bucket) {
const row = counts.find((c) => c.label === bucket.label);
if (row) row.count++;
}
}
return {
buckets: counts,
modeUsed: "percent",
scaleSource: "percent",
scaleLabel: "Percentage bands",
gradedCount: graded.length,
averagePercent:
percentCount > 0 ? Math.round((percentSum / percentCount) * 10) / 10 : null,
letterGradeCoverage: 0,
};
}
function buildLetterDistribution(
graded: Assessment[],
inferred: InferredLetterScale | null,
forceStandard: boolean,
): GradeDistributionResult {
const scale =
!forceStandard && inferred && inferred.bands.length >= 2
? inferred
: buildStandardLetterScale();
const scaleSource =
!forceStandard && inferred && inferred.bands.length >= 2 ? "inferred" : "standard";
const countByKey = new Map<string, number>();
for (const band of scale.bands) countByKey.set(band.key, 0);
let percentSum = 0;
let percentCount = 0;
let withLetter = 0;
for (const a of graded) {
if (a.finalGrade !== undefined) {
percentSum += a.finalGrade;
percentCount++;
}
const letterRaw = a.letterGrade?.trim();
if (letterRaw && looksLikeLetterGrade(letterRaw)) withLetter++;
let key: string | null = null;
if (letterRaw && looksLikeLetterGrade(letterRaw)) {
key = normalizeLetterKey(letterRaw);
if (/^\d+(\.\d+)?$/.test(key)) key = null;
}
if (!key && a.finalGrade !== undefined) {
key = assignPercentToBand(a.finalGrade, scale);
}
if (!key && letterRaw && looksLikeLetterGrade(letterRaw)) {
const approx = approximatePercentFromLetterGrade(letterRaw);
if (approx !== undefined) key = assignPercentToBand(approx, scale);
}
if (!key) continue;
if (!countByKey.has(key)) {
countByKey.set(key, 0);
const existing = scale.bands.find((b) => b.key === key);
if (!existing) {
const approx =
a.finalGrade ??
(letterRaw ? approximatePercentFromLetterGrade(letterRaw) : undefined) ??
0;
scale.bands.push({
key,
label:
letterRaw && looksLikeLetterGrade(letterRaw)
? letterRaw
: key.toUpperCase(),
medianPercent: approx,
minPercent: 0,
maxPercent: 100,
pairedSamples: 0,
totalCount: 0,
});
scale.bands.sort((x, y) => y.medianPercent - x.medianPercent);
}
}
countByKey.set(key, (countByKey.get(key) ?? 0) + 1);
}
const buckets: DistributionBucket[] = scale.bands
.filter((b) => (countByKey.get(b.key) ?? 0) > 0)
.map((b) => ({
label: b.label,
count: countByKey.get(b.key) ?? 0,
minPercent: Math.round(b.minPercent),
maxPercent: Math.round(b.maxPercent),
}));
const scaleLabel =
scaleSource === "inferred"
? "Learned from your school's percentage ↔ letter marks"
: "Standard AF style scale (override)";
return {
buckets,
modeUsed: "letter",
scaleSource,
scaleLabel,
gradedCount: graded.length,
averagePercent:
percentCount > 0 ? Math.round((percentSum / percentCount) * 10) / 10 : null,
letterGradeCoverage: graded.length ? withLetter / graded.length : 0,
};
}
export function buildGradeDistribution(
assessments: Assessment[],
mode: DistributionMode = "auto",
): GradeDistributionResult {
const graded = assessments.filter(isGradedAssessment);
if (!graded.length) {
return {
buckets: [],
modeUsed: "percent",
scaleSource: "percent",
scaleLabel: "Percentage bands",
gradedCount: 0,
averagePercent: null,
letterGradeCoverage: 0,
};
}
const inferred = inferLetterGradeScale(graded);
const effective = resolveEffectiveMode(mode, inferred, graded);
if (effective === "letter") {
return buildLetterDistribution(graded, inferred, mode === "letter" && !inferred);
}
return buildPercentDistribution(graded);
}
@@ -0,0 +1,38 @@
import { defineLazyPlugin } from "../../core/dynamicLoader";
import { defineSettings, numberSetting } from "../../core/settingsHelpers";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import styles from "./styles.css?inline";
const settings = defineSettings({
cacheTtlHours: numberSetting({
default: 24,
title: "Cache duration (hours)",
description: "How long to keep synced analytics before refreshing from SEQTA",
min: 1,
max: 168,
}),
});
const gradeAnalyticsPluginLazy = defineLazyPlugin({
id: "grade-analytics",
name: "Grade Analytics",
description:
"Grade trends, distribution charts, and assessment history synced from SEQTA",
version: "1.0.0",
settings,
disableToggle: false,
defaultEnabled: true,
styles,
loader: () => import("./core/index"),
});
const runGradeAnalytics = gradeAnalyticsPluginLazy.run!;
gradeAnalyticsPluginLazy.run = async (api) => {
if (isSeqtaEngageExperience()) {
return () => {};
}
return runGradeAnalytics(api);
};
export default gradeAnalyticsPluginLazy;
@@ -0,0 +1,116 @@
/**
* When SEQTA only reports letter bands (no percentage), map to approximate 0100
* so analytics charts can run. Conventional scale, not official school conversion.
*/
const LETTER_TO_APPROX_PERCENT: Record<string, number> = {
"a+": 95,
a: 85,
"a-": 80,
"b+": 75,
b: 68,
"b-": 62,
"c+": 58,
c: 55,
"c-": 50,
"d+": 48,
d: 45,
"d-": 42,
e: 38,
f: 32,
hd: 95,
cr: 60,
p: 55,
ps: 55,
n: 35,
pass: 55,
fail: 32,
};
function normalizeLetterKey(raw: string): string {
const s = raw.trim().toLowerCase();
const first = s.split(/[\s(/]/)[0] ?? s;
return first.replace(/[^a-z+-]/gi, "") || s;
}
export function approximatePercentFromLetterGrade(
letter: string | null | undefined,
): number | undefined {
if (letter == null) return undefined;
const t = String(letter).trim();
if (!t) return undefined;
if (/^\d+(\.\d+)?$/.test(t)) {
const n = parseFloat(t);
if (!isNaN(n) && n >= 0 && n <= 100) return n;
}
const key = normalizeLetterKey(t);
if (LETTER_TO_APPROX_PERCENT[key] !== undefined)
return LETTER_TO_APPROX_PERCENT[key];
if (t.length === 1 && /^[a-f]$/i.test(t)) {
const single = t.toLowerCase() as keyof typeof LETTER_TO_APPROX_PERCENT;
if (LETTER_TO_APPROX_PERCENT[single] !== undefined)
return LETTER_TO_APPROX_PERCENT[single];
}
return undefined;
}
export function extractLetterGradeStringFromPayload(data: {
criteria?: { results?: { grade?: unknown } }[];
results?: { grade?: unknown };
letterGrade?: unknown;
extra?: Record<string, unknown>;
}): string | undefined {
const merged: Record<string, unknown> = {
...(data?.extra && typeof data.extra === "object" ? data.extra : {}),
...data,
};
if (merged.letterGrade != null && String(merged.letterGrade).trim() !== "") {
return String(merged.letterGrade).trim();
}
const criteria = merged.criteria as
| { results?: { grade?: unknown } }[]
| undefined;
const c0 = criteria?.[0]?.results?.grade;
if (c0 != null && String(c0).trim() !== "") return String(c0).trim();
const r = (merged.results as { grade?: unknown } | undefined)?.grade;
if (r != null && String(r).trim() !== "") return String(r).trim();
return undefined;
}
export function resolveNumericGradeFromAssessmentPayload(data: {
status?: string;
finalGrade?: unknown;
criteria?: { results?: { percentage?: unknown; grade?: unknown } }[];
results?: { percentage?: unknown; grade?: unknown };
letterGrade?: unknown;
extra?: Record<string, unknown>;
}): number | undefined {
const merged: Record<string, unknown> = {
...(data?.extra && typeof data.extra === "object" ? data.extra : {}),
...data,
};
if (merged.finalGrade != null && merged.finalGrade !== "") {
const n = Number(merged.finalGrade);
if (!isNaN(n)) return n;
}
if (merged.status && merged.status !== "MARKS_RELEASED") return undefined;
const criteria = merged.criteria as
| { results?: { percentage?: unknown; grade?: unknown } }[]
| undefined;
if (criteria?.[0]?.results?.percentage !== undefined) {
const n = Number(criteria[0].results!.percentage);
if (!isNaN(n)) return n;
}
const results = merged.results as
| { percentage?: unknown; grade?: unknown }
| undefined;
if (results?.percentage !== undefined) {
const n = Number(results.percentage);
if (!isNaN(n)) return n;
}
const letter = extractLetterGradeStringFromPayload(
merged as Parameters<typeof extractLetterGradeStringFromPayload>[0],
);
return approximatePercentFromLetterGrade(letter);
}
@@ -0,0 +1,46 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { waitForElm } from "@/seqta/utils/waitForElm";
let loadInFlight: Promise<void> | null = null;
export async function loadAnalyticsPage(): Promise<void> {
if (!settingsState.onoff) return;
if (loadInFlight) {
await loadInFlight;
return;
}
loadInFlight = loadAnalyticsPageInner();
try {
await loadInFlight;
} finally {
loadInFlight = null;
}
}
async function loadAnalyticsPageInner(): Promise<void> {
document.title = "Analytics ― SEQTA Learn";
document.querySelectorAll("#menu .item").forEach((item) => {
item.classList.remove("active");
});
document.querySelector('[data-key="analytics"]')?.classList.add("active");
const main = (await waitForElm("#main", true, 100, 60)) as HTMLElement;
main.innerHTML = "";
main.style.overflow = "auto";
main.style.width = "100%";
main.style.maxWidth = "none";
const viewShell = document.createElement("div");
viewShell.id = "analytics-view-container";
main.appendChild(viewShell);
const container = viewShell;
const titlediv = document.getElementById("title")?.firstChild;
if (titlediv) (titlediv as HTMLElement).innerText = "Analytics";
const { renderAnalyticsPage } = await import("./ui");
renderAnalyticsPage(container);
}
@@ -0,0 +1,61 @@
import stringToHTML from "@/seqta/utils/stringToHTML";
import { openPopup } from "@/seqta/utils/Openers/PopupManager";
/** Grade Analytics privacy — uses the shared BetterSEQTA+ whatsnew popup shell. */
export function openAnalyticsPrivacyPopup() {
const header = stringToHTML(
/* html */
`<div class="whatsnewHeader">
<h1>Privacy notice</h1>
<p>Grade Analytics on this device</p>
</div>`,
).firstChild as HTMLElement;
const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer privacyStatement">
<p style="margin-top: 0;">
<strong>Your grade history and charts stay on this device.</strong>
BetterSEQTA+ does not collect or store your analytics on our servers.
</p>
<h3>What we store locally</h3>
<ul style="text-align: left; margin: 10px 0;">
<li>Assessment results and subjects used for trends, distribution, and the table</li>
<li>Chart preferences (for example, grade distribution grouping) for your school account</li>
<li>A cache timestamp so Refresh data knows when to fetch from SEQTA again</li>
</ul>
<h3>What we never do</h3>
<ul style="text-align: left; margin: 10px 0;">
<li>Upload analytics data to BetterSEQTA Cloud or any BetterSEQTA server</li>
<li>Include analytics in automatic cloud settings backup or restore</li>
<li>Send your grades to third-party analytics or tracking services</li>
</ul>
<h3>How refresh works</h3>
<p>
Refresh data loads released marks directly from SEQTA while you are logged in.
That traffic is between your browser and your schools SEQTA site not to us.
</p>
<h3>Clearing your data</h3>
<p>
You can remove cached analytics any time by clearing this extensions storage in
your browser settings.
</p>
<p style="font-weight: 600;">
General plugin settings (such as cache duration in the Grade Analytics plugin
panel) may still sync if you use BetterSEQTA Cloud but never your assessment
results or charts.
</p>
</div>
`).firstChild as HTMLElement;
openPopup({
header,
content: [text],
animateSelector: ".whatsnewTextContainer *",
containerClass: "whatsnewContainer--scrollBody",
});
}
@@ -0,0 +1,68 @@
import browser from "webextension-polyfill";
import type { DistributionMode } from "./gradeDistribution";
import type { AnalyticsCache } from "./types";
const STORAGE_PREFIX = "bsplus.analytics.v2";
const DISTRIBUTION_MODE_PREFIX = "bsplus.analytics.distMode.v1";
export function analyticsStorageKey(origin: string, studentId: number): string {
return `${STORAGE_PREFIX}.${origin}.${studentId}`;
}
export async function loadAnalyticsCache(
origin: string,
studentId: number,
): Promise<AnalyticsCache | null> {
const key = analyticsStorageKey(origin, studentId);
const result = await browser.storage.local.get(key);
const cached = result[key] as AnalyticsCache | undefined;
if (!cached?.assessments) return null;
return cached;
}
export async function saveAnalyticsCache(
origin: string,
studentId: number,
assessments: AnalyticsCache["assessments"],
): Promise<void> {
const key = analyticsStorageKey(origin, studentId);
const payload: AnalyticsCache = {
updatedAt: Date.now(),
assessments,
};
await browser.storage.local.set({ [key]: payload });
}
export function distributionModeStorageKey(
origin: string,
studentId: number,
): string {
return `${DISTRIBUTION_MODE_PREFIX}.${origin}.${studentId}`;
}
const VALID_DISTRIBUTION_MODES: DistributionMode[] = ["auto", "letter", "percent"];
export async function loadDistributionMode(
origin: string,
studentId: number,
): Promise<DistributionMode | null> {
const key = distributionModeStorageKey(origin, studentId);
const result = await browser.storage.local.get(key);
const mode = result[key];
if (
typeof mode === "string" &&
VALID_DISTRIBUTION_MODES.includes(mode as DistributionMode)
) {
return mode as DistributionMode;
}
return null;
}
export async function saveDistributionMode(
origin: string,
studentId: number,
mode: DistributionMode,
): Promise<void> {
const key = distributionModeStorageKey(origin, studentId);
await browser.storage.local.set({ [key]: mode });
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,224 @@
import type { Assessment } from "./types";
export type TimeRange = "all" | "365d" | "90d" | "30d" | "7d";
export const TIME_RANGE_OPTIONS: { value: TimeRange; label: string }[] = [
{ value: "all", label: "All time" },
{ value: "365d", label: "Last 12 months" },
{ value: "90d", label: "Last 3 months" },
{ value: "30d", label: "Last 30 days" },
{ value: "7d", label: "Last 7 days" },
];
export function getTimeRangeLabel(timeRange: TimeRange): string {
return TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.label ?? "All time";
}
export function getTimeRangeCutoff(timeRange: TimeRange): Date | null {
if (timeRange === "all") return null;
const referenceDate = new Date();
let daysToSubtract = 90;
if (timeRange === "30d") daysToSubtract = 30;
else if (timeRange === "7d") daysToSubtract = 7;
else if (timeRange === "365d") daysToSubtract = 365;
const cutoff = new Date(referenceDate);
cutoff.setDate(cutoff.getDate() - daysToSubtract);
cutoff.setHours(0, 0, 0, 0);
return cutoff;
}
export function filterAssessmentsByTimeRange(
assessments: Assessment[],
timeRange: TimeRange,
): Assessment[] {
const cutoff = getTimeRangeCutoff(timeRange);
if (!cutoff) return assessments;
return assessments.filter((a) => new Date(a.due) >= cutoff);
}
export type TrendPoint = {
date: Date;
average: number;
count: number;
[seriesKey: string]: number | Date;
};
export type TrendSeries = {
key: string;
label: string;
color: string;
isOverall?: boolean;
};
const SUBJECT_CHART_COLORS = [
"#2563eb",
"#16a34a",
"#ca8a04",
"#9333ea",
"#0891b2",
"#ea580c",
"#db2777",
"#4f46e5",
"#0d9488",
"#b45309",
"#7c3aed",
"#dc2626",
];
export function subjectChartColor(index: number): string {
return SUBJECT_CHART_COLORS[index % SUBJECT_CHART_COLORS.length];
}
function periodKeyForAssessment(
assessment: Assessment,
useMonthlyGrouping: boolean,
): string {
const date = new Date(assessment.due);
if (useMonthlyGrouping) {
return date.toISOString().slice(0, 7);
}
const monday = new Date(date);
const dayOfWeek = date.getDay();
const diff = date.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
monday.setDate(diff);
return monday.toISOString().slice(0, 10);
}
function periodDate(periodKey: string, useMonthlyGrouping: boolean): Date {
return useMonthlyGrouping ? new Date(`${periodKey}-01`) : new Date(periodKey);
}
function average(nums: number[]): number {
return nums.reduce((sum, g) => sum + g, 0) / nums.length;
}
function slugSubjectKey(name: string, keyBySubject: Map<string, string>): string {
if (keyBySubject.has(name)) return keyBySubject.get(name)!;
let base =
name
.trim()
.replace(/[^\w]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 48) || "subject";
const taken = new Set(keyBySubject.values());
let candidate = base;
let n = 2;
while (taken.has(candidate)) {
candidate = `${base}_${n}`;
n++;
}
keyBySubject.set(name, candidate);
return candidate;
}
export function buildGradeTrendChart(
data: Assessment[],
timeRange: TimeRange,
options: { showPerSubject?: boolean } = {},
): { points: TrendPoint[]; series: TrendSeries[]; accentColor: string } {
const accentColor =
"var(--bsplus-analytics-accent, var(--better-main, #007bff))";
const graded = data.filter(
(a) => a.finalGrade !== undefined && a.finalGrade !== null,
);
if (!graded.length) {
return { points: [], series: [], accentColor };
}
const useMonthlyGrouping = timeRange === "365d" || timeRange === "all";
const cutoff = getTimeRangeCutoff(timeRange);
const overallBuckets = new Map<string, number[]>();
const subjectBuckets = new Map<string, Map<string, number[]>>();
const subjectLabels = new Map<string, string>();
const keyBySubject = new Map<string, string>();
for (const assessment of graded) {
const grade = assessment.finalGrade!;
const periodKey = periodKeyForAssessment(assessment, useMonthlyGrouping);
const periodDateValue = periodDate(periodKey, useMonthlyGrouping);
if (cutoff && periodDateValue < cutoff) continue;
if (!overallBuckets.has(periodKey)) overallBuckets.set(periodKey, []);
overallBuckets.get(periodKey)!.push(grade);
if (options.showPerSubject) {
const subject = assessment.subject;
if (!subjectBuckets.has(subject)) {
subjectBuckets.set(subject, new Map());
subjectLabels.set(subject, subject);
slugSubjectKey(subject, keyBySubject);
}
const buckets = subjectBuckets.get(subject)!;
if (!buckets.has(periodKey)) buckets.set(periodKey, []);
buckets.get(periodKey)!.push(grade);
}
}
const periodKeys = new Set<string>(overallBuckets.keys());
if (options.showPerSubject) {
for (const buckets of subjectBuckets.values()) {
for (const key of buckets.keys()) periodKeys.add(key);
}
}
const points: TrendPoint[] = Array.from(periodKeys)
.sort()
.map((periodKey) => {
const grades = overallBuckets.get(periodKey) ?? [];
const point: TrendPoint = {
date: periodDate(periodKey, useMonthlyGrouping),
average: grades.length ? average(grades) : NaN,
count: grades.length,
};
if (options.showPerSubject) {
for (const [subject, buckets] of subjectBuckets) {
const seriesKey = keyBySubject.get(subject)!;
const subjectGrades = buckets.get(periodKey);
if (subjectGrades?.length) {
point[seriesKey] = average(subjectGrades);
}
}
}
return point;
})
.filter((p) => {
if (!Number.isNaN(p.average)) return true;
if (!options.showPerSubject) return false;
return Object.keys(p).some(
(key) =>
key !== "date" &&
key !== "average" &&
key !== "count" &&
typeof p[key] === "number" &&
!Number.isNaN(p[key] as number),
);
});
const series: TrendSeries[] = [
{
key: "average",
label: "Overall average",
color: accentColor,
isOverall: true,
},
];
if (options.showPerSubject) {
const subjects = [...subjectLabels.keys()].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: "base" }),
);
subjects.forEach((subject, index) => {
series.push({
key: keyBySubject.get(subject)!,
label: subject,
color: subjectChartColor(index),
});
});
}
return { points, series, accentColor };
}
@@ -0,0 +1,29 @@
export type AssessmentStatus = "OVERDUE" | "MARKS_RELEASED" | "PENDING";
export interface Assessment {
id: number;
title: string;
subject: string;
status: AssessmentStatus;
due: string;
code: string;
metaclassID: number;
programmeID: number;
graded: boolean;
overdue: boolean;
hasFeedback: boolean;
expectationsEnabled: boolean;
expectationsCompleted: boolean;
reflectionsEnabled: boolean;
reflectionsCompleted: boolean;
availability: string;
finalGrade?: number;
letterGrade?: string;
}
export type AnalyticsData = Assessment[];
export interface AnalyticsCache {
updatedAt: number;
assessments: Assessment[];
}
+195
View File
@@ -0,0 +1,195 @@
import tailwindStyles from "@/interface/index.css?inline";
import pluginStyles from "./styles.css?inline";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { mount, unmount } from "svelte";
import GradeAnalyticsPage from "./GradeAnalyticsPage.svelte";
type ThemeSettingKey =
| "selectedColor"
| "DarkMode"
| "adaptiveThemeColour"
| "adaptiveThemeGradient"
| "selectedTheme";
type ThemeListenerRegistration = {
key: ThemeSettingKey;
listener: () => void;
};
let currentApp: ReturnType<typeof mount> | null = null;
let shadowHost: HTMLElement | null = null;
let analyticsRoot: HTMLElement | null = null;
let darkModeObserver: MutationObserver | null = null;
let themeStyleObserver: MutationObserver | null = null;
let themeListeners: ThemeListenerRegistration[] = [];
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;
/** Resolve a solid colour for charts (gradients → first stop). */
function extractSolidColor(value: string): string | null {
const trimmed = value.trim();
if (!trimmed || trimmed === "initial") return null;
if (
trimmed.startsWith("#") ||
trimmed.startsWith("rgb") ||
trimmed.startsWith("hsl")
) {
return trimmed;
}
if (trimmed.includes("gradient")) {
const match = trimmed.match(
/#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/i,
);
return match?.[0] ?? null;
}
return null;
}
const THEME_ACCENT_OVERRIDES: Record<string, string> = {
"bb0aaf40-55ef-40f7-bc64-93b67ef96c01": "#4ade80",
};
function resolvePageAccentColor(): string {
const themeId = settingsState.selectedTheme;
if (themeId && themeId in THEME_ACCENT_OVERRIDES) {
return THEME_ACCENT_OVERRIDES[themeId];
}
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 syncThemeFromPage(target: HTMLElement) {
const computed = getComputedStyle(document.documentElement);
for (const name of THEME_CSS_VARS) {
let value = computed.getPropertyValue(name).trim();
value = document.documentElement.style.getPropertyValue(name).trim();
if (value) {
target.style.setProperty(name, value);
}
}
const accent = resolvePageAccentColor();
target.style.setProperty("--bsplus-analytics-accent", accent);
target.style.setProperty("--better-main", accent);
target.classList.toggle(
"dark",
document.documentElement.classList.contains("dark"),
);
}
function syncThemeToAnalyticsUi() {
if (shadowHost) syncThemeFromPage(shadowHost);
if (analyticsRoot) syncThemeFromPage(analyticsRoot);
}
function clearThemeListeners() {
for (const { key, listener } of themeListeners) {
settingsState.unregister(key, listener);
}
themeListeners = [];
}
function watchThemeChanges() {
clearThemeListeners();
const keys: ThemeSettingKey[] = [
"selectedColor",
"DarkMode",
"adaptiveThemeColour",
"adaptiveThemeGradient",
"selectedTheme",
];
const listener = () => syncThemeToAnalyticsUi();
for (const key of keys) {
settingsState.register(key, listener);
themeListeners.push({ key, listener });
}
themeStyleObserver?.disconnect();
themeStyleObserver = new MutationObserver(() => syncThemeToAnalyticsUi());
themeStyleObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style", "class"],
});
}
function teardown() {
clearThemeListeners();
themeStyleObserver?.disconnect();
themeStyleObserver = null;
if (currentApp) {
unmount(currentApp);
currentApp = null;
}
darkModeObserver?.disconnect();
darkModeObserver = null;
shadowHost?.remove();
shadowHost = null;
analyticsRoot = null;
}
export function renderAnalyticsPage(container: HTMLElement) {
teardown();
container.innerHTML = "";
container.className = "bsplus-analytics-container";
shadowHost = document.createElement("div");
shadowHost.className = "bsplus-analytics-host";
container.appendChild(shadowHost);
const shadow = shadowHost.attachShadow({ mode: "open" });
const styleElement = document.createElement("style");
styleElement.textContent = `${tailwindStyles}\n${pluginStyles}`;
shadow.appendChild(styleElement);
analyticsRoot = document.createElement("div");
analyticsRoot.className = "bsplus-analytics-root";
syncThemeToAnalyticsUi();
shadow.appendChild(analyticsRoot);
watchThemeChanges();
darkModeObserver = new MutationObserver(() => syncThemeToAnalyticsUi());
darkModeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
currentApp = mount(GradeAnalyticsPage, { target: analyticsRoot });
}
export function unmountAnalyticsPage() {
teardown();
}
@@ -0,0 +1,3 @@
export function cn(...classes: (string | false | null | undefined)[]): string {
return classes.filter(Boolean).join(" ");
}
@@ -0,0 +1,962 @@
import type { Plugin } from "../../core/types";
import { booleanSetting } from "@/plugins/core/settingsHelpers";
import { waitForElm } from "@/seqta/utils/waitForElm";
import styles from "./styles.css?inline";
const messageFoldersSettings = {
showTagsInAllMessages: booleanSetting({
default: true,
title: "Show folder tags in All Messages",
description:
"When off, folder tags are not shown on the message list until you select a folder.",
}),
hideFolderedMessagesInAll: booleanSetting({
default: true,
title: "Hide foldered messages in All Messages",
description:
"When on, messages assigned to a custom folder are hidden from the inbox until you open that folder.",
}),
} as const;
interface Folder {
id: string;
name: string;
color: string;
emoji: string;
}
interface MessageFoldersStorage {
folders: Folder[];
messageAssignments: Record<string, string[]>;
}
const FOLDER_COLORS = [
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b",
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316",
];
const FOLDER_HEROICONS = [
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`,
];
const FOLDER_ICON_SVG = `<svg style="width:24px;height:24px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const PLUS_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`;
const CHECK_SVG_WHITE = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#fff" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>`;
const CLOSE_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>`;
const EDIT_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>`;
const TRASH_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`;
const CHEVRON_SVG = `<svg style="width:12px;height:12px;flex-shrink:0;transition:transform .2s" viewBox="0 0 24 24"><path fill="#888" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>`;
const DRAG_SVG = `<svg style="width:14px;height:14px;flex-shrink:0;cursor:grab" viewBox="0 0 24 24"><path fill="#888" d="M6.5 12.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/></svg>`;
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFoldersStorage> = {
id: "messageFolders",
name: "Message Folders",
description: "Organize direct messages into custom folders",
version: "2.0.0",
settings: messageFoldersSettings,
disableToggle: true,
defaultEnabled: true,
run: async (api) => {
const styleEl = document.createElement("style");
styleEl.textContent = styles;
document.head.appendChild(styleEl);
await api.storage.loaded;
if (!api.storage.folders) api.storage.folders = [];
if (!api.storage.messageAssignments) api.storage.messageAssignments = {};
let activeFolderId: string | null = null;
let messageListObserver: MutationObserver | null = null;
let sidebarObserver: MutationObserver | null = null;
let actionsObserver: MutationObserver | null = null;
let openDropdown: HTMLElement | null = null;
let dropdownCloseHandler: ((e: MouseEvent) => void) | null = null;
let foldedSection: HTMLElement | null = null;
const unregisters: Array<{ unregister: () => void }> = [];
const getFolders = (): Folder[] => api.storage.folders ?? [];
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
const saveFolders = (folders: Folder[]) => {
api.storage.folders = [...folders];
};
const saveAssignments = (assignments: Record<string, string[]>) => {
api.storage.messageAssignments = { ...assignments };
};
const getMessageFolderIds = (messageId: string): string[] => {
const assignments = getAssignments();
const ids: string[] = [];
for (const [folderId, msgIds] of Object.entries(assignments)) {
if (msgIds.includes(messageId)) ids.push(folderId);
}
return ids;
};
const assignMessageToFolder = (messageId: string, folderId: string, add: boolean) => {
const assignments = getAssignments();
if (!assignments[folderId]) assignments[folderId] = [];
const idx = assignments[folderId].indexOf(messageId);
if (add && idx < 0) {
assignments[folderId].push(messageId);
} else if (!add && idx >= 0) {
assignments[folderId].splice(idx, 1);
}
saveAssignments(assignments);
};
const toggleMessageInFolder = (messageId: string, folderId: string) => {
const assignments = getAssignments();
if (!assignments[folderId]) assignments[folderId] = [];
const idx = assignments[folderId].indexOf(messageId);
if (idx >= 0) {
assignments[folderId].splice(idx, 1);
} else {
assignments[folderId].push(messageId);
}
saveAssignments(assignments);
};
const getFolderMessageCount = (folderId: string): number => {
return (getAssignments()[folderId] ?? []).length;
};
const restoreSubjectPlain = (subject: Element) => {
subject.querySelector(".bsplus-msg-badges")?.remove();
const textWrap = subject.querySelector(".bsplus-subject-text");
if (textWrap) {
subject.textContent = textWrap.textContent ?? "";
}
};
const isMessageInAnyCustomFolder = (messageId: string): boolean => {
for (const msgIds of Object.values(getAssignments())) {
if (msgIds.includes(messageId)) return true;
}
return false;
};
const shouldShowBadgesInList = (): boolean => {
return api.settings.showTagsInAllMessages || activeFolderId !== null;
};
const getSelectedMessageId = (): string | null => {
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
return selectedMsg?.getAttribute("data-message") ?? null;
};
const getMessageIdFromEvent = (target: HTMLElement): string | null => {
const li = target.closest("li[data-message]");
return li?.getAttribute("data-message") ?? null;
};
const getAllVisibleMessageIds = (): string[] => {
const ids: string[] = [];
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
const id = li.getAttribute("data-message");
if (id) ids.push(id);
});
return ids;
};
const showConfirmModal = (title: string, message: string, onConfirm: () => void) => {
const overlay = document.createElement("div");
overlay.className = "bsplus-modal-overlay";
const modal = document.createElement("div");
modal.className = "bsplus-modal";
modal.innerHTML = `
<h3>${title}</h3>
<p>${message}</p>
<div class="bsplus-modal-actions">
<button class="bsplus-modal-btn-cancel">Cancel</button>
<button class="bsplus-modal-btn-danger">Delete</button>
</div>
`;
overlay.appendChild(modal);
const remove = () => {
overlay.remove();
document.removeEventListener("keydown", onKey);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") remove();
};
overlay.addEventListener("click", (e) => {
if (e.target === overlay) remove();
});
modal.querySelector(".bsplus-modal-btn-cancel")!.addEventListener("click", remove);
modal.querySelector(".bsplus-modal-btn-danger")!.addEventListener("click", () => {
onConfirm();
remove();
});
document.body.appendChild(overlay);
document.addEventListener("keydown", onKey);
};
const renderSidebarFolders = () => {
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (!sidebar) return;
const ol = sidebar.querySelector("ol");
if (!ol) return;
let section = ol.querySelector(".bsplus-folders-section") as HTMLElement;
if (!section) {
section = document.createElement("div");
section.className = "bsplus-folders-section";
ol.appendChild(section);
}
foldedSection = section;
const folders = getFolders();
section.innerHTML = "";
const header = document.createElement("div");
header.className = "bsplus-folders-header";
header.dataset.folded = "false";
const collapseBtn = document.createElement("button");
collapseBtn.className = "bsplus-folders-collapse";
collapseBtn.innerHTML = CHEVRON_SVG;
collapseBtn.title = "Collapse";
collapseBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isFolded = collapseBtn.classList.toggle("bsplus-folded");
section.classList.toggle("bsplus-section-folded", isFolded);
collapseBtn.title = isFolded ? "Expand" : "Collapse";
});
header.appendChild(collapseBtn);
const label = document.createElement("span");
label.textContent = "Folders";
header.appendChild(label);
const addBtn = document.createElement("button");
addBtn.className = "bsplus-folders-add-btn";
addBtn.title = "New folder";
addBtn.innerHTML = PLUS_SVG;
addBtn.addEventListener("click", (e) => {
e.stopPropagation();
showNewFolderInput(section!);
});
header.appendChild(addBtn);
section.appendChild(header);
const allItem = document.createElement("div");
allItem.className = `bsplus-folder-item bsplus-all-msgs${activeFolderId === null ? " bsplus-folder-active" : ""}`;
allItem.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" style="fill: currentcolor; opacity: 0.5; flex-shrink: 0;"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
<span class="bsplus-folder-name">All Messages</span>
`;
allItem.addEventListener("click", () => {
activeFolderId = null;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
setTimeout(() => {
applyFolderFilter();
applyBadges();
}, 100);
});
section.appendChild(allItem);
for (const folder of folders) {
const item = document.createElement("div");
item.className = `bsplus-folder-item${activeFolderId === folder.id ? " bsplus-folder-active" : ""}`;
item.dataset.folderId = folder.id;
item.draggable = true;
const dragHandle = document.createElement("div");
dragHandle.className = "bsplus-folder-drag";
dragHandle.innerHTML = DRAG_SVG;
item.appendChild(dragHandle);
const dot = document.createElement("div");
dot.className = "bsplus-folder-dot";
dot.style.background = folder.color;
item.appendChild(dot);
const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
item.appendChild(iconSpan);
const name = document.createElement("span");
name.className = "bsplus-folder-name";
name.textContent = folder.name;
item.appendChild(name);
const actions = document.createElement("div");
actions.className = "bsplus-folder-actions";
const editBtn = document.createElement("button");
editBtn.className = "bsplus-folder-action-btn";
editBtn.title = "Rename";
editBtn.innerHTML = EDIT_SVG;
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
showEditFolderInput(section!, folder);
});
actions.appendChild(editBtn);
const deleteBtn = document.createElement("button");
deleteBtn.className = "bsplus-folder-action-btn";
deleteBtn.title = "Delete";
deleteBtn.innerHTML = TRASH_SVG;
deleteBtn.addEventListener("click", (e) => {
e.stopPropagation();
showConfirmModal("Delete folder", `Remove "${folder.name}"? Messages won't be deleted.`, () => {
const folders = getFolders().filter((f) => f.id !== folder.id);
saveFolders(folders);
const assignments = getAssignments();
delete assignments[folder.id];
saveAssignments(assignments);
if (activeFolderId === folder.id) activeFolderId = null;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
});
});
actions.appendChild(deleteBtn);
item.appendChild(actions);
const count = document.createElement("span");
count.className = "bsplus-folder-count";
const c = getFolderMessageCount(folder.id);
count.textContent = c > 0 ? String(c) : "";
item.appendChild(count);
item.addEventListener("click", () => {
activeFolderId = folder.id;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
setTimeout(() => {
applyFolderFilter();
applyBadges();
}, 100);
});
item.addEventListener("dragstart", (e) => {
e.dataTransfer?.setData("text/plain", `reorder:${folder.id}`);
item.classList.add("bsplus-dragging");
});
item.addEventListener("dragend", () => {
item.classList.remove("bsplus-dragging");
document.querySelectorAll(".bsplus-folder-item").forEach((el) => el.classList.remove("bsplus-drag-over"));
});
item.addEventListener("dragover", (e) => {
e.preventDefault();
const data = e.dataTransfer?.getData("text/plain") || "";
if (data.startsWith("reorder:") && !data.includes(folder.id)) {
item.classList.add("bsplus-drag-over");
}
});
item.addEventListener("dragleave", () => {
item.classList.remove("bsplus-drag-over");
});
item.addEventListener("drop", (e) => {
e.preventDefault();
item.classList.remove("bsplus-drag-over");
const data = e.dataTransfer?.getData("text/plain") || "";
if (data.startsWith("reorder:")) {
const draggedId = data.replace("reorder:", "");
const folders = getFolders();
const draggedIdx = folders.findIndex((f) => f.id === draggedId);
const targetIdx = folders.findIndex((f) => f.id === folder.id);
if (draggedIdx >= 0 && targetIdx >= 0 && draggedIdx !== targetIdx) {
const [removed] = folders.splice(draggedIdx, 1);
folders.splice(targetIdx, 0, removed);
saveFolders(folders);
renderSidebarFolders();
}
}
});
section.appendChild(item);
}
section.addEventListener("dragover", (e) => {
e.preventDefault();
});
section.addEventListener("drop", (e) => {
e.preventDefault();
const data = e.dataTransfer?.getData("text/plain") || "";
if (data.startsWith("msg:")) {
const messageId = data.replace("msg:", "");
const folderId = (e.target as HTMLElement).closest("[data-folder-id]")?.getAttribute("data-folder-id");
if (messageId && folderId) {
assignMessageToFolder(messageId, folderId, true);
applyBadges();
applyFolderFilter();
renderSidebarFolders();
}
}
});
attachDragListeners();
};
const attachDragListeners = () => {
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
if (li.getAttribute("data-bsplus-drag") === "true") return;
li.setAttribute("data-bsplus-drag", "true");
li.draggable = true;
li.addEventListener("dragstart", (e) => {
const id = li.getAttribute("data-message");
if (id) {
e.dataTransfer?.setData("text/plain", `msg:${id}`);
li.classList.add("bsplus-msg-dragging");
}
});
li.addEventListener("dragend", () => {
li.classList.remove("bsplus-msg-dragging");
document.querySelectorAll(".bsplus-folder-item").forEach((el) => el.classList.remove("bsplus-drag-over"));
});
});
};
const showNewFolderInput = (container: Element, editFolder?: Folder) => {
const existing = container.querySelector(".bsplus-folder-input");
if (existing) existing.remove();
container.querySelector(".bsplus-folder-colors")?.remove();
let selectedColor = editFolder?.color ?? FOLDER_COLORS[Math.floor(Math.random() * FOLDER_COLORS.length)];
let selectedIcon = editFolder?.emoji ?? FOLDER_HEROICONS[Math.floor(Math.random() * FOLDER_HEROICONS.length)];
const row = document.createElement("div");
row.className = "bsplus-folder-input";
const input = document.createElement("input");
input.type = "text";
input.placeholder = editFolder ? "Rename folder\u2026" : "Folder name\u2026";
input.value = editFolder?.name ?? "";
input.maxLength = 30;
const iconBtn = document.createElement("button");
iconBtn.className = "bsplus-folder-icon-btn";
iconBtn.title = "Pick icon";
iconBtn.innerHTML = selectedIcon;
iconBtn.addEventListener("click", (e) => {
e.stopPropagation();
const picker = container.querySelector(".bsplus-icon-picker") as HTMLElement | null;
if (picker) {
picker.remove();
return;
}
showIconPicker(container, (iconSvg) => {
selectedIcon = iconSvg;
iconBtn.innerHTML = iconSvg;
});
});
const confirmBtn = document.createElement("button");
confirmBtn.className = "bsplus-folder-input-confirm";
confirmBtn.innerHTML = CHECK_SVG_WHITE;
const cancelBtn = document.createElement("button");
cancelBtn.className = "bsplus-folder-input-cancel";
cancelBtn.innerHTML = CLOSE_SVG;
row.appendChild(iconBtn);
row.appendChild(input);
row.appendChild(confirmBtn);
row.appendChild(cancelBtn);
const colorRow = document.createElement("div");
colorRow.className = "bsplus-folder-colors";
for (const color of FOLDER_COLORS) {
const swatch = document.createElement("button");
swatch.className = `bsplus-folder-color-opt${color === selectedColor ? " bsplus-color-selected" : ""}`;
swatch.style.background = color;
swatch.addEventListener("click", (e) => {
e.stopPropagation();
selectedColor = color;
colorRow.querySelectorAll(".bsplus-folder-color-opt").forEach((s) =>
s.classList.toggle("bsplus-color-selected", (s as HTMLElement).style.background === color),
);
});
colorRow.appendChild(swatch);
}
const confirm = () => {
const name = input.value.trim();
if (!name) return;
if (editFolder) {
const folders = getFolders().map((f) =>
f.id === editFolder.id ? { ...f, name, color: selectedColor, emoji: selectedIcon } : f,
);
saveFolders(folders);
} else {
const folder: Folder = { id: generateId(), name, color: selectedColor, emoji: selectedIcon };
saveFolders([...getFolders(), folder]);
}
applyBadges();
renderSidebarFolders();
};
confirmBtn.addEventListener("click", (e) => {
e.stopPropagation();
confirm();
});
cancelBtn.addEventListener("click", (e) => {
e.stopPropagation();
renderSidebarFolders();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") confirm();
if (e.key === "Escape") renderSidebarFolders();
});
container.appendChild(row);
container.appendChild(colorRow);
requestAnimationFrame(() => input.focus());
};
const showIconPicker = (container: Element, onSelect: (iconSvg: string) => void) => {
const existing = container.querySelector(".bsplus-icon-picker");
if (existing) existing.remove();
const picker = document.createElement("div");
picker.className = "bsplus-icon-picker";
for (const icon of FOLDER_HEROICONS) {
const btn = document.createElement("button");
btn.className = "bsplus-icon-opt";
btn.innerHTML = icon;
btn.addEventListener("click", (e) => {
e.stopPropagation();
onSelect(icon);
picker.remove();
});
picker.appendChild(btn);
}
container.appendChild(picker);
};
const showEditFolderInput = (container: Element, folder: Folder) => {
showNewFolderInput(container, folder);
};
const attachNativeSidebarListeners = () => {
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (!sidebar) return;
const ol = sidebar.querySelector("ol");
if (!ol) return;
ol.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest(".bsplus-folders-section")) return;
const li = target.closest("li");
if (li && ol.contains(li)) {
if (activeFolderId !== null) {
activeFolderId = null;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
}
}
});
};
const closeDropdown = () => {
if (openDropdown) {
openDropdown.remove();
openDropdown = null;
}
if (dropdownCloseHandler) {
document.removeEventListener("click", dropdownCloseHandler, true);
dropdownCloseHandler = null;
}
};
const showFolderDropdown = (anchor: HTMLElement, messageId: string) => {
closeDropdown();
const dropdown = document.createElement("div");
dropdown.className = "bsplus-folder-dropdown";
dropdown.dataset.msgId = messageId;
const folders = getFolders();
const currentFolderIds = getMessageFolderIds(messageId);
if (folders.length === 0) {
const empty = document.createElement("div");
empty.className = "bsplus-folder-dropdown-empty";
empty.textContent = "No folders yet";
dropdown.appendChild(empty);
} else {
for (const folder of folders) {
const isChecked = currentFolderIds.includes(folder.id);
const item = document.createElement("button");
item.className = `bsplus-folder-dropdown-item${isChecked ? " bsplus-checked" : ""}`;
item.dataset.folderId = folder.id;
const check = document.createElement("div");
check.className = "bsplus-folder-dropdown-check";
check.style.borderColor = isChecked ? folder.color : "";
check.style.background = isChecked ? folder.color : "";
check.innerHTML = CHECK_SVG_WHITE;
const dot = document.createElement("div");
dot.className = "bsplus-folder-dot";
dot.style.background = folder.color;
const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
const name = document.createElement("span");
name.textContent = folder.name;
item.appendChild(check);
item.appendChild(dot);
item.appendChild(iconSpan);
item.appendChild(name);
item.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
toggleMessageInFolder(messageId, folder.id);
const nowChecked = getMessageFolderIds(messageId).includes(folder.id);
item.classList.toggle("bsplus-checked", nowChecked);
check.style.borderColor = nowChecked ? folder.color : "";
check.style.background = nowChecked ? folder.color : "";
applyBadges();
applyFolderFilter();
renderSidebarFolders();
});
dropdown.appendChild(item);
}
}
anchor.appendChild(dropdown);
openDropdown = dropdown;
dropdownCloseHandler = (e: MouseEvent) => {
if (!dropdown.contains(e.target as Node) && !anchor.contains(e.target as Node)) {
closeDropdown();
}
};
setTimeout(() => {
document.addEventListener("click", dropdownCloseHandler!, true);
}, 0);
};
const injectFolderButton = (actionsBar: Element) => {
if (actionsBar.querySelector(".bsplus-folder-btn")) return;
const wrapper = document.createElement("div");
wrapper.className = "bsplus-folder-btn";
wrapper.style.position = "relative";
wrapper.style.display = "inline-block";
const btn = document.createElement("button");
const btnClasses = actionsBar.querySelector("button")?.className ?? "";
btn.className = btnClasses;
btn.title = "Add to folder";
btn.innerHTML = FOLDER_ICON_SVG;
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
const messageId = selectedMsg?.getAttribute("data-message");
if (!messageId) return;
showFolderDropdown(wrapper, messageId);
});
wrapper.appendChild(btn);
const moreMenu = actionsBar.querySelector("[class*='MenuButton__Menu___']");
if (moreMenu) {
actionsBar.insertBefore(wrapper, moreMenu);
} else {
actionsBar.appendChild(wrapper);
}
};
const showContextMenu = (e: MouseEvent, messageId: string) => {
e.preventDefault();
e.stopPropagation();
closeDropdown();
const existing = document.querySelector(".bsplus-context-menu");
if (existing) existing.remove();
const menu = document.createElement("div");
menu.className = "bsplus-context-menu";
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
const title = document.createElement("div");
title.className = "bsplus-context-title";
title.textContent = "Add to folder";
menu.appendChild(title);
const folders = getFolders();
const currentFolderIds = getMessageFolderIds(messageId);
if (folders.length === 0) {
const empty = document.createElement("div");
empty.className = "bsplus-context-empty";
empty.textContent = "No folders";
menu.appendChild(empty);
} else {
for (const folder of folders) {
const isChecked = currentFolderIds.includes(folder.id);
const item = document.createElement("button");
item.className = `bsplus-context-item${isChecked ? " bsplus-context-checked" : ""}`;
const dot = document.createElement("div");
dot.className = "bsplus-folder-dot";
dot.style.background = folder.color;
const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
const name = document.createElement("span");
name.textContent = folder.name;
item.appendChild(dot);
item.appendChild(iconSpan);
item.appendChild(name);
if (isChecked) {
const check = document.createElement("span");
check.className = "bsplus-context-checkmark";
check.textContent = "\u2713";
item.appendChild(check);
}
item.addEventListener("click", (e) => {
e.stopPropagation();
toggleMessageInFolder(messageId, folder.id);
applyBadges();
applyFolderFilter();
renderSidebarFolders();
menu.remove();
});
menu.appendChild(item);
}
}
document.body.appendChild(menu);
const closeMenu = (ev: MouseEvent) => {
if (!menu.contains(ev.target as Node)) {
menu.remove();
document.removeEventListener("click", closeMenu);
}
};
setTimeout(() => document.addEventListener("click", closeMenu), 0);
};
const applyBadges = () => {
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
if (!shouldShowBadgesInList()) {
for (const li of messageItems) {
const subject = li.querySelector("[class*='MessageList__subject___']");
if (subject && (subject.querySelector(".bsplus-msg-badges") || subject.querySelector(".bsplus-subject-text"))) {
restoreSubjectPlain(subject);
} else {
li.querySelector(".bsplus-msg-badges")?.remove();
}
}
return;
}
const folders = getFolders();
const assignments = getAssignments();
for (const li of messageItems) {
const msgId = li.getAttribute("data-message");
if (!msgId) continue;
let badgeContainer = li.querySelector(".bsplus-msg-badges") as HTMLElement | null;
const folderIds: string[] = [];
for (const [fId, mIds] of Object.entries(assignments)) {
if (mIds.includes(msgId)) folderIds.push(fId);
}
if (folderIds.length === 0) {
badgeContainer?.remove();
continue;
}
if (!badgeContainer) {
badgeContainer = document.createElement("div");
badgeContainer.className = "bsplus-msg-badges";
const subject = li.querySelector("[class*='MessageList__subject___']");
if (subject) {
if (!subject.querySelector(".bsplus-subject-text")) {
const textWrap = document.createElement("span");
textWrap.className = "bsplus-subject-text";
textWrap.textContent = subject.textContent;
subject.textContent = "";
subject.appendChild(textWrap);
}
subject.appendChild(badgeContainer);
} else {
li.appendChild(badgeContainer);
}
}
badgeContainer.innerHTML = "";
for (const fId of folderIds) {
const folder = folders.find((f) => f.id === fId);
if (!folder) continue;
const badge = document.createElement("span");
badge.className = "bsplus-msg-badge";
badge.style.background = folder.color;
badge.innerHTML = `${folder.emoji ? `<span style="display:inline-flex;vertical-align:middle;margin-right:2px">${folder.emoji}</span>` : ""}${folder.name}`;
badge.title = `Filter by "${folder.name}"`;
badge.addEventListener("click", (e) => {
e.stopPropagation();
activeFolderId = folder.id;
applyFolderFilter();
applyBadges();
renderSidebarFolders();
});
badgeContainer.appendChild(badge);
}
}
};
const applyFolderFilter = () => {
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
const moreBtn = document.querySelector("[class*='MessageList__MessageList___'] ol > button");
if (activeFolderId === null) {
if (api.settings.hideFolderedMessagesInAll) {
for (const li of messageItems) {
const msgId = li.getAttribute("data-message");
if (msgId && isMessageInAnyCustomFolder(msgId)) {
li.classList.add("bsplus-folder-hidden");
} else {
li.classList.remove("bsplus-folder-hidden");
}
}
} else {
for (const li of messageItems) {
li.classList.remove("bsplus-folder-hidden");
}
}
if (moreBtn) (moreBtn as HTMLElement).classList.remove("bsplus-folder-hidden");
return;
}
const folderMsgIds = getAssignments()[activeFolderId] ?? [];
for (const li of messageItems) {
const msgId = li.getAttribute("data-message");
if (msgId && folderMsgIds.includes(msgId)) {
li.classList.remove("bsplus-folder-hidden");
} else {
li.classList.add("bsplus-folder-hidden");
}
}
if (moreBtn) (moreBtn as HTMLElement).classList.add("bsplus-folder-hidden");
};
const setupMessageListObserver = () => {
const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol");
if (!messageList || messageListObserver) return;
messageListObserver = new MutationObserver(() => {
applyBadges();
applyFolderFilter();
attachDragListeners();
attachContextMenuListeners();
});
messageListObserver.observe(messageList, { childList: true, subtree: false });
};
const attachContextMenuListeners = () => {
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
if (li.getAttribute("data-bsplus-ctx") === "true") return;
li.setAttribute("data-bsplus-ctx", "true");
li.addEventListener("contextmenu", (e) => {
const msgId = li.getAttribute("data-message");
if (msgId) {
showContextMenu(e, msgId);
}
});
});
};
const setupActionsObserver = () => {
if (actionsObserver) return;
const target = document.querySelector("[class*='Viewer__Viewer___']") ?? document.querySelector("div.messages");
if (!target) return;
actionsObserver = new MutationObserver(() => {
const actionsBar = document.querySelector("[class*='Message__actions___']");
if (actionsBar && !actionsBar.querySelector(".bsplus-folder-btn")) {
injectFolderButton(actionsBar);
}
});
actionsObserver.observe(target, { childList: true, subtree: true });
};
const handleMessagesPage = async () => {
await waitForElm("[class*='Viewer__sidebar___'] ol", true, 50, 100);
renderSidebarFolders();
attachNativeSidebarListeners();
await waitForElm("[class*='MessageList__MessageList___'] ol", true, 50, 100);
applyBadges();
applyFolderFilter();
setupMessageListObserver();
setupActionsObserver();
attachDragListeners();
attachContextMenuListeners();
const actionsBar = document.querySelector("[class*='Message__actions___']");
if (actionsBar) injectFolderButton(actionsBar);
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (sidebar && !sidebarObserver) {
sidebarObserver = new MutationObserver(() => {
const ol = sidebar.querySelector("ol");
if (ol && !ol.querySelector(".bsplus-folders-section")) {
renderSidebarFolders();
attachNativeSidebarListeners();
}
});
sidebarObserver.observe(sidebar, { childList: true, subtree: true });
}
};
const mountUnsub = api.seqta.onMount("div.messages", handleMessagesPage);
unregisters.push(mountUnsub);
unregisters.push(
api.settings.onChange("showTagsInAllMessages", () => {
applyBadges();
}),
);
unregisters.push(
api.settings.onChange("hideFolderedMessagesInAll", () => {
applyFolderFilter();
}),
);
return () => {
for (const u of unregisters) u.unregister();
messageListObserver?.disconnect();
sidebarObserver?.disconnect();
actionsObserver?.disconnect();
closeDropdown();
styleEl.remove();
document.querySelectorAll(".bsplus-folders-section").forEach((el) => el.remove());
document.querySelectorAll(".bsplus-folder-btn").forEach((el) => el.remove());
document.querySelectorAll(".bsplus-msg-badges").forEach((el) => el.remove());
document.querySelectorAll(".bsplus-context-menu").forEach((el) => el.remove());
document.querySelectorAll("[class*='MessageList__subject___']").forEach((subject) => {
if (subject.querySelector(".bsplus-subject-text")) {
restoreSubjectPlain(subject);
}
});
document.querySelectorAll(".bsplus-folder-hidden").forEach((el) =>
el.classList.remove("bsplus-folder-hidden"),
);
document.querySelectorAll(".bsplus-modal-overlay").forEach((el) => el.remove());
};
},
};
export default messageFoldersPlugin;
@@ -0,0 +1,731 @@
/* ── Sidebar folder section ── */
.bsplus-folders-section {
border-top: 1px solid var(--background-secondary, rgba(128, 128, 128, 0.2));
margin-top: 4px;
padding-top: 4px;
transition: opacity .2s;
}
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-item,
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-input,
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-colors,
.bsplus-folders-section.bsplus-section-folded .bsplus-emoji-picker,
.bsplus-folders-section.bsplus-section-folded .bsplus-all-msgs {
display: none !important;
}
.bsplus-folders-header {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px 2px;
user-select: none;
}
.bsplus-folders-header span {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-primary, #666);
opacity: 0.5;
flex: 1;
}
.bsplus-folders-collapse {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 18px !important;
height: 18px !important;
min-width: 0 !important;
border: none !important;
background: transparent !important;
opacity: 0.4;
cursor: pointer;
border-radius: 4px !important;
padding: 0 !important;
margin: 0 !important;
transition: all .2s;
}
.bsplus-folders-collapse:hover {
opacity: 0.8;
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
}
.bsplus-folders-collapse.bsplus-folded svg {
transform: rotate(-90deg);
}
.bsplus-folders-add-btn {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 20px !important;
height: 20px !important;
min-width: 0 !important;
border: none !important;
background: transparent !important;
opacity: 0.5;
cursor: pointer;
border-radius: 4px !important;
padding: 0 !important;
margin: 0 !important;
transition: all 0.2s ease;
text-align: center !important;
}
.bsplus-folders-add-btn:hover {
opacity: 1;
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
}
/* ── Folder list items ── */
.bsplus-folder-item {
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
transition: background 0.15s ease, opacity 0.2s;
position: relative;
gap: 6px;
user-select: none;
}
.bsplus-folder-item.bsplus-dragging {
opacity: 0.4;
}
.bsplus-folder-item.bsplus-drag-over {
background: var(--better-main, #007bff22) !important;
border-radius: 4px;
}
.bsplus-folder-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
}
.bsplus-folder-item.bsplus-folder-active {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.12));
}
.bsplus-folder-item.bsplus-folder-active::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--better-main, #007bff);
border-radius: 0 2px 2px 0;
}
.bsplus-folder-drag {
display: flex;
align-items: center;
opacity: 0;
transition: opacity .15s;
margin-right: -4px;
}
.bsplus-folder-item:hover .bsplus-folder-drag {
opacity: 0.5;
}
.bsplus-folder-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.bsplus-folder-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--text-primary, #333);
}
.bsplus-folder-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
}
.bsplus-folder-name {
font-size: 13px;
color: var(--text-primary, #333);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bsplus-folder-count {
font-size: 11px;
color: var(--text-primary, #999);
opacity: 0.5;
flex-shrink: 0;
min-width: 16px;
text-align: right;
}
.bsplus-folder-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.bsplus-folder-item:hover .bsplus-folder-actions {
opacity: 1;
}
.bsplus-folder-action-btn {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 20px !important;
height: 20px !important;
min-width: 0 !important;
border: none !important;
background: transparent !important;
opacity: 0.6;
cursor: pointer;
border-radius: 4px !important;
padding: 0 !important;
margin: 0 !important;
transition: all 0.15s ease;
}
.bsplus-folder-action-btn:hover {
opacity: 1;
background: var(--background-secondary, rgba(128, 128, 128, 0.15)) !important;
}
/* ── Inline folder name input ── */
.bsplus-folder-input {
display: flex;
align-items: center;
padding: 4px 12px;
gap: 6px;
}
.bsplus-folder-input input {
flex: 1;
min-width: 0;
padding: 4px 8px;
font-size: 13px;
border: 1px solid var(--background-secondary, #ccc);
border-radius: 6px;
background: var(--background-secondary, #f5f5f5);
color: var(--text-primary, #333);
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.bsplus-folder-input input:focus {
border-color: var(--better-main, #007bff);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2);
}
.bsplus-folder-icon-btn {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 28px !important;
height: 28px !important;
min-width: 0 !important;
border: 1px solid var(--background-secondary, #ccc) !important;
border-radius: 6px !important;
background: var(--background-secondary, #f5f5f5) !important;
cursor: pointer;
padding: 0 !important;
margin: 0 !important;
transition: all .15s;
color: var(--text-primary, #333);
}
.bsplus-folder-icon-btn:hover {
transform: scale(1.1);
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.1)) !important;
}
.bsplus-folder-icon-btn svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
}
.bsplus-folder-input-confirm,
.bsplus-folder-input-cancel {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 24px !important;
height: 24px !important;
min-width: 0 !important;
border: none !important;
border-radius: 4px !important;
cursor: pointer;
padding: 0 !important;
margin: 0 !important;
transition: all 0.15s ease;
}
.bsplus-folder-input-confirm {
background: var(--better-main, #007bff) !important;
}
.bsplus-folder-input-confirm:hover {
transform: scale(1.1);
}
.bsplus-folder-input-cancel {
background: transparent !important;
opacity: 0.6;
}
.bsplus-folder-input-cancel:hover {
opacity: 1;
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
}
/* ── Icon picker ── */
.bsplus-icon-picker {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
padding: 4px 12px 6px;
max-width: 140px;
}
.bsplus-icon-opt {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 28px !important;
height: 28px !important;
min-width: 0 !important;
border: none !important;
border-radius: 6px !important;
background: transparent !important;
cursor: pointer;
padding: 0 !important;
transition: all .15s;
color: var(--text-primary, #333);
}
.bsplus-icon-opt svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
}
.bsplus-icon-opt:hover {
transform: scale(1.3);
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.1)) !important;
}
/* ── Color picker row ── */
.bsplus-folder-colors {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
padding: 4px 12px 6px;
max-width: 120px;
}
.bsplus-folder-color-opt {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.2s ease,
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1);
padding: 0;
background: none;
box-sizing: border-box;
}
.bsplus-folder-color-opt:hover {
transform: scale(1.25);
box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.15);
}
.bsplus-folder-color-opt.bsplus-color-selected {
border-color: var(--text-primary, #333);
transform: scale(1.15);
box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.2);
}
.bsplus-folder-color-opt.bsplus-color-selected:hover {
transform: scale(1.25);
}
/* ── "Add to folder" button in message actions bar ── */
.bsplus-folder-btn {
position: relative;
}
.bsplus-folder-btn svg {
fill: currentColor;
}
/* ── Folder dropdown ── */
.bsplus-folder-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 180px;
background: var(--background-primary, #fff);
border: 1px solid var(--background-secondary, #e0e0e0);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 1000;
overflow: hidden;
animation: bsplus-dropdown-in 0.15s ease-out;
}
@keyframes bsplus-dropdown-in {
from {
opacity: 0;
transform: translateY(-4px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.bsplus-folder-dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.1s ease;
border: none;
background: transparent;
width: 100%;
text-align: left;
color: var(--text-primary, #333);
font-size: 13px;
}
.bsplus-folder-dropdown-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
}
.bsplus-folder-dropdown-check {
width: 16px;
height: 16px;
border: 2px solid var(--background-secondary, #ccc);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s ease;
}
.bsplus-folder-dropdown-item.bsplus-checked .bsplus-folder-dropdown-check {
background: var(--better-main, #007bff);
border-color: var(--better-main, #007bff);
}
.bsplus-folder-dropdown-check svg {
width: 10px;
height: 10px;
color: white;
opacity: 0;
transition: opacity 0.1s ease;
}
.bsplus-folder-dropdown-item.bsplus-checked .bsplus-folder-dropdown-check svg {
opacity: 1;
}
.bsplus-folder-dropdown-empty {
padding: 12px;
text-align: center;
font-size: 12px;
color: var(--text-primary, #999);
opacity: 0.5;
}
/* ── Context menu ── */
.bsplus-context-menu {
position: fixed;
min-width: 160px;
background: var(--background-primary, #fff) !important;
border: 1px solid var(--background-secondary, #e0e0e0) !important;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 2147483646;
overflow: hidden;
animation: bsplus-dropdown-in 0.12s ease-out;
padding: 4px 0;
}
.bsplus-context-title {
padding: 6px 12px 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-primary, #999) !important;
opacity: 0.5;
user-select: none;
}
.bsplus-context-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08)) !important;
}
.bsplus-context-item span {
flex: 1;
}
.bsplus-context-checkmark {
color: var(--better-main, #007bff) !important;
font-weight: bold;
flex: 0 !important;
}
.bsplus-context-item {
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
gap: 8px;
padding: 7px 12px !important;
font-size: 13px;
cursor: pointer;
border: none !important;
background: transparent !important;
width: 100%;
text-align: left !important;
color: var(--text-primary, #333) !important;
transition: background .1s;
font-family: inherit;
}
.bsplus-context-item .bsplus-folder-icon {
color: var(--text-primary, #333) !important;
width: 16px;
height: 16px;
}
.bsplus-context-item .bsplus-folder-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
}
.bsplus-context-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
}
.bsplus-context-item span {
flex: 1;
}
.bsplus-context-checkmark {
color: var(--better-main, #007bff) !important;
font-weight: bold;
flex: 0 !important;
}
.bsplus-context-empty {
padding: 12px;
text-align: center;
font-size: 12px;
color: var(--text-primary, #999);
opacity: 0.5;
}
/* ── Drag feedback ── */
.bsplus-msg-dragging {
opacity: 0.4;
}
[class*='MessageList__MessageList___'] ol > li[data-message] {
transition: opacity .15s;
}
/* ── Layout fixes ── */
[class*='MessageList__primary___'] {
flex: 1 1 0% !important;
min-width: 0 !important;
overflow: hidden !important;
}
[class*='MessageList__subject___'] {
display: flex !important;
align-items: center;
gap: 6px;
min-width: 0 !important;
overflow: hidden !important;
}
.bsplus-subject-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1 1 auto;
}
[class*='MessageList__secondary___'] {
flex: 0 0 auto !important;
width: auto !important;
min-width: 0 !important;
max-width: 200px !important;
}
[class*='MessageList__flags___'] {
width: 24px !important;
min-width: 0 !important;
flex-shrink: 0 !important;
}
/* ── Message list folder badges ── */
.bsplus-msg-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
margin-left: auto;
}
.bsplus-msg-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
border-radius: 8px;
font-size: 10px;
font-weight: 500;
line-height: 1.4;
color: white;
white-space: nowrap;
cursor: pointer;
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.bsplus-msg-badge:hover {
opacity: 0.85;
transform: scale(1.05);
}
/* ── Folder filtering ── */
.bsplus-folder-hidden {
display: none !important;
}
/* ── Delete confirmation modal ── */
@keyframes bsplus-modal-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes bsplus-modal-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.bsplus-modal-overlay {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
animation: bsplus-modal-overlay-in 0.2s ease-out forwards;
}
.bsplus-modal {
padding: 1rem 1.5rem;
margin: 0 1rem;
min-width: 16rem;
max-width: 22rem;
width: 100%;
box-sizing: border-box;
background: var(--background-primary, #fff);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid var(--background-secondary, #e0e0e0);
animation: bsplus-modal-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.bsplus-modal h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #333);
}
.bsplus-modal p {
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--text-primary, #666);
opacity: 0.8;
}
.bsplus-modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.bsplus-modal-actions button {
padding: 0.4rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.bsplus-modal-btn-cancel {
background: transparent;
border: 1px solid var(--background-secondary, #ccc);
color: var(--text-primary, #333);
}
.bsplus-modal-btn-cancel:hover {
background: var(--background-secondary, rgba(128, 128, 128, 0.1));
}
.bsplus-modal-btn-danger {
background: #e53e3e;
border: none;
color: white;
}
.bsplus-modal-btn-danger:hover {
background: #c53030;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35);
}
@@ -1,5 +1,10 @@
<script lang="ts"> <script lang="ts">
import localforage from 'localforage' import localforage from 'localforage'
import {
isUseCloudPfpEnabled,
notifyProfilePictureChanged,
syncLocalProfilePictureToCloud,
} from '@/seqta/utils/cloudPfpSync'
let value = $state<string | undefined>(undefined) let value = $state<string | undefined>(undefined)
let fileInput = $state<HTMLInputElement | undefined>(undefined) let fileInput = $state<HTMLInputElement | undefined>(undefined)
let dragging = $state(false) let dragging = $state(false)
@@ -25,6 +30,14 @@
load() load()
async function afterProfilePictureChange() {
window.dispatchEvent(new Event('profile-picture-updated'))
if (await isUseCloudPfpEnabled()) {
await syncLocalProfilePictureToCloud()
}
await notifyProfilePictureChanged()
}
function triggerSelect() { function triggerSelect() {
fileInput?.click() fileInput?.click()
} }
@@ -43,7 +56,7 @@
const newBlobUrl = URL.createObjectURL(file) const newBlobUrl = URL.createObjectURL(file)
value = newBlobUrl value = newBlobUrl
blobUrl = newBlobUrl blobUrl = newBlobUrl
window.dispatchEvent(new Event('profile-picture-updated')) await afterProfilePictureChange()
} }
function onFileChange() { function onFileChange() {
@@ -63,7 +76,7 @@
} }
value = undefined value = undefined
await store.removeItem('profile-picture') await store.removeItem('profile-picture')
window.dispatchEvent(new Event('profile-picture-updated')) await afterProfilePictureChange()
} }
</script> </script>
+20 -2
View File
@@ -6,6 +6,7 @@ import {
} from "@/plugins/core/settingsHelpers"; } from "@/plugins/core/settingsHelpers";
import ProfilePictureSetting from "./ProfilePictureSetting.svelte"; import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import browser from "webextension-polyfill";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import localforage from "localforage"; import localforage from "localforage";
@@ -67,7 +68,8 @@ const profilePicturePlugin: Plugin<typeof settings> = {
if (useCloud && pfpUrl) { if (useCloud && pfpUrl) {
img = document.createElement("img"); img = document.createElement("img");
img.className = "userInfoImg"; img.className = "userInfoImg";
img.src = pfpUrl; const base = pfpUrl.split("?")[0]!;
img.src = `${base}?v=${Date.now()}`;
if (svg) svg.style.display = "none"; if (svg) svg.style.display = "none";
container.appendChild(img); container.appendChild(img);
return; return;
@@ -93,11 +95,26 @@ const profilePicturePlugin: Plugin<typeof settings> = {
}; };
window.addEventListener("profile-picture-updated", onLocalPictureUpdated); window.addEventListener("profile-picture-updated", onLocalPictureUpdated);
const onStorageRevision = (
changes: Record<string, browser.Storage.StorageChange>,
areaName: string,
) => {
if (areaName === "local" && changes.profile_picture_revision) {
void applyProfileImage();
}
};
browser.storage.onChanged.addListener(onStorageRevision);
const cloudUnsub = cloudAuth.subscribe(() => { const cloudUnsub = cloudAuth.subscribe(() => {
void applyProfileImage(); void applyProfileImage();
}); });
const useCloudUnreg = api.settings.onChange("useCloudPfp", () => { const useCloudUnreg = api.settings.onChange("useCloudPfp", (enabled: boolean) => {
if (enabled) {
void import("@/seqta/utils/cloudPfpSync").then(({ syncLocalProfilePictureToCloud }) =>
syncLocalProfilePictureToCloud(),
);
}
void applyProfileImage(); void applyProfileImage();
}); });
@@ -105,6 +122,7 @@ const profilePicturePlugin: Plugin<typeof settings> = {
useCloudUnreg.unregister(); useCloudUnreg.unregister();
cloudUnsub(); cloudUnsub();
window.removeEventListener("profile-picture-updated", onLocalPictureUpdated); window.removeEventListener("profile-picture-updated", onLocalPictureUpdated);
browser.storage.onChanged.removeListener(onStorageRevision);
if (img) img.remove(); if (img) img.remove();
if (svg) svg.style.display = ""; if (svg) svg.style.display = "";
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl); if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
+1
View File
@@ -10,6 +10,7 @@ const themesPlugin: Plugin = {
run: async (_) => { run: async (_) => {
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
await themeManager.prepareThemeAfterCloudSync();
await themeManager.initialize(); await themeManager.initialize();
}, },
}; };
+108 -4
View File
@@ -6,15 +6,26 @@ import {
type LoadedCustomTheme, type LoadedCustomTheme,
shouldForceThemeAppearance, shouldForceThemeAppearance,
} from "@/types/CustomThemes"; } from "@/types/CustomThemes";
import { BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY } from "@/seqta/utils/cloudSettingsSync";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import debounce from "@/seqta/utils/debounce"; import debounce from "@/seqta/utils/debounce";
import { themeUpdates } from "@/interface/hooks/ThemeUpdates"; import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { getApiBase } from "@/seqta/utils/DevApiBase";
import { updateAllColors } from "@/seqta/ui/colors/Manager"; import { updateAllColors } from "@/seqta/ui/colors/Manager";
import { import {
clearCustomThemeAdaptiveCssVariables, clearCustomThemeAdaptiveCssVariables,
setCustomThemeAdaptiveCssVariables, setCustomThemeAdaptiveCssVariables,
} from "@/seqta/ui/colors/customThemeAdaptiveBindings"; } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
import {
clearThemeRuntime,
injectThemeDom,
runThemeScript,
type ThemeDomSpec,
type ThemeScriptSpec,
validateThemeDom,
validateThemeScript,
} from "./theme-runtime";
type ThemeContent = { type ThemeContent = {
id: string; id: string;
@@ -25,8 +36,12 @@ type ThemeContent = {
CanChangeColour?: boolean; CanChangeColour?: boolean;
CustomCSS?: string; CustomCSS?: string;
hideThemeName?: boolean; hideThemeName?: boolean;
forceTheme?: boolean;
forceDark?: boolean; forceDark?: boolean;
adaptiveCssVariables?: string[];
images?: { id: string; variableName: string; data: string }[]; // data: base64 images?: { id: string; variableName: string; data: string }[]; // data: base64
themeScript?: ThemeScriptSpec;
themeDom?: ThemeDomSpec;
}; };
export type InstallThemeMeta = { export type InstallThemeMeta = {
@@ -35,7 +50,7 @@ export type InstallThemeMeta = {
serverUpdatedAtSec?: number; serverUpdatedAtSec?: number;
forceTheme?: boolean; forceTheme?: boolean;
adaptiveCssVariables?: string[]; adaptiveCssVariables?: string[];
images: { id: string; variableName: string; data: string }[]; // data: base64 images?: { id: string; variableName: string; data: string }[]; // data: base64
}; };
export class ThemeManager { export class ThemeManager {
@@ -49,6 +64,7 @@ export class ThemeManager {
private imageUrlCache: Map<string, string> = new Map(); private imageUrlCache: Map<string, string> = new Map();
private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 }; private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 };
private storeUpdateCheckRunning = false; private storeUpdateCheckRunning = false;
private headObserver: MutationObserver | null = null;
private constructor() { private constructor() {
console.debug("[ThemeManager] Initializing..."); console.debug("[ThemeManager] Initializing...");
@@ -164,6 +180,33 @@ export class ThemeManager {
} }
} }
/**
* After cloud restore, IndexedDB/theme storage is only reachable from page context (not MV3 SW).
* Background sets BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY; we fetch the store JSON here before setTheme().
* The resolved id matches cloud sync **`themeId` / `selectedTheme`**: it may be a standard theme uuid or a
* flavour (slave) variant id **`downloadAndInstallStoreTheme`** is the same code path as the theme store installer.
*/
public async prepareThemeAfterCloudSync(): Promise<void> {
try {
const snap = await browser.storage.local.get(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
const pending = snap[BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY];
if (pending === undefined) return;
await browser.storage.local.remove(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
if (typeof pending !== "string") return;
const id = pending.trim();
if (!id) return;
const existing = (await localforage.getItem(id)) as CustomTheme | null;
if (existing) return;
await this.downloadAndInstallStoreTheme({ id, name: id });
} catch (e) {
console.warn("[ThemeManager] prepareThemeAfterCloudSync:", e);
}
}
/** /**
* Initialize the theme system and restore previous state * Initialize the theme system and restore previous state
*/ */
@@ -276,6 +319,13 @@ export class ThemeManager {
private async applyTheme(theme: CustomTheme): Promise<void> { private async applyTheme(theme: CustomTheme): Promise<void> {
console.debug("[ThemeManager] Applying theme:", theme.name); console.debug("[ThemeManager] Applying theme:", theme.name);
try { try {
// Run the theme script BEFORE injecting CustomCSS so any state the
// script publishes (e.g. `data-city-state` and `--city-sky-color` for
// Noir City) is already on <html> when the new CSS rules paint.
// Otherwise the CSS lands with var() unresolved and the page flashes
// its previous state before snapping to the right colour.
runThemeScript(theme.themeScript);
// Apply custom CSS // Apply custom CSS
if (theme.CustomCSS) { if (theme.CustomCSS) {
console.debug("[ThemeManager] Applying custom CSS"); console.debug("[ThemeManager] Applying custom CSS");
@@ -317,6 +367,8 @@ export class ThemeManager {
} }
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []); setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
injectThemeDom(theme.themeDom);
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error applying theme:", error); console.error("[ThemeManager] Error applying theme:", error);
} }
@@ -348,6 +400,13 @@ export class ThemeManager {
): Promise<void> { ): Promise<void> {
console.debug("[ThemeManager] Removing theme:", theme.name); console.debug("[ThemeManager] Removing theme:", theme.name);
try { try {
clearThemeRuntime();
// Disconnect the head observer BEFORE removing the style element,
// otherwise the removal fires the observer and it would no-op only
// because the style is already gone — wasted work, but harmless.
this.disconnectStyleObserver();
// Remove custom CSS // Remove custom CSS
if (this.styleElement) { if (this.styleElement) {
console.debug("[ThemeManager] Removing custom CSS"); console.debug("[ThemeManager] Removing custom CSS");
@@ -417,7 +476,13 @@ export class ThemeManager {
} }
/** /**
* Apply custom CSS to the document * Apply custom CSS to the document. The `<style>` element is always
* re-appended to the end of `<head>` so it wins specificity ties
* against any styles SEQTA's late-loading injected.scss adds in dev
* mode (where `import("@/css/injected.scss")` is fire-and-forget and
* can resolve after the theme has already been applied). The head
* observer below keeps us at the end if anything else gets appended
* later (Vite HMR, another script-injected stylesheet, etc.).
*/ */
private applyCustomCSS(css: string): void { private applyCustomCSS(css: string): void {
console.debug("[ThemeManager] Applying custom CSS"); console.debug("[ThemeManager] Applying custom CSS");
@@ -425,14 +490,39 @@ export class ThemeManager {
if (!this.styleElement) { if (!this.styleElement) {
this.styleElement = document.createElement("style"); this.styleElement = document.createElement("style");
this.styleElement.id = "custom-theme"; this.styleElement.id = "custom-theme";
document.head.appendChild(this.styleElement);
} }
this.styleElement.textContent = css; this.styleElement.textContent = css;
document.head.appendChild(this.styleElement);
this.ensureStyleStaysLast();
} catch (error) { } catch (error) {
console.error("[ThemeManager] Error applying custom CSS:", error); console.error("[ThemeManager] Error applying custom CSS:", error);
} }
} }
/**
* Watch `<head>` for any child-list changes and re-append the theme
* style element if anything has been added after it. Idempotent: the
* observer's own re-append fires the callback again, but the early
* `lastElementChild === style` check short-circuits the second pass.
*/
private ensureStyleStaysLast(): void {
if (this.headObserver) return;
this.headObserver = new MutationObserver(() => {
const style = this.styleElement;
if (!style || !document.head.contains(style)) return;
if (document.head.lastElementChild === style) return;
document.head.appendChild(style);
});
this.headObserver.observe(document.head, { childList: true });
}
private disconnectStyleObserver(): void {
if (this.headObserver) {
this.headObserver.disconnect();
this.headObserver = null;
}
}
/** /**
* Get list of available themes * Get list of available themes
*/ */
@@ -515,7 +605,10 @@ export class ThemeManager {
} }
} }
private readonly THEME_API_BASE = 'https://betterseqta.org/api'; /** Use a getter so dev-mode session-only base URL overrides take effect immediately. */
private get THEME_API_BASE(): string {
return `${getApiBase()}/api`;
}
private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes'; private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes';
/** /**
@@ -603,6 +696,15 @@ export class ThemeManager {
throw new Error("Theme is missing required fields (id or name)"); throw new Error("Theme is missing required fields (id or name)");
} }
// Validate optional runtime hooks. Reject the install if either is
// malformed so a tampered theme cannot smuggle in unsafe values.
if (!validateThemeScript(themeData.themeScript)) {
throw new Error("Theme has invalid themeScript");
}
if (!validateThemeDom(themeData.themeDom)) {
throw new Error("Theme has invalid themeDom");
}
const fromStore = meta?.fromStore ?? false; const fromStore = meta?.fromStore ?? false;
const serverUpdatedAtSec = meta?.serverUpdatedAtSec; const serverUpdatedAtSec = meta?.serverUpdatedAtSec;
@@ -667,6 +769,8 @@ export class ThemeManager {
? true ? true
: undefined, : undefined,
adaptiveCssVariables: themeData.adaptiveCssVariables, adaptiveCssVariables: themeData.adaptiveCssVariables,
themeScript: themeData.themeScript,
themeDom: themeData.themeDom,
}; };
await this.saveTheme(theme); await this.saveTheme(theme);

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