Compare commits

..

156 Commits

Author SHA1 Message Date
Seth Burkart f75d2566dd Merge pull request #448 from StroepWafel/Releases-Updater-forsideload
release build and other CI
2026-06-06 19:29:04 +10:00
StroepWafel 3f1910f610 release build and other CI 2026-06-05 16:58:11 +09:30
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
SethBurkart123 01eeb18638 perf: throttle theme update check to daily and remove redundant cloud poll on page load 2026-04-20 15:39:37 +10:00
SethBurkart123 3702443ece chore: bump version to 3.6.1 2026-04-20 15:33:04 +10:00
SethBurkart123 87ba75ff41 feat: simplify startup popups and convert engage announcement to toast
Remove auto-showing privacy statement and BS Cloud announcement from
startup queue. Convert SEQTA Engage announcement from a blocking modal
to a subtle bottom-right dismissable toast.
2026-04-20 14:59:24 +10:00
SethBurkart123 f9406fb469 feat: redesign Cloud settings UI and switch to OAuth redirect login
- Move Cloud section inline with other settings, remove dedicated header bar
- Replace in-extension login form with browser redirect to accounts.betterseqta.org
- Background script intercepts OAuth callback URL to capture tokens
- Add animated CloudPanel overlay (same pattern as ColourPicker)
- Hide cloud sync details and profile picture setting when not signed in
- Simplify CloudSettingsSync UI, reduce text verbosity
- Fix settings download to merge keys instead of clear+set
- Add legacy-to-plugin settings migration for cloud sync
- Shorten profile picture and default page descriptions
- Make DisclaimerModal title/message dynamic
- Update CloudHeader button styling to match other buttons
2026-04-20 13:42:49 +10:00
AdenMGB 690792fd62 chore: update chaneglog 2026-04-17 15:59:04 +09:30
AdenMGB f6ac112329 fix: fix the timetable edit plugin 2026-04-17 15:55:32 +09:30
AdenMGB ec68cec0ca feat: add smooth animation to notifications opening like settings 2026-04-17 15:51:25 +09:30
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
AdenMGB 44a029057a feat: auto download settings upon login 2026-04-13 19:42:11 +09:30
AdenMGB 249d1c1b4a feat: auto sync popoup 2026-04-13 19:39:57 +09:30
AdenMGB 6748b15024 feat: tweak engage popup 2026-04-13 19:33:28 +09:30
Aden Lindsay 8b26d07865 Merge pull request #426 from StroepWafel/main
update video (and edit notes)
2026-04-13 19:12:35 +09:30
Aden Lindsay 55d96a5e8f Update theme name in WhatsNewPopup 2026-04-13 19:12:18 +09:30
StroepWafel 133a5197aa Update update-video.webm 2026-04-13 19:10:32 +09:30
StroepWafel c0fa1576f3 Update update-video.webm 2026-04-12 23:28:30 +09:30
AdenMGB 48fbcde6ae fix: fix last two engage bugs 2026-04-12 20:14:52 +09:30
AdenMGB 89f50f774f feat: image full screen overlay for popoups 2026-04-12 20:06:03 +09:30
AdenMGB 1d9b8f3747 feat: queue popups and new engage popup 2026-04-12 19:54:43 +09:30
StroepWafel 0a5359df72 update video (and edit notes) 2026-04-12 19:19:19 +09:30
AdenMGB 2e9a643a8c fix: fix engage not always applying 2026-04-10 21:21:19 +09:30
AdenMGB 39d0b60024 feat: engage homepag 2026-04-08 11:27:38 +09:30
AdenMGB c7a6bf051c chore: bump ver and changelog 2026-04-08 08:49:37 +09:30
AdenMGB ea4a2c1ff0 feat: auto sync for cloud and fix some firefox weirdness 2026-04-08 08:29:25 +09:30
AdenMGB 71b7c9eb64 tweak: tweak cloud UI a lil bit 2026-04-08 07:40:20 +09:30
Aden Lindsay 0cac3022f5 Merge pull request #425 from StroepWafel/cloud-settings-backup
Cloud settings backup
2026-04-08 07:25:09 +09:30
StroepWafel 8b16a21d48 tweaks and fixes to UI 2026-04-07 22:39:09 +09:30
Aden Lindsay 2085ebe189 Upgrade project to Vite 8 + some cleanup (#424)
* Upgrade project to Vite 8

* Fix vite
2026-04-07 21:29:20 +09:30
AdenMGB 01c657d247 Merge branch 'main' of https://github.com/BetterSEQTA/BetterSEQTA-Plus 2026-04-07 21:25:15 +09:30
AdenMGB 1f26fb26d7 fix: somehwat fix overlap with vanilla seqta averages 2026-04-07 21:25:07 +09:30
Jones8683 dbd8e2be8e Merge branch 'main' of https://github.com/Jones8683/BetterSEQTA-Plus 2026-04-07 21:22:47 +09:30
Jones8683 6b5f00add0 Fix vite 2026-04-07 21:22:45 +09:30
Jones Jankovic 1f5eef2fb1 Merge branch 'BetterSEQTA:main' into main 2026-04-07 21:21:42 +09:30
StroepWafel 1e6e57ddcd feat: bs cloud header in settings, cloud pfp as local pfp option & doc updates
updates to docs and also profile
2026-04-07 21:15:29 +09:30
Aden Lindsay e18853ba3a Merge pull request #422 from StroepWafel/patch-2
Update README.md
2026-04-07 21:10:43 +09:30
Aden Lindsay 140cd66c9b chore: update old and non existant links 2026-04-07 21:09:55 +09:30
Jones8683 5684857456 Upgrade project to Vite 8 2026-04-07 20:13:47 +09:30
StroepWafel 24fee7a743 updates to docs and also profile 2026-04-07 20:02:30 +09:30
StroepWafel cef99b7278 Update README.md 2026-04-07 19:55:06 +09:30
StroepWafel aad5bcd97e Update README.md 2026-04-07 19:50:24 +09:30
StroepWafel 423aaa6b84 Update README.md 2026-04-07 19:41:45 +09:30
AdenMGB 97a1226eaf feat(engage): add icon-only sidebar support and Engage-specific titlebar styles
Made-with: Cursor
2026-04-07 15:11:26 +09:30
AdenMGB 72cab5905e fix(engage): disable assessments overview plugin on Engage
Made-with: Cursor
2026-04-07 15:11:15 +09:30
AdenMGB 088b745600 feat(engage): inject settings button, dark/light toggle, and user info with logout
Made-with: Cursor
2026-04-07 15:11:07 +09:30
AdenMGB 8801d5a435 feat(engage): fix loading screen hang and handle login/logout flow
Made-with: Cursor
2026-04-07 15:10:58 +09:30
Aden Lindsay b63a3d95f3 fix today's lessons tweaking out (#420) 2026-04-07 09:14:08 +09:30
StroepWafel eca8327420 fix today's lessons tweaking out 2026-04-07 09:05:13 +09:30
AdenMGB 05cf380e86 feat: some messed up stuff to fix some stuff 2026-04-07 08:46:28 +09:30
StroepWafel 73f005d645 Better theme displaying (#419)
* add more data

add more info for users about themes and also make related themes actually show related themes

* sorting and similar
2026-04-07 08:22:56 +09:30
StroepWafel f2fa9c39a9 Merge pull request #418 from StroepWafel/theme-updates
Theme updates
2026-04-07 08:13:27 +09:30
Aden Lindsay 783ff65fb5 Merge pull request #417 from StroepWafel/updated-sign-in
feat: Unified portaled sign-in overlay
2026-04-06 17:22:10 +09:30
Aden Lindsay 3e7ea3bc03 Adaptive custom themes (#416)
* feat: Smooth change in colour, no hard cut

Added option smoothing on colour change so there is no hard cut made when switching subjects

* feat: Themes can adapt to colour

* quick fix to forced mode as well

* [CodeFactor] Apply fixes

---------

Co-authored-by: codefactor-io <support@codefactor.io>
2026-04-06 17:21:38 +09:30
StroepWafel 398029eecd Merge branch 'main' into adaptive-custom-themes 2026-04-06 15:01:38 +09:30
StroepWafel a55cb84a69 feat: Smooth change in colour, no hard cut (#415)
Added option smoothing on colour change so there is no hard cut made when switching subjects
2026-04-06 14:58:09 +09:30
StroepWafel 94d54f65bf feat: Unified portaled sign-in overlay
Updated the sign-in overlay to be unified across the site, improving UX
2026-04-06 14:48:03 +09:30
codefactor-io 8123c5dd33 [CodeFactor] Apply fixes 2026-04-06 04:55:44 +00:00
StroepWafel ac1ee702ae quick fix to forced mode as well 2026-04-06 14:24:08 +09:30
StroepWafel e657152e3f feat: Themes can adapt to colour 2026-04-06 14:11:19 +09:30
StroepWafel f667ff9e9b feat: Smooth change in colour, no hard cut
Added option smoothing on colour change so there is no hard cut made when switching subjects
2026-04-06 13:39:25 +09:30
AdenMGB 3c613f4938 chore: bump ver 2026-04-03 10:55:41 +10:30
AdenMGB 04843a90fe fix: fix adaptive themeing to support current year subjects 2026-04-03 10:47:56 +10:30
183 changed files with 23899 additions and 1926 deletions
+21 -22
View File
@@ -5,31 +5,30 @@
"es2021": true,
"node": true
},
"extends": "eslint:recommended",
"extends": ["eslint:recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
// allow importing ts extensions
"sort-imports": [
"error",
{
"ignoreCase": true,
"ignoreDeclarationSort": true,
"ignoreMemberSort": false,
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
}
],
"import/extensions": [
"error",
"ignorePackages",
{
"js": "never",
"ts": "never",
"tsx": "never"
}
]
"plugins": ["@typescript-eslint", "import"],
"ignorePatterns": ["**/*.d.ts"],
"globals": {
"__ENABLE_GH_RELEASE_UPDATE_CHECK__": "readonly",
"__GH_RELEASE_REPO__": "readonly",
"__UPDATE_CHANNEL__": "readonly",
"__BUILD_LABEL__": "readonly"
},
"plugins": ["import"]
"rules": {
"no-unused-vars": "off",
"no-undef": "off",
"no-useless-escape": "off",
"no-prototype-builtins": "off",
"no-empty": "off",
"no-case-declarations": "off",
"no-irregular-whitespace": "off",
"sort-imports": "off",
"import/extensions": "off",
"no-async-promise-executor": "off"
}
}
@@ -0,0 +1,67 @@
name: Build Extension
description: Install dependencies, build Chrome and Firefox extensions, and package zips.
inputs:
gh_release_update_check:
description: Enable GitHub release update checker in the built extension.
required: false
default: "false"
update_channel:
description: Update channel baked into the build (stable or nightly).
required: false
default: stable
build_label:
description: Optional build label for nightly display (e.g. run number).
required: false
default: ""
release_repo:
description: GitHub repo slug for the update checker (owner/name).
required: false
default: BetterSEQTA/BetterSEQTA-Plus
outputs:
version:
description: Version from package.json
value: ${{ steps.version.outputs.version }}
chrome_zip:
description: Path to the Chrome extension zip
value: ${{ steps.zip.outputs.chrome_zip }}
firefox_zip:
description: Path to the Firefox extension zip
value: ${{ steps.zip.outputs.firefox_zip }}
runs:
using: composite
steps:
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Install dependencies
shell: bash
run: npm install --legacy-peer-deps
- name: Read version
id: version
shell: bash
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Build extension
shell: bash
env:
GH_RELEASE_UPDATE_CHECK: ${{ inputs.gh_release_update_check }}
UPDATE_CHANNEL: ${{ inputs.update_channel }}
GH_RELEASE_REPO: ${{ inputs.release_repo }}
BUILD_LABEL: ${{ inputs.build_label }}
run: npm run build
- name: Package zips
id: zip
shell: bash
run: |
VERSION="${{ steps.version.outputs.version }}"
(cd dist/chrome && zip -r "../betterseqtaplus-${VERSION}-chrome.zip" .)
(cd dist/firefox && zip -r "../betterseqtaplus-${VERSION}-firefox.zip" .)
echo "chrome_zip=dist/betterseqtaplus-${VERSION}-chrome.zip" >> "$GITHUB_OUTPUT"
echo "firefox_zip=dist/betterseqtaplus-${VERSION}-firefox.zip" >> "$GITHUB_OUTPUT"
+1
View File
@@ -0,0 +1 @@
Experimental nightly build from `main`. May break. **Do not upload to Chrome Web Store or Firefox Add-ons.** Download, replace your sideloaded copy, and reload the extension.
-2
View File
@@ -3,8 +3,6 @@ name: NodeJS Build
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
+46
View File
@@ -0,0 +1,46 @@
# Nightly release workflow — updates the same "nightly" release with fresh builds from main.
# Runs only on BetterSEQTA/BetterSEQTA-Plus. Uses the default GITHUB_TOKEN.
name: Nightly Release
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
permissions:
contents: write
env:
NIGHTLY_TAG: nightly
jobs:
nightly:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build extension
id: build
uses: ./.github/actions/build-extension
with:
gh_release_update_check: "true"
update_channel: nightly
build_label: ${{ github.run_number }}
release_repo: ${{ github.repository }}
- name: Ensure nightly release exists
run: |
if ! gh release view "${{ env.NIGHTLY_TAG }}" 2>/dev/null; then
gh release create "${{ env.NIGHTLY_TAG }}" \
--prerelease \
--title "Nightly" \
--notes-file .github/nightly-release-notes.md
fi
- name: Upload nightly assets
run: |
gh release upload "${{ env.NIGHTLY_TAG }}" \
--clobber \
"${{ steps.build.outputs.chrome_zip }}" \
"${{ steps.build.outputs.firefox_zip }}"
+29
View File
@@ -0,0 +1,29 @@
name: PR CI
on:
pull_request:
branches: ["main"]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Lint
run: npm run lint
env:
ESLINT_USE_FLAT_CONFIG: "false"
- name: Build extension
uses: ./.github/actions/build-extension
with:
gh_release_update_check: "false"
+56
View File
@@ -0,0 +1,56 @@
# Manual release workflow only — runs on BetterSEQTA/BetterSEQTA-Plus via workflow_dispatch.
# Bump package.json version on main, confirm when dispatching, then run.
# Uses the default GITHUB_TOKEN (contents: write).
name: Release
on:
workflow_dispatch:
inputs:
version_updated:
description: I have already updated the version in package.json
type: boolean
required: true
default: false
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Confirm version was updated
if: inputs.version_updated != true
run: |
echo "Check the confirmation box: you must update package.json version before releasing."
exit 1
- uses: actions/checkout@v4
- name: Read version
id: version
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Build extension
id: build
uses: ./.github/actions/build-extension
with:
gh_release_update_check: "true"
update_channel: stable
release_repo: ${{ github.repository }}
- name: Create release if missing
run: |
if ! gh release view "${{ steps.version.outputs.version }}" 2>/dev/null; then
gh release create "${{ steps.version.outputs.version }}" \
--title "${{ steps.version.outputs.version }}" \
--notes "Edit this release description on GitHub."
fi
- name: Upload release assets
run: |
gh release upload "${{ steps.version.outputs.version }}" \
--clobber \
"${{ steps.build.outputs.chrome_zip }}" \
"${{ steps.build.outputs.firefox_zip }}"
+6 -1
View File
@@ -6,6 +6,10 @@ pnpm-lock.yaml
yarn.lock
bun.lock
# PDF.js extension assets (copied by postinstall from pdfjs-dist)
src/public/resources/pdfjs/pdf.worker.min.mjs
src/public/resources/pdfjs/pdf.legacy.min.mjs
# Build
extension.zip
build/
@@ -18,4 +22,5 @@ betterseqtaplus-safari/
.parcel-cache
.env
.env.submit
dependency-graph.svg
dependency-graph.svg
+5 -5
View File
@@ -6,9 +6,9 @@ Hey there! 👋 Thanks for your interest in contributing to BetterSEQTA+! We're
**Never contributed to an open source project before?** No worries! We've made it super easy to get started:
- **📖 Read our [Getting Started Guide](./docs/GETTING_STARTED_CONTRIBUTING.md)** - This walks you through everything step-by-step, from setting up your development environment to making your first pull request.
- **🏗️ Understand the codebase** with our [Architecture Guide](./docs/ARCHITECTURE.md)
- **🔧 Having issues?** Check our [Troubleshooting Guide](./docs/TROUBLESHOOTING.md)
- **📖 Read our [contributing guide](https://docs.betterseqta.org/contributing/)** - This walks you through everything step-by-step, from setting up your development environment to making your first pull request.
- **🏗️ Understand the codebase** with the [architecture guide](https://docs.betterseqta.org/architecture/)
- **🔧 Having issues?** Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
We have lots of [`good first issue`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/good%20first%20issue) labels that are perfect for beginners!
@@ -33,8 +33,8 @@ Join our community channels to discuss the project, get help, and connect with o
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Creating Your First Plugin](./docs/plugins/creating-plugins.md)
- [Plugin API Reference](./docs/advanced/plugin-api.md)
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
## Pull Request Process
+25 -56
View File
@@ -16,17 +16,15 @@
<img src="https://img.shields.io/chrome-web-store/rating/afdgaoaclhkhemfkkkonemoapeinchel" />
</div>
## Table of contents
## 📚 Documentation
All documentation has been moved to the [official docs site](https://docs.betterseqta.org):
- [Features](#features)
- [Creating Custom Themes](#creating-custom-themes)
- [Getting Started](#getting-started)
- [Running Development](#running-development)
- [Building for production](#building-for-production)
- [Folder Structure](#folder-structure)
- [Contributors](#contributors)
- [Credits](#credits)
- [Star History](#star-history)
Includes:
- Getting started
- Development setup
- Architecture
- Plugin system
- Theme creation
## Features
@@ -50,64 +48,32 @@
## Creating Custom Themes
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://betterseqta.gitbook.io/betterseqta-docs). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://docs.betterseqta.org/theme-creation/). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
Don't worry- if you get stuck feel free to ask around in the [discord](https://discord.gg/YzmbnCDkat). We're open and happy to help out! Happy creating :)
## 🚀 Want to Contribute?
## 🚀 Contributing
**New contributors welcome!**
- 📖 Start here: https://docs.betterseqta.org/install/
- 🧠 Architecture: https://docs.betterseqta.org/architecture/
- 🧩 Plugins: https://docs.betterseqta.org/plugins/
- 💬 Discord: https://discord.gg/YzmbnCDkat
**New contributors welcome!** 🎉 We've made it easy to get started:
- **👋 New to the project?** Start with our [Getting Started Guide](./docs/GETTING_STARTED_CONTRIBUTING.md)
- **🏗️ Want to understand the code?** Check out our [Architecture Guide](./docs/ARCHITECTURE.md)
- **🧩 Interested in plugins?** Read our [Plugin Development Guide](./docs/plugins/README.md)
- **🐛 Found a bug?** Open an [issue](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) or fix it yourself!
- **💬 Need help?** Join our [Discord community](https://discord.gg/YzmbnCDkat)
## ⚡ Quick Start
We have lots of https://github.com/BetterSEQTA/BetterSEQTA-Plus/labels/good%20first%20issue labels perfect for beginners!
## Quick Development Setup
&nbsp;&nbsp;&nbsp; **1. Fork & Clone**
```bash
git clone https://github.com/YOUR_USERNAME/BetterSEQTA-Plus
git clone https://github.com/YOUR_USERNAME_FORKED_WITH/BetterSEQTA-Plus
cd BetterSEQTA-Plus
```
&nbsp;&nbsp;&nbsp; **2. Install & Run**
```bash
npm install --legacy-peer-deps
npm run dev
```
````
&nbsp;&nbsp;&nbsp; **3. Load in Browser**
1. Go to `chrome://extensions`
2. Enable "Developer mode"
3. Click "Load unpacked" → Select `dist` folder
4. Visit a SEQTA page to see it work! 🎉
> [!WARNING]
> Whenever you update the extension while not in dev mode, you will need to use the reload button on the extension page.
Then load `dist` in `chrome://extensions` (Developer Mode → Load unpacked).
📚 **Need more details?** Check our [detailed setup guide](./docs/GETTING_STARTED_CONTRIBUTING.md#your-first-30-minutes)
### Building for Production
```bash
npm run build # Build for all browsers
npm run zip # Package for distribution (requires 7-Zip)
```
## Folder Structure
The folder structure is as follows:
- The `src` folder contains source files that are compiled to the build directory.
- The `src/plugins` folder contains vital loaders required for BetterSEQTA+ functionality.
- The `src/interface` folder contains source React & Svelte files that are required for the Settings page.
- The `dist` folder is where the compiled code ends up, this is the folder what you need to load into chrome as an unpacked extension for development.
Full setup guide:
[https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment](https://betterseqta.github.io/BetterSEQTA-Docs/install/#for-developers-development-environment)
## Contributors
@@ -115,11 +81,14 @@ The folder structure is as follows:
<img src="https://contrib.rocks/image?repo=betterseqta/betterseqta-plus" />
</a>
Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plus/blob/main/CONTRIBUTING.md)
Want to contribute? [Click Here!](https://docs.betterseqta.org/contributing/)
## Credits
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers.
Originally developed by [Nulkem](https://github.com/Nulkem/betterseqta)
Ported to Manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68)
Maintained by [SethBurkart123](https://github.com/SethBurkart123), [Crazypersonalph](https://github.com/Crazypersonalph) & the rest of the BetterSEQTA team!
## Star History
+122 -22
View File
@@ -5,7 +5,7 @@
"": {
"name": "betterseqtaplus",
"dependencies": {
"@bedframe/core": "^0.0.46",
"@bedframe/core": "^0.1.0",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1",
@@ -13,7 +13,7 @@
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.1.4",
@@ -52,17 +52,17 @@
"react-dom": "17",
"rss-parser": "^3.13.0",
"sortablejs": "^1.15.6",
"svelte": "^5.22.6",
"svelte": "^5.46.4",
"typescript": "^5.8.2",
"uuid": "^11.1.0",
"vite": "^6.2.1",
"vite": "^8.0.5",
"webextension-polyfill": "^0.12.0",
},
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.95",
"@crxjs/vite-plugin": "^2.2.0",
"@bedframe/cli": "^0.1.2",
"@crxjs/vite-plugin": "^2.4.0",
"@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10",
"@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=="],
"@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=="],
@@ -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=="],
"@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=="],
@@ -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/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.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=="],
"@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-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=="],
"@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/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/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-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=="],
"@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=="],
"@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=="],
"@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/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=="],
"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=="],
@@ -631,7 +673,7 @@
"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=="],
@@ -693,6 +735,8 @@
"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=="],
"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=="],
"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=="],
@@ -787,7 +831,7 @@
"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=="],
@@ -995,6 +1039,30 @@
"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=="],
"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-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=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -1143,6 +1213,8 @@
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
@@ -1423,7 +1497,7 @@
"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=="],
@@ -1477,7 +1551,7 @@
"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=="],
@@ -1485,7 +1559,7 @@
"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=="],
@@ -1555,7 +1629,9 @@
"@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=="],
@@ -1591,6 +1667,8 @@
"@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/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=="],
"dependency-cruiser/commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
"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=="],
@@ -1633,6 +1713,8 @@
"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/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=="],
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"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=="],
@@ -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/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/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=="],
"@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=="],
"@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/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/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=="],
+3 -1
View File
@@ -1,5 +1,7 @@
# BetterSEQTA+ Architecture
**Published version:** [docs.betterseqta.org/architecture/](https://docs.betterseqta.org/architecture/)
Hey there! 👋 New to the codebase and feeling a bit lost? Don't worry - this guide will help you understand how everything fits together!
## Table of Contents
@@ -221,7 +223,7 @@ console.log(settingsState.[the setting name])
Ready to contribute? Here's what to do next:
1. **Read the code**: Start with `src/SEQTA.ts` and follow the flow
2. **Try creating a simple plugin**: Follow our [plugin guide](./plugins/README.md)
2. **Try creating a simple plugin**: Follow the [plugin documentation](https://docs.betterseqta.org/plugins/)
3. **Look at existing issues**: Check our [GitHub issues](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) for "good first issue" labels
4. **Join our Discord**: Get help from the community!
+141
View File
@@ -0,0 +1,141 @@
# BetterSEQTA Cloud — settings sync (server specification)
This document describes the HTTP API the BetterSEQTA+ extension expects for **cloud backup of extension settings**. The client is implemented in the extension repo; the accounts service (`accounts.betterseqta.org`) must implement these endpoints.
## Purpose
- Store **one JSON document per authenticated BetterSEQTA Cloud user** representing a snapshot of the extensions `chrome.storage.local` data (theme, layout, plugin settings, `plugin.*` keys, etc.).
- The extension **does not upload OAuth tokens** (`bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`). Those remain only on the client.
- **Download** replaces local storage with the stored snapshot, then the client reapplies the current devices session tokens so the user stays signed in.
## Base URL
All routes below are relative to:
`https://accounts.betterseqta.org`
## Authentication
Every request must include:
```http
Authorization: Bearer <access_token>
```
Use the same **access tokens** issued by the existing BetterSEQTA+ OAuth flows (`/api/bsplus/login`, `/api/bsplus/refresh`). Resolve the user from the token; the document is scoped to that user.
## Endpoints
### `PUT /api/bsplus/settings/sync`
Upserts the callers settings backup.
**Request body (JSON):**
```json
{
"schemaVersion": 1,
"themeId": "uuid-string-or-empty",
"data": {
"...": "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.
- **`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`).
**Success response:** HTTP `200` (or `201` if you prefer create semantics). Example:
```json
{
"updated_at": "2026-04-07T12:00:00.000Z"
}
```
`updated_at` should be an ISO 8601 timestamp of the save time. The extension displays success without requiring extra fields.
**Error responses:** Standard JSON error body if applicable, e.g. `{ "error": "message" }`, with appropriate HTTP status (`401`, `413`, `422`, etc.).
---
### `GET /api/bsplus/settings/sync`
Returns the callers latest settings backup.
**Success response:** HTTP `200` with body:
```json
{
"schemaVersion": 1,
"themeId": "uuid-string-or-empty",
"data": { },
"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).
- **`themeId`**: optional; if present must match **`data.selectedTheme`** (see `PUT`).
- **`schemaVersion`**: optional but recommended; should match what was stored.
- **`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.
**Error responses:** `401` if the token is invalid, etc.
---
## Suggested database shape
Example relational layout:
| Column | Type | Notes |
|---------------|-------------|--------|
| `user_id` | FK → users | Unique per backup row (one row per user). |
| `payload` | JSON / JSONB| Store `{ "schemaVersion", "data" }` or only `data` + separate `schema_version` column. |
| `updated_at` | timestamptz | Set on each successful `PUT`. |
Unique constraint on `user_id`.
## Semantics
- **Last write wins:** each `PUT` replaces the stored backup for that user.
- **Optional later:** `If-Unmodified-Since` or a `revision` field for conflict detection (not required for v1).
## Security and privacy
- **Encryption at rest** for `payload` is recommended.
- Payload may contain **school-related UI preferences** and plugin data; treat as **user data** under your privacy policy.
- **Do not require or store** refresh/access tokens in the payload; the extension already strips them on upload.
### Never included in the sync payload (`chrome.storage.local` only)
The backup is a flat JSON map of **`chrome.storage.local`** keys. It does **not** include:
- **IndexedDB** — e.g. the Global Search index (`betterseqta-index` and related DBs) lives outside extension storage and is never serialized here.
- **OAuth / session keys** — `bsplus_token`, `bsplus_refresh_token`, `bsplus_client_id`, `bsplus_user`, plus legacy `cloudAccessToken` / `cloudUsername`.
- **Assessment Averages caches** — `plugin.assessments-average.storage.assessments`, `plugin.assessments-average.storage.weightings` (school assessment data).
- **Keys under** `plugin.global-search.storage.*` — reserved so any future plugin storage cache there is not synced.
- **Grade Analytics** — keys under `bsplus.analytics.*` (synced assessment cache and per-school chart preferences).
- **`bsplus_cloud_settings_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.
## Related endpoint: `GET /api/user/cloud-summary`
The extension may call **`GET /api/user/cloud-summary`** (same host, `Authorization: Bearer`) for a **small** JSON summary (e.g. whether DesQTA / BetterSEQTA+ cloud settings exist and **`bsplus.updated_at`** / **`schemaVersion`**). It does **not** return the large settings `data` blob.
- **Auto-sync flow:** compare `bsplus.updated_at` to a **client-only** watermark stored in extension storage as **`bsplus_cloud_settings_known_remote_updated_at`** (never uploaded, never applied from the server payload; preserved on restore).
- If the server timestamp is newer (and `schemaVersion` is not ahead of the client), the client then calls **`GET /api/bsplus/settings/sync`** and applies the full envelope as usual.
This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages, `storage`) and works on **Chromium and Firefox** builds (see `webextension-polyfill`).
## Client reference (extension)
- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, **`bsplus_pending_theme_ensure_after_cloud`**, and **`bsplus_cloud_settings_known_remote_updated_at`** — includes **`themeId`** aligned with **`selectedTheme`**).
- Download: resolve id via **`resolveThemeIdForPostSyncDownload`** → **`applyDownloadedEnvelope`** after `GET` → prefetch theme blobs in page context if needed (**`prepareThemeAfterCloudSync`** in **`ThemeManager`**) → reload SEQTA tabs; local auth keys, sensitive device keys, client-only watermark, and **`bsplus_pending_theme_ensure_after_cloud`** semantics preserved as documented above.
- Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`.
+3 -1
View File
@@ -1,5 +1,7 @@
# Getting Started as a Contributor
**Published version:** [docs.betterseqta.org/contributing/](https://docs.betterseqta.org/contributing/)
Welcome to BetterSEQTA+! 🎉 This guide will walk you through making your first contribution, even if you're completely new to the project.
## Table of Contents
@@ -222,7 +224,7 @@ git push origin your-branch-name
### Stuck? Here's How to Get Unstuck
1. **Check the docs** - [Architecture guide](./ARCHITECTURE.md) explains everything
1. **Check the docs** - The [architecture guide](https://docs.betterseqta.org/architecture/) explains everything
2. **Search existing issues** - Someone might have had the same problem
3. **Ask in Discord** - Our community is super helpful
4. **Create an issue** - If you found a bug or need help
+22 -21
View File
@@ -1,30 +1,37 @@
# BetterSEQTA+ Documentation
🚧 DOCS UNDER CONSTRUCTION! 🚧
Welcome to the BetterSEQTA+ documentation! This documentation will help you understand how BetterSEQTA+ works and how to extend it with plugins and new features.
**Canonical documentation lives at [docs.betterseqta.org](https://docs.betterseqta.org/).** The pages below are the same topics; use the site for search, navigation, and the latest updates.
## Table of Contents
### Getting Started
- [Project Overview](./README.md) - This file
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+
- [Getting Started Contributing](./GETTING_STARTED_CONTRIBUTING.md) - **Start here!** Complete beginner-friendly guide
- [Architecture Guide](./ARCHITECTURE.md) - How BetterSEQTA+ works under the hood
- [Contributing Guide](../CONTRIBUTING.md) - Official contribution guidelines
- [Troubleshooting](./TROUBLESHOOTING.md) - Common issues and solutions
- [Documentation home](https://docs.betterseqta.org/)
- [Installation](https://docs.betterseqta.org/install/)
- [Contributing](https://docs.betterseqta.org/contributing/)
- [GitHub releases & CI](RELEASES.md) — workflows, nightly builds, update detector
- [Architecture](https://docs.betterseqta.org/architecture/)
- [Contribution guidelines (repository)](../CONTRIBUTING.md)
- [Troubleshooting](https://docs.betterseqta.org/troubleshooting/)
### Plugin System
### Features & customization
- [Creating Your First Plugin](./plugins/README.md) - A comprehensive, beginner-friendly guide to creating plugins
- [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs
- [Features](https://docs.betterseqta.org/features/)
- [Themes & customization](https://docs.betterseqta.org/customization/)
- [Theme creation](https://docs.betterseqta.org/theme-creation/)
### Plugin system
- [Plugins overview](https://docs.betterseqta.org/plugins/)
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
- [Example plugin](https://docs.betterseqta.org/example-plugin/)
## Core Concepts
BetterSEQTA+ is built around several core concepts:
1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. Check out our [plugin guide](./plugins/README.md) to learn how to create your own!
1. **Plugin System**: BetterSEQTA+ uses a plugin system to extend SEQTA with new features. Plugins are self-contained pieces of code that can be enabled or disabled by the user. See the [plugins documentation](https://docs.betterseqta.org/plugins/).
2. **Type-Safe Settings**: Each plugin can define settings that are type-safe and automatically rendered in the settings UI. The settings system uses TypeScript decorators to make it easy to define settings with proper typing.
@@ -36,19 +43,13 @@ BetterSEQTA+ is built around several core concepts:
If you need help with BetterSEQTA+, you can:
- [Open an Issue](https://github.com/SeqtaLearning/betterseqta-plus/issues) - Report bugs or request features
- [Open an Issue](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) - Report bugs or request features
- [Join the Discord](https://discord.gg/YzmbnCDkat) - Chat with the community
- [Email the Maintainers](mailto:betterseqta.plus@gmail.com) - Contact the maintainers directly
## Contributing to the Documentation
We welcome contributions to the documentation! If you find something unclear or missing, please open an issue or submit a pull request.
To contribute to the documentation:
1. Fork the repository
2. Make your changes to the documentation files
3. Submit a pull request with a clear description of your changes
We welcome contributions to the documentation. The published site is built from the documentation repository; see [Contributing](https://docs.betterseqta.org/contributing/) for how to help.
## License
+266
View File
@@ -0,0 +1,266 @@
# GitHub releases, CI, and the update detector
BetterSEQTA+ is distributed on the Chrome Web Store and Firefox Add-ons, but some users sideload builds from GitHub. This document explains how automated builds, releases, and the in-extension update badge work.
All published releases target the upstream repository: **[BetterSEQTA/BetterSEQTA-Plus](https://github.com/BetterSEQTA/BetterSEQTA-Plus)**.
---
## Overview
```mermaid
flowchart TB
subgraph ci [Continuous integration]
PrCi[pr-ci.yml — every PR]
Mvp[mvp.yml — push to main]
end
subgraph releases [GitHub releases]
Manual[release.yml — manual stable]
Nightly[nightly.yml — daily cron]
end
subgraph output [Outputs]
Zips["betterseqtaplus-VERSION-chrome.zip\nbetterseqtaplus-VERSION-firefox.zip"]
GhStable["Release tag: 3.7.0"]
GhNightly["Release tag: nightly"]
Badge[Update badge in settings]
end
PrCi -->|build only| NoRelease[No release]
Mvp -->|artifact| DistZip[dist.zip artifact]
Manual --> Zips
Manual --> GhStable
Manual --> Badge
Nightly --> Zips
Nightly --> GhNightly
Nightly --> Badge
```
| Workflow | Trigger | Creates a release? | Update detector in build? |
|----------|---------|------------------|---------------------------|
| `pr-ci.yml` | Every pull request to `main` | No | No |
| `mvp.yml` | Every push to `main` | No | No |
| `release.yml` | Manual (`workflow_dispatch`) | Yes — stable version tag | Yes — stable channel |
| `nightly.yml` | Daily at 03:00 UTC + manual | Yes — reuses `nightly` tag | Yes — nightly channel |
---
## Shared build action
All release and CI builds that need packaged extensions use the composite action at [`.github/actions/build-extension/action.yml`](../.github/actions/build-extension/action.yml).
It:
1. Installs dependencies (`npm install --legacy-peer-deps`)
2. Runs `npm run build` (Chrome then Firefox via Vite)
3. Zips each unpacked folder into sideload-ready archives:
- `dist/betterseqtaplus-{version}-chrome.zip`
- `dist/betterseqtaplus-{version}-firefox.zip`
The `{version}` comes from `package.json`.
### Build-time flags (release builds only)
Release and nightly workflows pass environment variables into Vite, which bakes them into the extension at compile time via `define` in [`vite.config.ts`](../vite.config.ts):
| Variable | Stable release | Nightly | PR / local dev |
|----------|----------------|---------|----------------|
| `GH_RELEASE_UPDATE_CHECK` | `true` | `true` | `false` / unset |
| `UPDATE_CHANNEL` | `stable` | `nightly` | `stable` (unused) |
| `GH_RELEASE_REPO` | `BetterSEQTA/BetterSEQTA-Plus` | same | same |
| `BUILD_LABEL` | empty | GitHub run number | empty |
When `GH_RELEASE_UPDATE_CHECK` is not `true`, the update-checker code is tree-shaken out of the bundle. PR CI builds and local `npm run build` do **not** include the update badge.
To test a release-style build locally:
```bash
# PowerShell
$env:GH_RELEASE_UPDATE_CHECK="true"
$env:UPDATE_CHANNEL="stable"
npm run build
# bash
GH_RELEASE_UPDATE_CHECK=true UPDATE_CHANNEL=stable npm run build
```
---
## Stable release (`release.yml`)
**Purpose:** A safe, manual way to publish versioned builds that users can download from GitHub.
**Trigger:** Manual only — Actions → **Release****Run workflow**. There is no schedule or automatic trigger.
When dispatching, check **“I have already updated the version in package.json”**. The workflow will not run without that confirmation.
### Before you run it
1. Merge your changes to `main`.
2. Bump `version` in [`package.json`](../package.json) (e.g. `3.7.0``3.8.0`).
3. Commit and push that bump.
### What the workflow does
1. Aborts if the version confirmation was not checked.
2. Reads the version from `package.json`.
3. Builds Chrome and Firefox with the update detector enabled (stable channel).
4. If no release exists for that tag: creates one (e.g. `3.8.0`) with a placeholder description.
5. If a release already exists for that tag: **only replaces the zip assets** (`--clobber`). Title and body are left unchanged.
6. Uploads `betterseqtaplus-{version}-chrome.zip` and `-firefox.zip`.
On first publish, edit the **title** and **release notes** on GitHub afterward. Re-running for the same version refreshes the files only.
### Downloading and installing
1. Open [github.com/BetterSEQTA/BetterSEQTA-Plus/releases](https://github.com/BetterSEQTA/BetterSEQTA-Plus/releases).
2. Pick the version you want.
3. Download `betterseqtaplus-{version}-chrome.zip` or `-firefox.zip`.
4. Unzip and load the unpacked folder as a temporary extension (Chrome: Extensions → Load unpacked; Firefox: `about:debugging` → Load Temporary Add-on).
**These GitHub builds are for sideloading only. Do not upload them to the Chrome Web Store or Firefox Add-ons.**
---
## Nightly release (`nightly.yml`)
**Purpose:** Continuous experimental builds from `main` so testers always have a single place to get the latest code.
**Trigger:**
- Automatically every day at **03:00 UTC**
- Manually via Actions → **Nightly Release****Run workflow**
### What the workflow does
1. Builds from the current `main` branch with the update detector enabled (nightly channel).
2. Uses a fixed release tag: **`nightly`** (marked as prerelease).
3. On first run: creates the `nightly` release with the text in [`.github/nightly-release-notes.md`](../.github/nightly-release-notes.md).
4. On every subsequent run: **replaces** the zip assets on the same release (`--clobber`). The release title and body are not rewritten.
The nightly release body warns that builds are experimental and must not be uploaded to extension stores.
---
## PR CI (`pr-ci.yml`)
**Purpose:** Verify that every pull request builds cleanly in a fresh environment.
**Trigger:** Every pull request targeting `main`.
**Steps:**
1. `npm install --legacy-peer-deps`
2. `npm run lint` (ESLint on `src/**/*.{js,ts}`)
3. Build via the shared action with **no** update detector
No release is created and no artifacts are published for end users.
---
## Push CI (`mvp.yml`)
**Purpose:** Build verification when code lands on `main`.
**Trigger:** Push to `main` only (not pull requests — those use `pr-ci.yml`).
Uploads a `dist.zip` artifact containing the full `dist/` folder for debugging. No release, no update detector.
---
## Authentication
Release workflows run **only on [BetterSEQTA/BetterSEQTA-Plus](https://github.com/BetterSEQTA/BetterSEQTA-Plus)**. They use the default `GITHUB_TOKEN` with `contents: write` — no extra secrets or PATs required.
---
## Update detector (in-extension)
GitHub release builds include a small feature that tells sideload users when a newer build is available, so they do not have to check GitHub manually.
### Where it appears
In the **settings popup** header (top-right): an amber **“Update available — X.X.X”** pill when an update exists, plus a muted line:
> GitHub release build — do not upload to extension stores.
Clicking the badge opens the relevant GitHub releases page.
### How it checks for updates
Implementation: [`src/utils/githubReleaseUpdate.ts`](../src/utils/githubReleaseUpdate.ts)
**Stable channel** (from `release.yml` builds):
1. Fetches releases from the GitHub API for `BetterSEQTA/BetterSEQTA-Plus`.
2. Ignores the `nightly` tag and any non-semver tags.
3. Finds the highest semver tag.
4. Compares it to `browser.runtime.getManifest().version`.
5. Shows the badge if the remote version is newer.
**Nightly channel** (from `nightly.yml` builds):
1. Fetches the `nightly` release.
2. Compares its `published_at` timestamp to `lastSeenNightlyPublishedAt` in extension storage.
3. On first install, records the current publish time without showing a badge.
4. Shows **“Update available — nightly #123”** (run number) when a newer nightly has been published.
Checks are throttled to once every **6 hours** per browser profile (`localStorage` key `bsplus_lastGhReleaseCheck`). Recent results are cached in memory for the session.
### Dev override (testing)
To test the badge without publishing a real release:
1. Open settings and unlock **dev mode** (click the logo, type `dev`).
2. In the developer section, set **GitHub latest version override** to a version higher than your installed one (e.g. `9.9.9`).
3. Reopen settings — the badge should appear.
This only applies when dev mode is on. Clear the field to return to normal API checks.
---
## File reference
| Path | Role |
|------|------|
| `.github/actions/build-extension/action.yml` | Shared install, build, zip |
| `.github/workflows/release.yml` | Manual stable releases |
| `.github/workflows/nightly.yml` | Nightly releases |
| `.github/workflows/pr-ci.yml` | PR lint + build |
| `.github/workflows/mvp.yml` | Push-to-main build artifact |
| `.github/nightly-release-notes.md` | Static body for the `nightly` release |
| `vite.config.ts` | Injects build-time `define` flags |
| `src/env.d.ts` | TypeScript declarations for those flags |
| `src/utils/githubReleaseUpdate.ts` | Update check logic |
| `src/interface/pages/settings.svelte` | Badge and disclaimer UI |
| `src/interface/pages/settings/general.svelte` | Dev override input |
| `src/types/storage.ts` | `devGhReleaseVersionOverride`, `lastSeenNightlyPublishedAt` |
---
## Quick reference
### Publish a stable release
```text
1. Bump version in package.json on main
2. Actions → Release → Run workflow → confirm version checkbox
3. Edit release title/notes on GitHub (first time only)
4. Re-run with same version to refresh zips without changing release text
```
### Get the latest nightly
```text
https://github.com/BetterSEQTA/BetterSEQTA-Plus/releases/tag/nightly
```
### Verify a PR locally
```bash
npm run lint
npm run build
```
+2
View File
@@ -1,5 +1,7 @@
# Theme Creation Guide
**Published version:** [docs.betterseqta.org/theme-creation/](https://docs.betterseqta.org/theme-creation/)
This guide covers everything you need to know about creating custom themes for BetterSEQTA+.
## Table of Contents
+2
View File
@@ -1,5 +1,7 @@
# Troubleshooting Guide
**Published version:** [docs.betterseqta.org/troubleshooting/](https://docs.betterseqta.org/troubleshooting/)
Having issues with BetterSEQTA+ development? This guide covers the most common problems and their solutions.
## Table of Contents
+28 -6
View File
@@ -1,5 +1,7 @@
# Contributing to BetterSEQTA+
**Published version:** [docs.betterseqta.org/contributing/](https://docs.betterseqta.org/contributing/)
Thank you for your interest in contributing to BetterSEQTA+! This document provides guidelines and instructions for contributing to the project.
## Table of Contents
@@ -57,7 +59,7 @@ Key points:
5. **Install in Chrome/Firefox**
Follow the [installation instructions](./installation.md#development-installation) to load the development version into your browser.
Follow the [installation instructions](https://docs.betterseqta.org/install/) to load the development version into your browser.
### Project Structure
@@ -117,12 +119,13 @@ git checkout -b feature/my-new-feature
If your changes require documentation updates, include them in the same PR.
4. **Run Tests**
4. **Run CI checks locally**
Make sure all tests pass before submitting your PR:
Pull requests trigger **PR CI**, which lints and builds in a clean environment:
```bash
npm test
npm run lint
npm run build
```
5. **Submit Your PR**
@@ -137,6 +140,25 @@ git checkout -b feature/my-new-feature
Once approved, a maintainer will merge your PR.
### GitHub Actions workflows
See [RELEASES.md](RELEASES.md) for a full guide (workflows, sideload installs, and update detector).
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `pr-ci.yml` | Every PR to `main` | Typecheck, lint, and build (no release) |
| `mvp.yml` | Push to `main` | Build and upload `dist.zip` artifact |
| `release.yml` | Manual (`workflow_dispatch`) | Stable release on [BetterSEQTA/BetterSEQTA-Plus](https://github.com/BetterSEQTA/BetterSEQTA-Plus) tagged with `package.json` version |
| `nightly.yml` | Daily cron + manual | Updates the fixed `nightly` prerelease with latest `main` builds |
**Releasing a stable version:** bump `version` in `package.json` on `main`, then manually run the **Release** workflow and confirm the version checkbox. Edit the release title and notes on GitHub after the first publish. Re-running for the same tag only updates the zip files. Assets are `betterseqtaplus-{version}-chrome.zip` and `-firefox.zip`.
**Nightly builds** replace assets on the same `nightly` release. The release body warns that builds are experimental and must not be uploaded to extension stores.
**GitHub release builds** include an in-extension update checker (settings header badge). **PR CI and local dev builds do not.**
Release workflows are dispatched only on the main **BetterSEQTA/BetterSEQTA-Plus** repository and use the default `GITHUB_TOKEN`.
### Coding Standards
We follow TypeScript best practices and have a consistent code style:
@@ -246,8 +268,8 @@ Join our community channels to discuss the project, get help, and connect with o
If you're interested in creating plugins for BetterSEQTA+, check out our plugin development guides:
- [Creating Your First Plugin](./plugins/creating-plugins.md)
- [Plugin API Reference](./advanced/plugin-api.md)
- [Plugin development](https://docs.betterseqta.org/plugin-development/)
- [Plugin API](https://docs.betterseqta.org/plugin-api/)
## Recognition
+4 -2
View File
@@ -1,5 +1,7 @@
# Installing BetterSEQTA+
**Published version:** [docs.betterseqta.org/install/](https://docs.betterseqta.org/install/)
This guide will walk you through the process of installing and setting up BetterSEQTA+ for development or usage.
## Prerequisites
@@ -178,5 +180,5 @@ bun run dev
Now that you have BetterSEQTA+ installed, you can:
- [Getting Started with Plugins](./plugins/getting-started.md)
- [Contribute to the project](../CONTRIBUTING.md)
- [Plugins](https://docs.betterseqta.org/plugins/)
- [Contribute to the project](https://docs.betterseqta.org/contributing/) · [Repository CONTRIBUTING.md](../CONTRIBUTING.md)
+4 -2
View File
@@ -1,5 +1,7 @@
# Example Plugin Template
**Published version:** [docs.betterseqta.org/example-plugin/](https://docs.betterseqta.org/example-plugin/)
This is a complete, working example of a simple BetterSEQTA+ plugin. You can copy this code and modify it to create your own plugin!
## What This Example Does
@@ -328,8 +330,8 @@ Once you've got this working:
## Need Help?
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
- 📚 Read our [Plugin Development Guide](./README.md)
- 🐛 Check the [Troubleshooting Guide](../TROUBLESHOOTING.md)
- 📚 Read the [plugin documentation](https://docs.betterseqta.org/plugins/)
- 🐛 Check the [troubleshooting guide](https://docs.betterseqta.org/troubleshooting/)
- 📝 Open an issue on GitHub
Happy coding! 🎉
+3 -1
View File
@@ -1,5 +1,7 @@
# Creating Plugins for BetterSEQTA+
**Published version:** [docs.betterseqta.org/plugins/](https://docs.betterseqta.org/plugins/) · [Plugin development](https://docs.betterseqta.org/plugin-development/) · [Plugin API](https://docs.betterseqta.org/plugin-api/)
Hey there! 👋 So you want to create a plugin for BetterSEQTA+? That's awesome! This guide will walk you through everything you need to know, from the very basics to more advanced features. Don't worry if you're new to this - we'll explain everything step by step.
## What is a Plugin?
@@ -294,4 +296,4 @@ Got stuck? No worries! Here's where you can get help:
- Check out the built-in plugins in the `src/plugins/built-in` folder
- Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues)
Happy coding and feel free to checkout the api reference [here](./api-reference.md)
Happy coding and feel free to check out the [plugin API](https://docs.betterseqta.org/plugin-api/) on the documentation site.
+3 -1
View File
@@ -1,6 +1,8 @@
# Plugin API Reference
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see [Creating Your First Plugin](./README.md).
**Published version:** [docs.betterseqta.org/plugin-api/](https://docs.betterseqta.org/plugin-api/)
This document provides detailed technical information about BetterSEQTA+'s plugin APIs. For a beginner-friendly introduction, see the [plugins section](https://docs.betterseqta.org/plugins/) at [docs.betterseqta.org](https://docs.betterseqta.org/).
## Plugin Structure
+43
View File
@@ -0,0 +1,43 @@
import type { Plugin } from "vite";
/**
* Firefox extension pages forbid eval / `Function` constructor. Some deps still emit:
* - `Function(\`return this\`)()` (lodash-style global)
* - `try { return Function(\`\`) / new Function("") … }` (feature probes, e.g. PDF.js / ORT)
*/
export function firefoxStripFunctionProbe(): Plugin {
return {
name: "firefox-strip-function-probe",
apply: "build",
enforce: "post",
generateBundle(_options, bundle) {
if ((process.env.MODE || "chrome").toLowerCase() !== "firefox") return;
const literalReplacements: [string, string][] = [
['try{return new Function(""),!0}catch{return!1}', "return!1"],
["try{return new Function(''),!0}catch{return!1}", "return!1"],
['try{return new Function(""),true}catch{return false}', "return false"],
["try{return new Function(''),true}catch{return false}", "return false"],
// Empty template literal probe (minifier output)
["try{return Function(``),!0}catch{return!1}", "return!1"],
];
for (const chunk of Object.values(bundle)) {
if (chunk.type !== "chunk" || typeof chunk.code !== "string") continue;
let { code } = chunk;
code = code.replace(/Function\(`return this`\)\(\)/g, "(globalThis)");
code = code.replace(/Function\("return this"\)\(\)/g, "(globalThis)");
code = code.replace(/Function\('return this'\)\(\)/g, "(globalThis)");
for (const [from, to] of literalReplacements) {
if (code.includes(from)) {
code = code.split(from).join(to);
}
}
chunk.code = code;
}
},
};
}
+21 -11
View File
@@ -1,10 +1,11 @@
{
"name": "betterseqtaplus",
"version": "3.5.2",
"version": "3.7.0",
"type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development add 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",
"scripts": {
"postinstall": "node scripts/copy-pdfjs-assets.mjs",
"autoaudit": "npm audit && npm audit fix && npm run build",
"dev": "cross-env MODE=chrome vite dev",
"dev:firefox": "cross-env MODE=firefox vite build --watch",
@@ -16,7 +17,8 @@
"build:dev": "cross-env MODE=chrome SOURCEMAP=true vite build && cross-env MODE=firefox SOURCEMAP=true vite build",
"convert:safari": "xcrun safari-web-extension-converter dist/safari --project-location . --app-name $npm_package_name-safari",
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
"lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint \"src/**/*.{js,ts}\"",
"release": "gh release create $npm_package_version --repo BetterSEQTA/BetterSEQTA-Plus ./dist/*.zip --generate-notes",
"publish": "bun lib/publish.js --b",
"zip": "bedframe zip"
},
@@ -31,20 +33,25 @@
"author": {
"name": "SethBurkart123",
"email": "betterseqta.plus@gmail.com",
"url": "https://github.com/BetterSEQTA/BetterSEQTA-plus"
"url": "https://github.com/BetterSEQTA/BetterSEQTA-Plus"
},
"license": "MIT",
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.95",
"@crxjs/vite-plugin": "^2.2.0",
"@bedframe/cli": "^0.1.2",
"@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/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"cross-env": "^10.0.0",
"dependency-cruiser": "^17.0.1",
"eslint": "^9.33.0",
"eslint-plugin-import": "^2.31.0",
"glob": "^11.0.1",
"mime-types": "^3.0.1",
"prettier": "^3.5.3",
@@ -57,7 +64,7 @@
"url": "^0.11.4"
},
"dependencies": {
"@bedframe/core": "^0.0.46",
"@bedframe/core": "^0.1.0",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1",
@@ -65,7 +72,7 @@
"@codemirror/search": "^6.5.10",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.1.4",
@@ -82,8 +89,10 @@
"canvas-confetti": "^1.9.3",
"codemirror": "^6.0.1",
"color": "^5.0.0",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"dompurify": "^3.2.4",
"embeddia": "^1.2.1",
"embeddia": "^1.3.0",
"embla-carousel-autoplay": "^8.5.2",
"embla-carousel-svelte": "^8.5.2",
"esbuild": "^0.25.3",
@@ -91,6 +100,7 @@
"flexsearch": "^0.8.147",
"fuse.js": "^7.1.0",
"idb": "^8.0.2",
"layerchart": "2.0.0-next.27",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"mathjs": "^14.4.0",
@@ -104,10 +114,10 @@
"react-dom": "17",
"rss-parser": "^3.13.0",
"sortablejs": "^1.15.6",
"svelte": "^5.22.6",
"svelte": "^5.46.4",
"typescript": "^5.8.2",
"uuid": "^11.1.0",
"vite": "^6.2.1",
"vite": "^8.0.5",
"webextension-polyfill": "^0.12.0"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { copyFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const pdfjsRoot = join(root, "node_modules", "pdfjs-dist");
const outDir = join(root, "src", "public", "resources", "pdfjs");
mkdirSync(outDir, { recursive: true });
copyFileSync(
join(pdfjsRoot, "build", "pdf.worker.min.mjs"),
join(outDir, "pdf.worker.min.mjs"),
);
copyFileSync(
join(pdfjsRoot, "legacy", "build", "pdf.min.mjs"),
join(outDir, "pdf.legacy.min.mjs"),
);
+4
View File
@@ -59,6 +59,10 @@ async function init() {
IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page");
if (typeof window !== "undefined" && window === window.top) {
void browser.runtime.sendMessage({ type: "cloudSettingsPoll" }).catch(() => {});
}
registerFetchSeqtaAppLinkListener();
const documentLoadStyle = document.createElement("style");
+343 -6
View File
@@ -1,6 +1,29 @@
import browser from "webextension-polyfill";
import semver from "semver";
import type { SettingsState } from "@/types/storage";
import { fetchNews } from "./background/news";
import {
initCloudSettingsAutoSync,
performCloudSettingsDownloadWithRetry,
performCloudSettingsUploadWithRetry,
requestCloudSettingsDebouncedUpload,
runCloudSettingsPoll,
} 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() {
const result = browser.tabs.query({});
@@ -20,20 +43,68 @@ function reloadSeqtaPages() {
/** Callback for sending a response back to the message sender */
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 {
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 headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
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)
.catch((err) => {
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
fetch(githubUrl, { cache: "no-store" })
.then((r) => r.json())
.then((data) => sendResponse({ success: true, data: { themes: data.themes ?? [] } }))
.then(async (r) => {
if (!r.ok) throw new Error(`GitHub fallback HTTP ${r.status}`);
const data = await r.json();
const themes = Array.isArray(data) ? data : (data?.themes ?? []);
return normalizeFetchThemesResponse({ success: true, data: { themes } });
})
.then(sendResponse)
.catch((fallbackErr) => {
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
sendResponse({ success: false, error: fallbackErr?.message });
@@ -50,7 +121,7 @@ function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boo
}
const headers: Record<string, string> = {};
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(sendResponse)
.catch((err) => {
@@ -127,6 +198,74 @@ function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
return true;
}
function handleCloudStartLogin(request: any, sendResponse: MessageSender): boolean {
const { client_id, redirect_uri } = request;
if (!client_id || !redirect_uri) {
sendResponse({ error: "Missing client_id or redirect_uri" });
return true;
}
const authorizeUrl = `https://accounts.betterseqta.org/login?redirect=${encodeURIComponent(`/oauth/authorize?client_id=${client_id}&redirect_uri=${encodeURIComponent(redirect_uri)}`)}`;
browser.tabs.create({ url: authorizeUrl }).then(() => {
sendResponse({ success: true });
}).catch((err) => {
console.error("[Background] cloudStartLogin error:", err);
sendResponse({ error: err?.message ?? "Failed to open login page" });
});
return true;
}
const CALLBACK_URL_PREFIX = "https://accounts.betterseqta.org/auth/bsplus/callback";
function initCloudLoginCallbackListener() {
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.url && changeInfo.url.startsWith(CALLBACK_URL_PREFIX)) {
try {
const url = new URL(changeInfo.url);
const token = url.searchParams.get("token");
const refreshToken = url.searchParams.get("refresh_token");
const userId = url.searchParams.get("user_id");
if (token && refreshToken) {
// Store tokens
void (async () => {
try {
await browser.storage.local.set({
bsplus_token: token,
bsplus_refresh_token: refreshToken,
});
// Fetch full user info
const userRes = await fetch("https://accounts.betterseqta.org/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});
if (userRes.ok) {
const user = await userRes.json();
await browser.storage.local.set({ bsplus_user: user });
} else if (userId) {
await browser.storage.local.set({ bsplus_user: { id: userId } });
}
// Trigger cloud settings download
void performCloudSettingsDownloadWithRetry(token).catch((err) => {
console.warn("[Background] Cloud settings download after login:", err);
});
} catch (err) {
console.error("[Background] Failed to process login callback:", err);
}
})();
// Close the callback tab
void browser.tabs.remove(tabId);
}
} catch (err) {
console.error("[Background] Error parsing callback URL:", err);
}
}
});
}
initCloudLoginCallbackListener();
function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
const { refresh_token, client_id } = request;
if (!refresh_token || !client_id) {
@@ -150,6 +289,57 @@ function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean
return true;
}
function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const res = await performCloudSettingsUploadWithRetry(token);
sendResponse({
success: res.success,
error: res.error,
updated_at: res.updated_at,
});
} catch (err) {
console.error("[Background] cloudSettingsUpload error:", err);
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Upload failed",
});
}
})();
return true;
}
function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): boolean {
void (async () => {
try {
const token = request.token as string | undefined;
if (!token) {
sendResponse({ success: false, error: "Not authenticated" });
return;
}
const res = await performCloudSettingsDownloadWithRetry(token);
sendResponse({
success: res.success,
notFound: res.notFound,
error: res.error,
updated_at: res.updated_at,
});
} catch (err) {
console.error("[Background] cloudSettingsDownload error:", err);
sendResponse({
success: false,
error: err instanceof Error ? err.message : "Download failed",
});
}
})();
return true;
}
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
const { themeId, token, action } = request;
if (!themeId || !token) {
@@ -157,7 +347,7 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean
return false;
}
const isFavorite = action === "favorite";
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, {
fetch(`${apiBase()}/api/themes/${themeId}/favorite`, {
method: isFavorite ? "POST" : "DELETE",
headers: { Authorization: `Bearer ${token}` },
})
@@ -184,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> = {
reloadTabs: () => reloadSeqtaPages(),
setDevApiBase: handleSetDevApiBase,
extensionPages: (req) => {
browser.tabs.query({}).then((tabs) => {
for (const tab of tabs) {
@@ -212,8 +413,19 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
fetchFromUrl: handleFetchFromUrl,
cloudReserveClient: handleCloudReserveClient,
cloudLogin: handleCloudLogin,
cloudStartLogin: handleCloudStartLogin,
cloudRefresh: handleCloudRefresh,
cloudFavorite: handleCloudFavorite,
cloudSettingsUpload: handleCloudSettingsUpload,
cloudSettingsDownload: handleCloudSettingsDownload,
cloudSettingsPoll: () => {
void runCloudSettingsPoll();
return false;
},
cloudSettingsRequestDebouncedUpload: () => {
requestCloudSettingsDebouncedUpload();
return false;
},
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
(async () => {
try {
@@ -331,6 +543,9 @@ function getDefaultValues(): SettingsState {
iconOnlySidebar: false,
adaptiveThemeColour: false,
adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true,
themeOfTheMonthDisabled: false,
autoCloudSettingsSync: true,
};
}
@@ -340,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.storage.local.remove(["justupdated"]);
browser.storage.local.remove(["data"]);
@@ -347,4 +676,12 @@ browser.runtime.onInstalled.addListener(function (event) {
if (event.reason == "install" || event.reason == "update") {
browser.storage.local.set({ justupdated: true });
}
if (event.reason === "update" && event.previousVersion) {
void migrateGlobalSearchDefaultsFor365Upgrade(event.previousVersion);
void resetThemeOfTheMonthDisabledFor366Upgrade(event.previousVersion);
void resetThemeOfTheMonthDismissalFor370Upgrade(event.previousVersion);
}
});
initCloudSettingsAutoSync({ reloadSeqtaPages });
+420
View File
@@ -0,0 +1,420 @@
import browser from "webextension-polyfill";
import {
applyDownloadedEnvelope,
buildUploadPayload,
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
isKeyIncludedInCloudUploadPayload,
resolveThemeIdForPostSyncDownload,
setKnownRemoteUpdatedAt,
} from "@/seqta/utils/cloudSettingsSync";
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
export const CLOUD_SUMMARY_URL = `${ACCOUNTS_BASE}/api/user/cloud-summary`;
const CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`;
const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`;
const UPLOAD_DEBOUNCE_MS = 2000;
const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000;
const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll";
type CloudSummaryResponse = {
desqta?: unknown;
bsplus?: { updated_at: string; schemaVersion: number } | null;
};
let reloadSeqtaPagesFn: (() => void) | null = null;
let suppressAutoUploadDuringRestore = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let pollInFlight: Promise<void> | null = null;
function isAutoCloudSyncEnabled(all: Record<string, unknown>): boolean {
return all.autoCloudSettingsSync !== false;
}
async function parseJsonResponse(r: Response): Promise<any> {
const text = await r.text();
try {
return text ? JSON.parse(text) : {};
} catch {
return {};
}
}
async function getAccessToken(): Promise<string | null> {
const { bsplus_token } = await browser.storage.local.get("bsplus_token");
return typeof bsplus_token === "string" && bsplus_token.length > 0 ? bsplus_token : null;
}
async function tryRefreshTokens(): Promise<boolean> {
const result = await browser.storage.local.get([
"bsplus_refresh_token",
"bsplus_client_id",
"bsplus_user",
]);
const refresh_token = result.bsplus_refresh_token as string | undefined;
const client_id = result.bsplus_client_id as string | undefined;
if (!refresh_token || !client_id) return false;
try {
const r = await fetch(REFRESH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token, client_id }),
});
const data = await parseJsonResponse(r);
if (!r.ok || !data.access_token || !data.refresh_token) return false;
await browser.storage.local.set({
bsplus_token: data.access_token,
bsplus_refresh_token: data.refresh_token,
bsplus_user: data.user ?? result.bsplus_user,
});
return true;
} catch {
return false;
}
}
function isServerTimestampNewer(serverIso: string, localIso: string | undefined): boolean {
const a = Date.parse(serverIso);
if (Number.isNaN(a)) return false;
if (localIso === undefined || localIso === "") return true;
const b = Date.parse(localIso);
if (Number.isNaN(b)) return true;
return a > b;
}
async function fetchCloudSummaryOnce(
token: string,
): Promise<
| { ok: true; data: CloudSummaryResponse }
| { ok: false; unauthorized: boolean; error?: string }
> {
try {
const r = await fetch(CLOUD_SUMMARY_URL, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = (await parseJsonResponse(r)) as CloudSummaryResponse;
if (r.status === 401) return { ok: false, unauthorized: true };
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: (data as { error?: string })?.error ?? `Summary failed (${r.status})`,
};
}
return { ok: true, data };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Network error",
};
}
}
async function fetchCloudSummaryWithAuthRetry(
token: string,
): Promise<CloudSummaryResponse | null> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await fetchCloudSummaryOnce(t);
if (res.ok) return res.data;
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) break;
const next = await getAccessToken();
if (!next) break;
t = next;
continue;
}
if (res.error) console.warn("[BS+ cloud sync] cloud-summary:", res.error);
break;
}
return null;
}
type PutResult =
| { ok: true; updated_at?: string }
| { ok: false; unauthorized: boolean; error?: string };
async function putSettingsOnce(token: string): Promise<PutResult> {
try {
const all = await browser.storage.local.get();
const payload = buildUploadPayload(all as Record<string, unknown>);
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await parseJsonResponse(r);
if (r.status === 401) return { ok: false, unauthorized: true };
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: data?.error ?? `Upload failed (${r.status})`,
};
}
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Upload failed",
};
}
}
export async function performCloudSettingsUploadWithRetry(
token: string,
): Promise<{ success: boolean; error?: string; updated_at?: string }> {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await putSettingsOnce(t);
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
const next = await getAccessToken();
if (!next) return { success: false, error: "Not authenticated" };
t = next;
continue;
}
return { success: false, error: res.error ?? "Upload failed" };
}
return { success: false, error: "Upload failed" };
}
type GetResult =
| { ok: true; updated_at?: string }
| { ok: false; notFound?: boolean; unauthorized: boolean; error?: string };
async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
try {
const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
const data = await parseJsonResponse(r);
if (r.status === 401) return { ok: false, unauthorized: true };
if (r.status === 404) {
return {
ok: false,
notFound: true,
unauthorized: false,
error: "No settings backup found in the cloud",
};
}
if (!r.ok) {
return {
ok: false,
unauthorized: false,
error: data?.error ?? `Download failed (${r.status})`,
};
}
const themeIdToEnsure = resolveThemeIdForPostSyncDownload(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?.();
const updated_at = data?.updated_at as string | undefined;
await setKnownRemoteUpdatedAt(updated_at);
return { ok: true, updated_at };
} catch (e) {
return {
ok: false,
unauthorized: false,
error: e instanceof Error ? e.message : "Download failed",
};
}
}
export async function performCloudSettingsDownloadWithRetry(
token: string,
): Promise<{ success: boolean; notFound?: boolean; error?: string; updated_at?: string }> {
suppressAutoUploadDuringRestore = true;
try {
let t = token;
for (let attempt = 0; attempt < 2; attempt++) {
const res = await getSettingsAndApplyOnce(t);
if (res.ok) return { success: true, updated_at: res.updated_at };
if (res.unauthorized && attempt === 0) {
const refreshed = await tryRefreshTokens();
if (!refreshed) return { success: false, error: "Not authenticated" };
const next = await getAccessToken();
if (!next) return { success: false, error: "Not authenticated" };
t = next;
continue;
}
return {
success: false,
notFound: res.notFound,
error: res.error ?? "Download failed",
};
}
return { success: false, error: "Download failed" };
} finally {
suppressAutoUploadDuringRestore = false;
}
}
async function maybeUploadBaseline(token: string): Promise<void> {
const res = await performCloudSettingsUploadWithRetry(token);
if (!res.success) {
console.warn("[BS+ cloud sync] Baseline upload failed:", res.error);
}
}
async function downloadIfNeeded(token: string): Promise<void> {
const res = await performCloudSettingsDownloadWithRetry(token);
if (!res.success && !res.notFound) {
console.warn("[BS+ cloud sync] Auto-download failed:", res.error);
}
}
async function runCloudSettingsPollInner(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
let token = await getAccessToken();
if (!token) return;
const summary = await fetchCloudSummaryWithAuthRetry(token);
if (!summary) return;
const bsplus = summary.bsplus;
const watermark = all[BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as string | undefined;
if (
bsplus &&
typeof bsplus.schemaVersion === "number" &&
bsplus.schemaVersion > CLOUD_SETTINGS_SYNC_SCHEMA_VERSION
) {
console.warn(
"[BS+ cloud sync] Server schemaVersion newer than client; skip auto-download",
);
return;
}
token = (await getAccessToken()) ?? token;
if (!watermark) {
if (!bsplus?.updated_at) {
await maybeUploadBaseline(token);
return;
}
await downloadIfNeeded(token);
return;
}
if (!bsplus?.updated_at) return;
if (isServerTimestampNewer(bsplus.updated_at, watermark)) {
await downloadIfNeeded(token);
}
}
export function runCloudSettingsPoll(): Promise<void> {
if (pollInFlight) return pollInFlight;
pollInFlight = (async () => {
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();
} catch (e) {
console.error("[BS+ cloud sync] Poll error:", e);
} finally {
pollInFlight = null;
}
})();
return pollInFlight;
}
function clearUploadDebounce(): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
}
function scheduleDebouncedUpload(): void {
if (suppressAutoUploadDuringRestore) return;
clearUploadDebounce();
debounceTimer = setTimeout(() => {
debounceTimer = null;
void runDebouncedUploadJob();
}, 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> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) return;
const token = await getAccessToken();
if (!token) return;
const res = await performCloudSettingsUploadWithRetry(token);
if (!res.success) {
console.warn("[BS+ cloud sync] Auto-upload failed:", res.error);
}
}
async function syncAutoUploadWithStorage(): Promise<void> {
const all = (await browser.storage.local.get()) as Record<string, unknown>;
if (!isAutoCloudSyncEnabled(all)) {
clearUploadDebounce();
}
}
function onStorageChanged(
changes: Record<string, browser.storage.StorageChange>,
area: string,
): void {
if (area !== "local") return;
if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) {
void syncAutoUploadWithStorage();
}
const keys = Object.keys(changes);
if (!keys.some((k) => isKeyIncludedInCloudUploadPayload(k))) return;
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();
})();
}
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
reloadSeqtaPagesFn = deps.reloadSeqtaPages;
browser.storage.onChanged.addListener(onStorageChanged);
}
+3 -2
View File
@@ -37,8 +37,9 @@
@layer base, override;
@layer override {
* {
font-family: Rubik, sans-serif !important;
.legacy-root,
.legacy-root * {
font-family: var(--betterseqta-font-family, Rubik), sans-serif !important;
}
.iconFamily,
+666 -3
View File
@@ -1,3 +1,4 @@
@use "sass:meta";
@import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,600,700");
@@ -118,7 +119,8 @@ select option {
#container {
background: var(--auto-background) !important;
}
:root * {
.legacy-root,
.legacy-root * {
font-family: Rubik, sans-serif !important;
--theme-fg-parts: white;
}
@@ -454,6 +456,58 @@ ul.magicDelete > li.deleting {
top: 71.5px;
margin-top: -2px;
}
/* Drill-in stack: only the current list + folder header stay clickable.
Class is toggled by updateSidebarAccessibility (never touches aria-hidden). */
#menu .bsplus-sidebar-offscreen,
#menu .bsplus-sidebar-offscreen * {
pointer-events: none !important;
user-select: none !important;
}
#menu > ul > .bsplus-sidebar-offscreen:not(.hasChildren.active) {
position: absolute !important;
left: -10000px !important;
width: 1px !important;
height: 1px !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
opacity: 0 !important;
}
#menu .sub .bsplus-sidebar-offscreen:not(.hasChildren.active) {
visibility: hidden !important;
position: absolute !important;
left: -10000px !important;
width: 1px !important;
height: 1px !important;
margin: 0 !important;
padding: 0 !important;
opacity: 0 !important;
}
/* Only the frontmost open .sub panel receives pointer events */
#menu .sub {
pointer-events: none;
}
#menu li.hasChildren.active > .sub {
pointer-events: auto;
}
#menu li.hasChildren.active > .sub:has(.hasChildren.active) {
pointer-events: none !important;
}
#menu li.hasChildren.active .hasChildren.active > .sub {
pointer-events: auto !important;
}
#menu:has(> ul > li.hasChildren.active) > ul > li:not(.hasChildren.active) {
pointer-events: none !important;
}
#menu section > label {
align-items: center;
box-sizing: border-box;
@@ -555,6 +609,31 @@ body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) {
flex-shrink: 0 !important;
}
/* Engage: hide text nodes in labels via font-size trick, restore SVG size */
#menu .logo-link li > label,
#menu .logo-link section > label {
font-size: 0 !important;
justify-content: center !important;
svg {
width: 24px !important;
height: 24px !important;
font-size: initial !important;
flex-shrink: 0 !important;
margin: 0 auto !important;
}
}
/* Engage: hide chevron arrows on hasChildren items */
#menu .logo-link li > svg {
display: none !important;
}
/* Engage: hide the name/details in #userActions */
#menu #userActions .details {
display: none !important;
}
}
[class*="notifications__items___"] {
-ms-overflow-style: none !important;
@@ -1628,6 +1707,13 @@ html.transparencyEffects
box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.4);
}
/* Smoothed by attachNotificationsPanelAnimation (matches #ExtensionPopup spring) */
.bsplus-notifications-panel {
transform-origin: top right;
will-change: opacity, transform;
filter: drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.35));
}
#menu li.active {
color: #ffffff !important;
background: rgba(0, 0, 0, 0.35);
@@ -2293,6 +2379,10 @@ blurred {
height: 64px;
cursor: pointer;
}
/* While a drill-in submenu is open, don't steal clicks meant for folder rows. */
#menu:has(li.hasChildren.active) > .icon-cover {
pointer-events: none;
}
.uiSlidePane > .pane > .header button {
color: var(--text-color) !important;
}
@@ -2577,7 +2667,7 @@ body {
[class*="MessageList__unread___"] {
position: relative;
background: rgb(228 225 225);
background: var(--background-secondary, rgb(228 225 225));
}
.dark [class*="MessageList__unread___"] {
@@ -2703,7 +2793,7 @@ body {
[class*="MessageList__MessageList___"]
> ol
> li[class*="MessageList__selected___"] {
background: rgb(228 225 225);
background: var(--background-secondary, rgb(228 225 225));
color: var(--text-primary);
box-shadow: none !important;
position: relative;
@@ -3296,6 +3386,88 @@ div.day-empty {
position: absolute;
right: 250px;
}
.engage-titlebar {
right: 250px;
top: 0;
}
/* Engage parent home: same timetable DOM as Learn; title+student replace the lone h2 — give the cluster Learns h2 margin/inset. */
.timetable-container .home-subtitle > .engage-timetable-title-cluster {
align-items: center;
box-sizing: border-box;
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 0.75rem 1rem;
margin: 20px;
min-width: 0;
}
.timetable-container .engage-timetable-title-cluster > h2 {
font-size: 20px;
font-weight: 400;
margin: 0 !important;
}
#engage-home-root.home-root {
box-sizing: border-box;
min-height: 100%;
}
.engage-child-select {
background: var(--background-primary);
border: 1px solid var(--border-primary, rgba(128, 128, 128, 0.35));
border-radius: 0.5rem;
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25;
max-width: 16rem;
min-width: 10rem;
padding: 0.35rem 0.6rem;
transition: border-color 0.2s ease-in-out, color 0.2s ease-in-out;
}
.engage-child-select:focus {
outline: none;
box-shadow: 0 0 0 2px var(--background-primary), 0 0 0 4px rgba(59, 130, 246, 0.45);
}
#engage-day-container:has(> .day-empty) {
align-content: center;
display: flex;
grid-auto-columns: unset;
grid-auto-flow: unset;
justify-content: center;
min-height: 12rem;
padding: 1.5rem;
}
#engage-day-container .day-empty {
text-align: center;
}
#engage-logouttooltip {
width: 50px !important;
margin-left: -28px !important;
top: 105% !important;
.engage-logout {
background: none !important;
border: none;
cursor: pointer;
color: var(--background-primary) !important;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
svg {
fill: var(--background-primary) !important;
}
}
}
.pagename {
align-items: center;
display: flex;
@@ -3387,6 +3559,32 @@ div.day-empty {
color: var(--text-primary);
transform-origin: center center;
}
/* Text-only popups (privacy notices): body fills remaining height, scrolls inside */
.whatsnewContainer.whatsnewContainer--scrollBody {
.whatsnewHeader {
flex-shrink: 0;
height: auto;
min-height: 3em;
}
> .whatsnewTextContainer {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
max-height: none;
width: 90%;
margin: 0 auto 0.75rem;
padding-bottom: 0.5rem;
box-sizing: border-box;
}
> .whatsnewTextContainer.privacyStatement {
font-size: 1.1rem;
line-height: 1.6;
}
}
.whatsnewTextContainer.privacyStatement p {
margin-bottom: 1.5ex;
@@ -3417,6 +3615,26 @@ div.day-empty {
font-size: 1em;
color: var(--text-primary);
}
.whatsnewHeader.engageParentsAnnouncementHeader {
height: auto;
min-height: unset;
}
.whatsnewHeader.engageParentsAnnouncementHeader h1 {
line-height: 1.2;
}
.whatsnewHeader.engageParentsAnnouncementHeader .engageParentsSubheading {
margin-top: 0.35rem;
font-size: 1.05rem;
font-weight: 600;
opacity: 0.92;
}
.seqtaEngageAccent {
color: #ea580c;
font-weight: 700;
}
.dark .seqtaEngageAccent {
color: #fb923c;
}
.whatsnewBackground {
width: 100%;
height: 100%;
@@ -3545,6 +3763,388 @@ div.day-empty {
object-fit: cover;
margin-bottom: 12px;
}
.whatsnewTextContainer .engageParentsPromoWrap {
width: 100%;
margin-bottom: 12px;
border-radius: 16px;
overflow: hidden;
aspect-ratio: 16 / 9;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.28);
background: color-mix(in srgb, var(--background-secondary) 88%, var(--text-primary) 12%);
}
.whatsnewTextContainer .engageParentsPromoWrap .engageParentsPromoImg {
display: block;
width: 100%;
height: 100%;
margin: 0;
border-radius: 0;
aspect-ratio: unset;
object-fit: contain;
object-position: center;
}
.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader {
height: auto;
min-height: unset;
}
.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader h1 {
line-height: 1.2;
}
.bsCloudAccent {
color: #059669;
font-weight: 700;
}
.dark .bsCloudAccent {
color: #34d399;
}
.whatsnewTextContainer .bsCloudAutoSyncSignupCallout {
margin: 1.5rem 0 0;
padding: 1.25rem 1rem 0;
border-top: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
font-size: clamp(1.35rem, 3.8vw, 1.85rem);
font-weight: 800;
line-height: 1.35;
letter-spacing: -0.02em;
text-align: center;
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 {
cursor: pointer;
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
}
.popup-media-fullscreenable:hover {
opacity: 0.95;
}
.popup-media-fullscreenable:focus {
outline: none;
}
.popup-media-fullscreenable:focus-visible {
outline: 2px solid color-mix(in srgb, var(--text-primary) 70%, transparent);
outline-offset: 4px;
}
.bsplus-popup-media-overlay-backdrop {
position: fixed;
inset: 0;
z-index: 2147483646;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(20px, 4vw, 48px);
box-sizing: border-box;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
opacity: 0;
transition: opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1);
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible {
opacity: 1;
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant {
transition: none;
}
.bsplus-popup-media-overlay-inner {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
max-width: min(96vw, 1320px);
max-height: calc(100vh - clamp(40px, 10vw, 96px));
border-radius: 20px;
overflow: hidden;
background: var(--background-primary);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
opacity: 0;
transform: scale(0.94) translateY(12px);
transition:
opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1),
transform 0.28s cubic-bezier(0.22, 0.03, 0.26, 1);
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible
.bsplus-popup-media-overlay-inner {
opacity: 1;
transform: scale(1) translateY(0);
}
.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant
.bsplus-popup-media-overlay-inner {
transition: none;
}
.bsplus-popup-media-overlay-slot {
width: 100%;
max-height: inherit;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(16px, 3vw, 28px);
box-sizing: border-box;
}
.bsplus-popup-media-overlay-media {
max-width: 100%;
max-height: calc(100vh - clamp(120px, 22vh, 200px));
width: auto;
height: auto;
object-fit: contain;
border-radius: 12px;
}
@keyframes shimmer {
0% {
@@ -4105,3 +4705,66 @@ h2.home-subtitle {
font-size: 20px;
font-weight: 400;
}
.bsplus-toast {
position: fixed;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
z-index: 10000;
width: min(360px, calc(100vw - 36px));
padding: 14px 16px 16px;
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
border-radius: 20px;
background: var(--background-primary, #fff);
color: var(--text-primary, #1a1a1a);
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
font-size: 0.9rem;
line-height: 1.45;
}
.bsplus-toast-eyebrow {
margin: 0 0 6px !important;
font-size: 0.72rem !important;
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 {
position: absolute !important;
top: 4px !important;
right: 4px !important;
z-index: 2;
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;
font-size: 1.35rem;
line-height: 1;
transition: filter 0.15s ease;
}
.bsplus-toast-close:hover {
filter: brightness(1.08);
}
+4
View File
@@ -0,0 +1,4 @@
declare const __ENABLE_GH_RELEASE_UPDATE_CHECK__: boolean;
declare const __GH_RELEASE_REPO__: string;
declare const __UPDATE_CHANNEL__: "stable" | "nightly";
declare const __BUILD_LABEL__: string;
+15 -2
View File
@@ -1,7 +1,20 @@
<script lang="ts">
let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>();
let {
onClick,
text,
disabled = false,
} = $props<{
onClick: () => void;
text: string;
disabled?: boolean;
}>();
</script>
<button onclick={onClick} class='px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg'>
<button
type="button"
onclick={onClick}
disabled={disabled}
class="px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
>
{text}
</button>
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from "svelte";
import { animate } from "motion";
import { delay } from "@/seqta/utils/delay.ts";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
const { hidePanel } = $props<{
hidePanel: () => void;
}>();
let cloudState = $state(cloudAuth.state);
let background = $state<HTMLDivElement | null>(null);
let content = $state<HTMLDivElement | null>(null);
let loginError = $state<string | null>(null);
onMount(() => {
const unsub = cloudAuth.subscribe((s) => {
cloudState = s;
});
if (background && content) {
animate(
background,
{ opacity: [0, 1] },
{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }
);
animate(
content,
{ scale: [0.4, 1], opacity: [0, 1] },
{ type: "spring", stiffness: 400, damping: 30 }
);
}
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closePanel();
};
document.addEventListener("keydown", handleEscapeKey);
return () => {
unsub();
document.removeEventListener("keydown", handleEscapeKey);
};
});
async function closePanel() {
if (!background || !content) return;
animate(
content,
{ scale: [1, 0.4], opacity: [1, 0] },
{ type: "spring", stiffness: 400, damping: 30 }
);
animate(
background,
{ opacity: [1, 0] },
{ ease: [0.4, 0, 0.2, 1] }
);
await delay(400);
hidePanel();
}
function handleBackgroundClick(event: MouseEvent) {
if (event.target === background) closePanel();
}
async function handleSignIn() {
loginError = null;
const result = await cloudAuth.startLogin();
if (result.success) {
closePanel();
} else {
loginError = result.error ?? "Failed to open login page";
}
}
async function handleLogout() {
await cloudAuth.logout();
}
function getInitials(): string {
const u = cloudState.user;
if (!u) return "?";
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
if (u.username) return u.username.slice(0, 2).toUpperCase();
if (u.email) return u.email.slice(0, 2).toUpperCase();
return "?";
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={background}
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50"
onclick={handleBackgroundClick}
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }}
>
<div
bind:this={content}
class="p-5 w-[320px] bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
>
<h3 class="text-lg font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
<p class="mt-0.5 text-sm text-zinc-500 dark:text-zinc-400">Account & sync</p>
<div class="mt-4">
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()}
</div>
{/if}
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-zinc-900 dark:text-white truncate">
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</p>
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
<p class="text-xs text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
{/if}
</div>
</div>
<button
type="button"
onclick={handleLogout}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Sign out
</button>
</div>
{:else}
<div class="flex flex-col gap-3">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Sign in to sync settings across devices, use your cloud profile picture, and more.
</p>
<button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
{#if loginError}
<p class="text-xs text-red-600 dark:text-red-400">{loginError}</p>
{/if}
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,120 @@
<script lang="ts">
import browser from "webextension-polyfill";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import Button from "./Button.svelte";
import Switch from "./Switch.svelte";
let { showDisclaimer } = $props<{
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
}>();
let cloudState = $state(cloudAuth.state);
let busy = $state(false);
let statusMessage = $state<string | null>(null);
let statusError = $state<string | null>(null);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => {
cloudState = s;
});
return unsub;
});
async function upload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsUpload",
token,
})) as { success?: boolean; error?: string };
if (res?.success) {
statusMessage = "Settings uploaded.";
} else {
statusError = res?.error ?? "Upload failed";
}
} catch (e) {
statusError = e instanceof Error ? e.message : "Upload failed";
} finally {
busy = false;
}
}
function promptDownload() {
showDisclaimer(confirmDownload, () => {});
}
async function confirmDownload() {
const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true;
statusError = null;
statusMessage = null;
try {
const res = (await browser.runtime.sendMessage({
type: "cloudSettingsDownload",
token,
})) as { success?: boolean; error?: string; notFound?: boolean };
if (res?.success) {
statusMessage = "Settings restored.";
} else {
statusError = res?.error ?? "Download failed";
}
} catch (e) {
statusError = e instanceof Error ? e.message : "Download failed";
} finally {
busy = false;
}
}
</script>
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-2.5">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-[11px] font-semibold text-zinc-800 dark:text-zinc-100">Automatic sync</p>
<p class="text-[10px] text-zinc-500 dark:text-zinc-400">Syncs settings when SEQTA loads and when you make changes</p>
</div>
<div class="shrink-0">
<Switch
state={$settingsState.autoCloudSettingsSync !== false}
onChange={(isOn: boolean) => (settingsState.autoCloudSettingsSync = isOn)}
/>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button
text={busy ? "Please wait\u2026" : "Upload"}
onClick={upload}
disabled={busy}
/>
<Button
text={busy ? "Please wait\u2026" : "Download"}
onClick={promptDownload}
disabled={busy}
/>
</div>
{#if statusMessage}
<p class="text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
{/if}
{#if statusError}
<p class="text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
{/if}
<p class="text-[10px] text-zinc-400 dark:text-zinc-500">
Passwords and tokens are never synced.
<a
href="https://betterseqta.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 rounded-sm"
>Privacy policy</a>
</p>
</div>
{/if}
@@ -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>
@@ -1,29 +1,35 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { animate } from "motion";
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let { onClose } = $props<{ onClose: () => void }>();
let modalElement: HTMLElement;
onMount(() => {
return cloudAuth.subscribe((s) => {
if (s.isLoggedIn) onClose();
});
});
$effect(() => {
if (modalElement) {
animate(modalElement, { scale: [0.9, 1], opacity: [0, 1] }, { type: "spring", stiffness: 300, damping: 25 });
animate(
modalElement,
{ scale: [0.9, 1], opacity: [0, 1] },
{ type: "spring", stiffness: 300, damping: 25 },
);
}
});
function handleSignIn() {
onClose();
if (document.getElementById("ExtensionPopup")) {
closeExtensionPopup();
} else {
window.close();
}
async function handleSignIn() {
await cloudAuth.startLogin();
}
</script>
<div
class="flex fixed inset-0 z-[10000] justify-center items-center bg-black/50"
class="flex fixed inset-0 z-[99999] justify-center items-center bg-black/50"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
@@ -37,7 +43,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={modalElement}
class="p-4 mx-4 w-full max-w-md bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white"
class="p-4 mx-4 w-full max-w-md max-h-[90vh] overflow-y-auto bg-white rounded-2xl shadow-2xl dark:bg-zinc-800 dark:text-white"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
@@ -45,32 +51,28 @@
Sign in to favorite themes
</h2>
<p class="mb-6 text-zinc-600 dark:text-zinc-400">
Sign in in the Theme Store to save favorites across devices, or create an account to get started.
<p class="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
Sign in to the Theme Store to save favorites across devices, or create an account to get started.
</p>
<div class="flex flex-wrap gap-2 justify-end">
<button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
<p class="mt-2 text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
<div class="flex justify-end mt-4">
<button
type="button"
onclick={onClose}
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
OK
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
>
Create account
</a>
<button
type="button"
onclick={handleSignIn}
class="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in
Close
</button>
</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);
}
@@ -2,10 +2,11 @@
import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let username = $state("");
let password = $state("");
let loading = $state(false);
let error = $state<string | null>(null);
let { alwaysShowUserName = false, onClick = undefined } = $props<{
alwaysShowUserName?: boolean;
onClick?: () => void;
}>();
let cloudState = $state(cloudAuth.state);
let open = $state(false);
let dropdownEl: HTMLElement;
@@ -35,32 +36,24 @@
}
});
async function handleLogin() {
if (loading) return;
error = null;
if (!username.trim() || !password) {
error = "Please enter username and password";
return;
}
loading = true;
try {
const result = await cloudAuth.login(username.trim(), password);
if (result.success) {
password = "";
open = false;
} else {
error = result.error ?? "Login failed";
}
} finally {
loading = false;
}
}
async function handleLogout() {
await cloudAuth.logout();
open = false;
}
async function handleSignIn() {
await cloudAuth.startLogin();
open = false;
}
function handleButtonClick() {
if (onClick) {
onClick();
} else {
open = !open;
}
}
function getInitials(): string {
const u = cloudState.user;
if (!u) return "?";
@@ -74,31 +67,35 @@
<div class="relative flex items-center" bind:this={dropdownEl}>
<button
type="button"
onclick={() => (open = !open)}
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-100/80 dark:bg-zinc-700/80 hover:bg-zinc-200/80 dark:hover:bg-zinc-600/80 transition-colors duration-200 text-base font-medium text-zinc-900 dark:text-white"
onclick={handleButtonClick}
class="flex items-center gap-2 px-3 py-1.5 text-[0.75rem] rounded-lg shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white transition-colors duration-200"
>
{#if cloudState.isLoggedIn}
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-8 h-8 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-sm">
<div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
{getInitials()}
</div>
{/if}
<span class="hidden max-w-24 truncate sm:inline text-base">
<span
class={alwaysShowUserName
? "inline max-w-[10rem] truncate text-[0.75rem]"
: "hidden max-w-24 truncate sm:inline text-[0.75rem]"}
>
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</span>
{:else}
<span class="text-xl font-IconFamily" aria-hidden="true">{'\ued53'}</span>
<span class="text-base font-medium">Sign in</span>
<span class="text-sm font-IconFamily" aria-hidden="true">{'\ued53'}</span>
<span class="text-[0.75rem]">Sign in</span>
{/if}
</button>
{#if open}
{#if !onClick && open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
@@ -142,58 +139,21 @@
</button>
</div>
{:else}
<p class="mb-4 text-base text-zinc-600 dark:text-zinc-400">
Sign in to favorite themes. Your favorites sync across devices when logged in.
</p>
<form
class="flex flex-col gap-3"
autocomplete="off"
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
<input
type="text"
name="betterseqta-cloud-username"
autocomplete="off"
placeholder="Email or username"
bind:value={username}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute('readonly')}
class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
/>
<input
type="password"
name="betterseqta-cloud-password"
autocomplete="new-password"
placeholder="Password"
bind:value={password}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute('readonly')}
class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
/>
{#if error}
<p class="text-base text-red-600 dark:text-red-400">{error}</p>
{/if}
<div class="flex flex-col gap-3">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Sign in to sync favorites across devices.
</p>
<button
type="submit"
disabled={loading}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200"
type="button"
onclick={handleSignIn}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
{loading ? "Signing in..." : "Sign in"}
Sign in with BetterSEQTA Cloud
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
>
Create account
</a>
</form>
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
</div>
{/if}
</div>
</div>
@@ -0,0 +1,111 @@
<script lang="ts">
import { cloudAuth } from "@/seqta/utils/CloudAuth";
let {
introText,
onSuccess,
compact = false,
} = $props<{
introText?: string;
onSuccess?: () => void;
/** Smaller padding/text for overlays (e.g. SignInToFavoriteModal) */
compact?: boolean;
}>();
let username = $state("");
let password = $state("");
let loading = $state(false);
let error = $state<string | null>(null);
const inputClass = $derived(
compact
? "w-full px-4 py-2 text-sm rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200"
: "w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200",
);
const btnClass = $derived(
compact
? "w-full px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200"
: "w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 disabled:opacity-50 transition-colors duration-200",
);
const linkClass = $derived(
compact
? "inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200"
: "inline-flex items-center justify-center gap-2 px-4 py-3 text-base font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200",
);
async function handleLogin() {
if (loading) return;
error = null;
if (!username.trim() || !password) {
error = "Please enter username and password";
return;
}
loading = true;
try {
const result = await cloudAuth.login(username.trim(), password);
if (result.success) {
password = "";
onSuccess?.();
} else {
error = result.error ?? "Login failed";
}
} finally {
loading = false;
}
}
</script>
{#if introText}
<p
class="mb-4 text-zinc-600 dark:text-zinc-400 {compact ? 'text-sm' : 'text-base'}"
>
{introText}
</p>
{/if}
<form
class="flex flex-col gap-3"
autocomplete="off"
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
<input
type="text"
name="betterseqta-cloud-username"
autocomplete="off"
placeholder="Email or username"
bind:value={username}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute("readonly")}
class={inputClass}
/>
<input
type="password"
name="betterseqta-cloud-password"
autocomplete="new-password"
placeholder="Password"
bind:value={password}
disabled={loading}
readonly
onfocus={(e) => e.currentTarget.removeAttribute("readonly")}
class={inputClass}
/>
{#if error}
<p class="text-red-600 dark:text-red-400 {compact ? 'text-sm' : 'text-base'}">{error}</p>
{/if}
<button type="submit" disabled={loading} class={btnClass}>
{loading ? "Signing in..." : "Sign in"}
</button>
<a
href="https://accounts.betterseqta.org/register"
target="_blank"
rel="noopener noreferrer"
class={linkClass}
>
Create account
</a>
</form>
@@ -1,20 +1,27 @@
<script lang="ts">
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 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();
const options = { loop: true };
const plugins = [
Autoplay({
delay: 5000,
stopOnInteraction: false,
stopOnMouseEnter: true
})
];
const options = $derived({ loop: slides.length > 1 });
const plugins = $derived(
slides.length > 1
? [
Autoplay({
delay: 5000,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
]
: [],
);
function onInit(event: CustomEvent) {
emblaApi = event.detail;
@@ -26,41 +33,77 @@
const slideNext = () => emblaApi?.scrollNext();
</script>
{#if coverThemes.length > 0}
{#if slides.length > 0}
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
<div
class="w-full aspect-[5/1] max-h-[500px]"
use:emblaCarouselSvelte={{ options, plugins }}
<div
class="w-full aspect-[5/1] max-h-[500px]"
use:emblaCarouselSvelte={{ options, plugins }}
onemblaInit={onInit}
>
<div class="flex">
{#each coverThemes as theme}
{#each slides as slide (slide.imageUrl + slide.title + (slide.subtitle ?? ''))}
<div
class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip"
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
onclick={() => setDisplayTheme(theme)}
onkeydown={(e) => {
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" />
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
<p class='text-lg text-white'>{theme.description}</p>
<img src={slide.imageUrl} alt="" class="object-cover w-full h-full" />
{#if slide.badgeFeatured === true}
<div class="absolute top-4 left-4 z-[2] pointer-events-none">
<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"
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">
<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>
Featured
</span>
</div>
{/if}
<div class="absolute bottom-0 left-0 p-8 z-[1]">
<h2 class="text-4xl font-bold text-white">{slide.title}</h2>
{#if slide.subtitle}
<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}
</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>
{/each}
</div>
</div>
<!-- Navigation buttons -->
<div class='flex absolute right-2 bottom-2 z-10 gap-2'>
<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'>
<div class="flex absolute right-2 bottom-2 z-10 gap-2">
<button
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">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
</svg>
</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">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
+106 -15
View File
@@ -1,17 +1,55 @@
<script lang="ts">
import type { Theme } from '@/interface/types/Theme'
import {
masterGridDisplayDownloadCount,
gridCardPreviewImageUrls,
} from '@/interface/utils/themeStoreFlavours'
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte';
let { theme, onClick, toggleFavorite, isLoggedIn } = $props<{
import emblaCarouselSvelte from 'embla-carousel-svelte';
import Autoplay from 'embla-carousel-autoplay';
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn, allStoreThemeRows } = $props<{
theme: Theme;
onClick: () => void;
toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean;
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 showSignInModal = $state(false);
let menuRef: HTMLDivElement;
onMount(() => {
@@ -34,14 +72,36 @@
if (isLoggedIn) {
toggleFavorite(theme);
} else {
showSignInModal = true;
onRequestSignIn?.();
}
menuOpen = false;
}
</script>
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={handleCardClick}>
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
<div
class="relative z-0 hover:z-20 w-full cursor-pointer"
role="button"
tabindex="-1"
onkeydown={onClick}
onclick={handleCardClick}
>
<div
class="bg-gray-50 w-full transition-all duration-500 ease-out relative group flex flex-col rounded-xl overflow-clip border hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto"
transition:fade
>
{#if theme.featured === true}
<div class="absolute top-2 left-2 z-20 pointer-events-none">
<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"
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">
<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>
Featured
</span>
</div>
{/if}
<!-- Menu dropdown -->
<div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}>
<button
@@ -83,12 +143,15 @@
</div>
<div class="absolute bottom-1 left-3 right-3 z-10 mb-1 flex flex-col gap-0.5">
<span class="text-xl font-bold text-white drop-shadow-md">{theme.name}</span>
{#if theme.author}
<span class="text-xs text-white/85 drop-shadow-md line-clamp-1">By {theme.author}</span>
{/if}
<div class="flex gap-3 text-xs font-medium text-white/90 drop-shadow-sm">
<span class="flex items-center gap-1">
<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" />
</svg>
{(theme.download_count ?? 0).toLocaleString()}
{displayDownloadCount.toLocaleString()}
</span>
<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">
@@ -99,12 +162,40 @@
</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='w-full'>
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
</div>
{#if gridRotatorUrls.length === 0}
<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>
{/key}
{/if}
</div>
</div>
{#if showSignInModal}
<SignInToFavoriteModal onClose={() => (showSignInModal = false)} />
{/if}
@@ -2,17 +2,31 @@
import type { Theme } from '@/interface/types/Theme'
import ThemeCard from './ThemeCard.svelte';
let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn } = $props<{
let {
themes,
searchTerm,
setDisplayTheme,
toggleFavorite,
isLoggedIn,
onRequestSignIn,
allStoreThemeRows,
} = $props<{
themes: Theme[];
searchTerm: string;
setDisplayTheme: (theme: Theme) => void;
toggleFavorite: (theme: Theme) => void;
isLoggedIn: boolean;
onRequestSignIn?: () => void;
/** Raw API list (includes `slave` rows) for master download aggregation */
allStoreThemeRows?: Theme[];
}>();
let filteredThemes = $derived(themes.filter((theme: Theme) =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
));
let filteredThemes = $derived(themes.filter((theme: Theme) => {
const q = searchTerm.toLowerCase();
const name = (theme.name ?? '').toLowerCase();
const description = (theme.description ?? '').toLowerCase();
return name.includes(q) || description.includes(q);
}));
</script>
<div class="relative" >
@@ -23,12 +37,14 @@
onClick={() => setDisplayTheme(theme)}
{toggleFavorite}
{isLoggedIn}
{onRequestSignIn}
{allStoreThemeRows}
/>
{/each}
{#if filteredThemes.length !== 0}
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='w-full cursor-pointer'>
<div class="bg-zinc-50 h-48 w-full transition-all hover:scale-105 duration-500 relative justify-center items-center group group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] rounded-xl overflow-clip border">
<a href="https://docs.betterseqta.org/theme-creation/" class="block relative z-0 hover:z-20 w-full cursor-pointer">
<div class="bg-zinc-50 h-48 w-full transition-all duration-500 ease-out relative overflow-clip rounded-xl border group group/card flex flex-col justify-center items-center hover:scale-105 hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1]">
<div class="text-2xl font-IconFamily">{'\uecb3'}</div>
<div class="text-xl font-bold text-center transition-all duration-500 dark:text-white">
Got a Theme Idea?
@@ -43,7 +59,7 @@
<div class="absolute top-0 flex flex-col items-center justify-center w-full text-center h-96">
<h1 class="mt-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl">That doesn't exist! 😭😭😭</h1>
<p class="mt-6 text-lg leading-7 text-zinc-600 dark:text-zinc-300">Sorry, we couldn't find the theme you're looking for. Maybe... you could create it?</p>
<a href="https://betterseqta.gitbook.io/betterseqta-docs" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
<a href="https://docs.betterseqta.org/theme-creation/" class='p-2 px-3 mt-4 transition rounded-md cursor-pointer dark:text-white bg-zinc-500/10 hover:scale-105'>
Show me how!
</a>
</div>
File diff suppressed because it is too large Load Diff
@@ -85,7 +85,7 @@
try {
const result = JSON.parse(event.target?.result as string);
tempTheme = result;
await themeManager.installTheme(result);
await themeManager.installTheme(result, { fromStore: false });
await fetchThemes();
} catch (error) {
console.error('Error parsing file:', error);
+83 -13
View File
@@ -15,13 +15,35 @@
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
import ColourPicker from "../components/ColourPicker.svelte";
import FontPickerModal from "../components/FontPickerModal.svelte";
import CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "../hooks/SettingsPopup";
import {
checkGithubReleaseUpdate,
dismissNightlyUpdate,
isGhReleaseUpdateCheckEnabled,
type GhReleaseUpdateInfo,
} from "@/utils/githubReleaseUpdate";
let devModeSequence = "";
let settingsActiveTab = $state(0);
let showDisclaimerModal = $state(false);
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
let disclaimerTitle = $state("Confirm");
let disclaimerMessage = $state("");
const ghReleaseUpdateEnabled = isGhReleaseUpdateCheckEnabled();
let ghReleaseUpdate = $state<GhReleaseUpdateInfo | null>(null);
const openGhRelease = () => {
const url = ghReleaseUpdate?.url
?? "https://github.com/BetterSEQTA/BetterSEQTA-Plus/releases";
if (ghReleaseUpdate?.available) {
dismissNightlyUpdate();
}
window.open(url, "_blank");
closeExtensionPopup();
};
const handleDevModeToggle = () => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -44,6 +66,10 @@
showColourPicker = true;
};
const openFontPicker = () => {
showFontPicker = true;
};
const openChangelog = () => {
OpenWhatsNewPopup();
closeExtensionPopup();
@@ -66,25 +92,41 @@
let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state<boolean>(false);
let showFontPicker = $state<boolean>(false);
let showCloudPanel = $state<boolean>(false);
const showDisclaimer = (onConfirm: () => void, onCancel: () => void) => {
const openCloudPanel = () => {
showCloudPanel = true;
};
const showDisclaimer = (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => {
disclaimerCallbacks = { onConfirm, onCancel };
disclaimerTitle = title ?? "Confirm";
disclaimerMessage = message ?? "";
showDisclaimerModal = true;
};
onMount(() => {
settingsPopup.addListener(() => {
showColourPicker = false;
showFontPicker = false;
showCloudPanel = false;
});
if (standalone) {
StandaloneStore.setStandalone(true);
}
if (ghReleaseUpdateEnabled) {
void checkGithubReleaseUpdate().then((info) => {
ghReleaseUpdate = info;
});
}
});
</script>
<div
class="w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode
class="relative w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode
? 'dark'
: ''} {standalone ? 'h-[600px]' : 'h-full rounded-xl'} overflow-clip"
>
@@ -116,7 +158,25 @@
/>
{#if !standalone}
<div class="flex absolute top-1 right-1 gap-1 items-center">
<div class="flex absolute top-1 right-1 gap-1 items-start">
{#if ghReleaseUpdateEnabled}
<div class="flex flex-col items-end gap-0.5 max-w-[9rem] mr-0.5">
{#if ghReleaseUpdate?.available}
<button
type="button"
onclick={openGhRelease}
class="px-1.5 py-0.5 text-[10px] font-semibold leading-tight text-white rounded-full bg-amber-500 hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-500"
title="Open GitHub release"
>
Update available — {ghReleaseUpdate.label}
</button>
{/if}
<p class="text-[9px] leading-tight text-right text-zinc-500 dark:text-zinc-400">
GitHub release build — do not upload to extension stores.
</p>
</div>
{/if}
<div class="flex gap-1 items-center">
<button
onclick={openAbout}
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
@@ -138,6 +198,7 @@
>
{"\uecba"}
</button>
</div>
<!-- <button
onclick={openMinecraftServer}
@@ -282,7 +343,7 @@
{
title: "Settings",
Content: Settings,
props: { showColourPicker: openColourPicker, showDisclaimer },
props: { showColourPicker: openColourPicker, showFontPicker: openFontPicker, showDisclaimer, showCloudPanel: openCloudPanel },
},
{ title: "Shortcuts", Content: Shortcuts },
{ title: "Themes", Content: Theme },
@@ -297,19 +358,28 @@
}}
/>
{/if}
{#if showCloudPanel}
<CloudPanel
hidePanel={() => {
showCloudPanel = false;
}}
/>
{/if}
</div>
{#if showFontPicker}
<FontPickerModal
hidePicker={() => {
showFontPicker = false;
}}
/>
{/if}
{#if showDisclaimerModal && disclaimerCallbacks}
<DisclaimerModal
title="Assessment Averages Disclaimer"
message="This feature calculates a simple average of your assessment grades. It does not take into account:
• Assessment weightings
• Different grading scales
• Other factors used in official reports
The displayed average may be inaccurate compared to your actual marks found in reports.
Do you want to enable this feature?"
title={disclaimerTitle}
message={disclaimerMessage}
onConfirm={() => {
disclaimerCallbacks?.onConfirm();
showDisclaimerModal = false;
+192 -19
View File
@@ -11,10 +11,41 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
import CloudSettingsSync from "@/interface/components/CloudSettingsSync.svelte"
import CloudHeader from "@/interface/components/store/CloudHeader.svelte"
import { cloudAuth } from "@/seqta/utils/CloudAuth"
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
import { showThemeOfTheMonthPopupNow } from "@/seqta/utils/Openers/OpenThemeOfTheMonthPopup"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase"
let devApiBaseInput = $state<string>(getStoredOverride() ?? "")
let devApiBaseActive = $state<string | null>(getStoredOverride())
function applyDevApiBase() {
const trimmed = devApiBaseInput.trim()
if (trimmed === "") {
setApiBase(null)
devApiBaseActive = null
return
}
if (!/^https?:\/\//.test(trimmed)) {
alert("Please enter a full URL starting with http:// or https://")
return
}
setApiBase(trimmed)
devApiBaseActive = trimmed.replace(/\/$/, "")
}
function clearDevApiBase() {
devApiBaseInput = ""
setApiBase(null)
devApiBaseActive = null
}
import { getAllPluginSettings } from "@/plugins"
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
// Union type representing all possible settings
@@ -49,8 +80,16 @@
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>>>({});
let cloudState = $state(cloudAuth.state);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => { cloudState = s; });
return unsub;
});
async function loadPluginSettings() {
for (const plugin of pluginSettings) {
@@ -93,10 +132,25 @@
loadPluginSettings();
})
const { showColourPicker, showDisclaimer } = $props<{
const { showColourPicker, showFontPicker, showDisclaimer, showCloudPanel } = $props<{
showColourPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
showFontPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void;
showCloudPanel: () => void;
}>();
async function exportCloudSettingsJsonToFile() {
const payload = await getSnapshotForUpload();
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `betterseqta-plus-settings-export-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
a.click();
URL.revokeObjectURL(url);
}
</script>
{#snippet Setting({ title, description, Component, props }: SettingsList) }
@@ -139,6 +193,16 @@
onClick: showColourPicker
}
},
{
title: "Interface Font",
description: "Choose the typeface used across SEQTA Learn",
id: 16,
Component: Button,
props: {
onClick: showFontPicker,
text: "Change"
}
},
{
title: "Icon Only Sidebar",
description: "Show only icons in the sidebar for a compact layout",
@@ -181,22 +245,22 @@
},
{
title: "Default Page",
description: "The page to load when SEQTA Learn is opened",
description: "Choose which page loads first when you open SEQTA",
id: 10,
Component: Select,
props: {
state: $settingsState.defaultPage,
onChange: (value: string) => settingsState.defaultPage = value,
state: $settingsState.defaultPage ?? "home",
onChange: (value: string) => (settingsState.defaultPage = value),
options: [
{ value: 'home', label: 'Home' },
{ value: 'dashboard', label: 'Dashboard' },
{ value: 'timetable', label: 'Timetable' },
{ value: 'welcome', label: 'Welcome' },
{ value: 'messages', label: 'Messages' },
{ value: 'documents', label: 'Documents' },
{ value: 'reports', label: 'Reports' },
]
}
{ value: "home", label: "Home" },
{ value: "dashboard", label: "Dashboard" },
{ value: "timetable", label: "Timetable" },
{ value: "welcome", label: "Welcome" },
{ value: "messages", label: "Messages" },
{ value: "documents", label: "Documents" },
{ value: "reports", label: "Reports" },
],
},
},
{
title: "News Feed Source",
@@ -252,6 +316,18 @@
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
<div class="pr-4">
<h2 class="text-sm font-bold">Smooth colour transition</h2>
<p class="text-xs">Ease between class/subject colours when navigating instead of switching instantly</p>
</div>
<div>
<Switch
state={$settingsState.adaptiveThemeColourTransition ?? true}
onChange={(isOn: boolean) => settingsState.adaptiveThemeColourTransition = isOn}
/>
</div>
</div>
{/if}
</div>
</div>
@@ -282,8 +358,9 @@
async () => {
await updatePluginSetting(plugin.pluginId, 'enabled', true);
},
() => {
}
() => {},
"Assessment Averages Disclaimer",
"This feature calculates a simple average of your assessment grades. It does not take into account:\n• Assessment weightings\n• Different grading scales\n• Other factors used in official reports\n\nThe displayed average may be inaccurate compared to your actual marks found in reports.\n\nDo you want to enable this feature?"
);
return;
}
@@ -296,8 +373,8 @@
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]}
<!-- Skip the 'enabled' setting if it's part of the settings object -->
{#if key !== 'enabled'}
<!-- Skip the 'enabled' setting and hide cloud-only settings when not signed in -->
{#if key !== 'enabled' && !(key === 'useCloudPfp' && !cloudState.isLoggedIn)}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">{setting.title || key}</h2>
@@ -357,9 +434,40 @@
{/each}
{/if}
</div>
{#if plugin.pluginId === 'global-search'}
{@render Setting({
title: "Theme of the Month",
description: "Show the monthly featured theme popup when a new entry is available",
id: 15,
Component: Switch,
props: {
state: !($settingsState.themeOfTheMonthDisabled ?? false),
onChange: (isOn: boolean) => settingsState.themeOfTheMonthDisabled = !isOn
}
})}
{/if}
</div>
{/each}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">BetterSEQTA Cloud</h2>
<p class="text-xs">Account & sync</p>
</div>
<div>
<CloudHeader alwaysShowUserName onClick={showCloudPanel} />
</div>
</div>
{#if cloudState.isLoggedIn}
<div class="px-3 pb-3">
<CloudSettingsSync showDisclaimer={(onConfirm, onCancel) => showDisclaimer(onConfirm, onCancel, "Restore from cloud?", "This will replace your local settings with the cloud backup. Continue?")} />
</div>
{/if}
</div>
</div>
<div class="p-1 border-none"></div>
{@render Setting({
@@ -427,6 +535,71 @@
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Show Theme of the Month</h2>
<p class="text-xs">Fetch and show the current month's popup now (ignores dismissed state)</p>
</div>
<div>
<Button
onClick={async () => {
closeExtensionPopup();
await new Promise((resolve) => setTimeout(resolve, 100));
await showThemeOfTheMonthPopupNow();
}}
text="Show Now"
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
<p class="text-xs">Download the same payload as cloud sync (OAuth tokens stripped). For debugging and server testing.</p>
</div>
<div>
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
</div>
</div>
<div class="flex flex-col gap-2 px-4 py-3">
<div class="flex justify-between items-start gap-3">
<div class="pr-4">
<h2 class="text-sm font-bold">API Base URL (session only)</h2>
<p class="text-xs">Override the content API host for this browser session. Cleared on restart. Affects themes, theme of the month, and other server-driven content.</p>
{#if devApiBaseActive}
<p class="text-xs mt-1 text-amber-600 dark:text-amber-400">
Override active: <span class="font-mono">{devApiBaseActive}</span>
</p>
{/if}
</div>
</div>
<div class="flex gap-2 items-center">
<input
type="text"
placeholder="https://betterseqta.org"
bind:value={devApiBaseInput}
class="flex-1 px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
/>
<Button onClick={applyDevApiBase} text="Apply" />
{#if devApiBaseActive}
<Button onClick={clearDevApiBase} text="Clear" />
{/if}
</div>
</div>
<div class="flex flex-col gap-2 px-4 py-3">
<div>
<h2 class="text-sm font-bold">GitHub latest version override</h2>
<p class="text-xs">Pretend a newer GitHub release exists to test the update badge. Only applies when dev mode is on.</p>
</div>
<input
type="text"
placeholder="e.g. 9.9.9"
value={$settingsState.devGhReleaseVersionOverride ?? ""}
oninput={(e) => {
settingsState.devGhReleaseVersionOverride = e.currentTarget.value;
}}
class="px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
/>
</div>
</div>
{/if}
</div>
+1 -1
View File
@@ -36,4 +36,4 @@
</div>
</div>
{/if}
</div>
</div>
+156 -38
View File
@@ -7,6 +7,7 @@
import SkeletonLoader from '../components/SkeletonLoader.svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import type { Theme } from '../types/Theme'
import { visibleStoreThemes, buildCoverSlidesForThemes, normalizeStoreTheme } from '@/interface/utils/themeStoreFlavours'
import browser from 'webextension-polyfill'
import ThemeModal from '../components/store/ThemeModal.svelte'
import Header from '../components/store/Header.svelte'
@@ -16,6 +17,8 @@
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
import Backgrounds from '../components/store/Backgrounds.svelte'
import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
import { consumePendingHighlightThemeId } from '@/seqta/utils/openThemeStoreWithHighlight'
const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
@@ -25,7 +28,12 @@
// State variables
let searchTerm = $state('');
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 darkMode = $state(false);
let displayTheme = $state<Theme | null>(null);
@@ -33,7 +41,21 @@
let activeTab = $state('themes');
let error = $state<string | null>(null);
let fetchAttempt = $state(0);
let selectedBackground = $state<string | null>(null);
let showSignInOverlay = $state(false);
const MAX_FETCH_ATTEMPTS = 3;
const FETCH_MESSAGE_TIMEOUT_MS = 25_000;
function sendMessageWithTimeout<T>(message: object): Promise<T> {
return Promise.race([
browser.runtime.sendMessage(message) as Promise<T>,
new Promise<T>((_, reject) => {
setTimeout(() => reject(new Error('Theme store request timed out — reload the SEQTA page after updating the extension.')), FETCH_MESSAGE_TIMEOUT_MS);
}),
]);
}
const fetchCurrentThemes = async () => {
const themes = await themeManager.getAvailableThemes();
@@ -52,6 +74,17 @@
activeTab = tab;
};
/** Featured themes first; within each group, newest by `created_at` (API: Unix seconds). */
function compareStoreThemes(a: Theme, b: Theme): number {
const fa = a.featured === true ? 1 : 0;
const fb = b.featured === true ? 1 : 0;
if (fa !== fb) return fb - fa;
const ca = a.created_at ?? 0;
const cb = b.created_at ?? 0;
if (ca !== cb) return cb - ca;
return a.name.localeCompare(b.name);
}
const toggleFavorite = async (theme: Theme) => {
const token = await cloudAuth.getStoredToken();
if (!token) return;
@@ -80,47 +113,112 @@
};
// 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 {
const token = await cloudAuth.getStoredToken();
const data = (await browser.runtime.sendMessage({
const data = await sendMessageWithTimeout<{
success?: boolean;
data?: { themes: unknown[] };
error?: string;
}>({
type: 'fetchThemes',
token: token ?? undefined,
})) as {
success?: boolean;
data?: { themes: Theme[] };
error?: string;
};
if (!data?.success || !data?.data?.themes) {
});
if (!data?.success || !Array.isArray(data?.data?.themes)) {
throw new Error(data?.error || 'Failed to fetch themes');
}
themes = data.data.themes;
// Shuffle for cover themes
const shuffled = [...themes].sort(() => 0.5 - Math.random());
coverThemes = shuffled.slice(0, 3);
themes = data.data.themes
.map((row) => normalizeStoreTheme(row as Record<string, unknown>))
.filter((t) => t.id.length > 0)
.sort(compareStoreThemes);
error = null;
loading = false;
} catch (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
onMount(async () => {
window.addEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
await fetchThemes();
await fetchCurrentThemes();
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
darkMode = $settingsState.DarkMode;
const pending = consumePendingHighlightThemeId();
if (pending) focusThemeById(pending);
return () => {
window.removeEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
};
});
// Filter themes based on search term
let filteredThemes = $derived(themes.filter(theme =>
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
theme.description.toLowerCase().includes(searchTerm.toLowerCase())
));
// Filter themes (list is already featured-first, then newest; filter preserves order)
let filteredThemes = $derived(
listThemes.filter((theme) => {
const q = searchTerm.toLowerCase();
const name = (theme.name ?? '').toLowerCase();
const description = (theme.description ?? '').toLowerCase();
return name.includes(q) || description.includes(q);
}),
);
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(() => {
loadBackground();
@@ -153,48 +251,64 @@
<!-- Loading State -->
{#if loading}
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
<SkeletonLoader width="100%" height="200px" />
{#each Array(6) as _, i (i)}
<SkeletonLoader width="100%" height="200px" />
{/each}
</div>
{:else if error}
<div class="flex flex-col items-center justify-center py-24 text-center max-w-lg mx-auto">
<h2 class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">Couldn&apos;t load themes</h2>
<p class="mt-3 text-zinc-600 dark:text-zinc-300">{error}</p>
<p class="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
After an extension update, reload your SEQTA tab so the new version can talk to the browser.
</p>
<button
type="button"
class="mt-6 px-4 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700"
onclick={() => {
loading = true;
error = null;
void fetchThemes();
}}
>
Try again
</button>
</div>
{:else}
<!-- Themes Tab Content -->
{#if activeTab === 'themes'}
{#if searchTerm === ''}
<CoverSwiper {coverThemes} {setDisplayTheme} />
<CoverSwiper slides={coverSlides} {setDisplayTheme} />
{/if}
<!-- ThemeGrid to display filtered themes -->
<ThemeGrid
themes={filteredThemes}
allStoreThemeRows={themes}
{searchTerm}
{setDisplayTheme}
{toggleFavorite}
isLoggedIn={cloudLoggedIn}
onRequestSignIn={() => (showSignInOverlay = true)}
/>
{#if displayTheme}
<ThemeModal
currentThemes={currentThemes}
allThemes={themes}
allThemes={listThemes}
allStoreThemeRows={themes}
theme={displayTheme}
{displayTheme}
{setDisplayTheme}
{toggleFavorite}
isLoggedIn={cloudLoggedIn}
onInstall={async () => {
if (displayTheme) {
await themeManager.downloadTheme(displayTheme);
await themeManager.setTheme(displayTheme.id);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
onRequestSignIn={() => (showSignInOverlay = true)}
onInstall={async (themeId: string) => {
if (displayTheme) await installThemeFromStore(themeId, displayTheme);
}}
onRemove={async () => {
if (displayTheme?.id) {
console.debug('deleting theme', displayTheme.id);
await themeManager.deleteTheme(displayTheme.id);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
onRemove={async (themeId: string) => {
console.debug('deleting theme', themeId);
await removeThemeFromStore(themeId);
}}
/>
{/if}
@@ -204,4 +318,8 @@
{/if}
</div>
</div>
{#if showSignInOverlay}
<SignInToFavoriteModal onClose={() => (showSignInOverlay = false)} />
{/if}
</div>
+57 -10
View File
@@ -4,7 +4,10 @@
import { slide } from 'svelte/transition';
import { fade } from 'svelte/transition';
import { type LoadedCustomTheme } from '@/types/CustomThemes'
import {
type LoadedCustomTheme,
shouldForceThemeAppearance,
} from '@/types/CustomThemes'
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
@@ -21,9 +24,9 @@
handleImageVariableChange,
handleCoverImageUpload
} from '../utils/themeImageHandlers';
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { CloseThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
const { themeID } = $props<{ themeID: string }>()
const themeManager = ThemeManager.getInstance();
@@ -40,7 +43,9 @@
coverImage: null,
isEditable: true,
hideThemeName: false,
forceDark: undefined
forceTheme: undefined,
forceDark: undefined,
adaptiveCssVariables: [],
})
let closedAccordions = $state<string[]>([])
let themeLoaded = $state(false);
@@ -80,7 +85,13 @@
}))
}
theme = loadedTheme
theme = {
...loadedTheme,
adaptiveCssVariables: loadedTheme.adaptiveCssVariables ?? [],
forceTheme:
loadedTheme.forceTheme ??
(loadedTheme.forceDark !== undefined ? true : undefined),
}
themeLoaded = true
} else {
themeLoaded = true
@@ -114,6 +125,14 @@
blob: image.blob
}))
themeClone.coverImage = theme.coverImage
themeClone.userEdited = true
if (shouldForceThemeAppearance(themeClone)) {
themeClone.forceTheme = true;
} else {
themeClone.forceTheme = false;
themeClone.forceDark = undefined;
}
themeManager.clearPreview();
await themeManager.saveTheme(themeClone);
@@ -270,7 +289,7 @@
<h1 class='text-xl font-semibold'>Theme Creator</h1>
<a href='https://betterseqta.gitbook.io/betterseqta-docs' target='_blank' class='text-sm font-light text-zinc-500 dark:text-zinc-400'>
<a href='https://docs.betterseqta.org/theme-creation/' target='_blank' rel='noopener noreferrer' class='text-sm font-light text-zinc-500 dark:text-zinc-400'>
<span class='pr-0.5 no-underline font-IconFamily'>{'\ueb44'}</span>
<span class='underline'>
Need help? Check out the docs!
@@ -317,6 +336,27 @@
<Divider />
<div class="py-3">
<h2 class="text-sm font-bold">Adaptive CSS variables</h2>
<p class="text-xs text-zinc-600 dark:text-zinc-400">
One per line, each must start with <code class="text-xs">--</code>. These receive the same colour as the adaptive accent when &quot;Adaptive theme colour&quot; is enabled in general settings. Use them in Custom CSS, e.g. <code class="text-xs">border-color: var(--my-accent);</code>
</p>
<textarea
placeholder="--my-accent&#10;--class-banner"
value={theme.adaptiveCssVariables?.join('\n') ?? ''}
oninput={(e) => {
const lines = e.currentTarget.value
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
theme = { ...theme, adaptiveCssVariables: lines };
}}
class="p-2 mt-2 w-full min-h-[5rem] font-mono text-sm rounded-lg border-0 transition dark:placeholder-zinc-400 bg-zinc-200 dark:bg-zinc-700 focus:outline-none focus:ring-1 focus:ring-zinc-100 dark:focus:ring-zinc-700 focus:bg-zinc-300/50 dark:focus:bg-zinc-600"
></textarea>
</div>
<Divider />
{#each [
{
type: 'switch',
@@ -332,21 +372,28 @@
title: 'Force Theme',
description: 'Force users to use either dark or light mode',
props: {
state: theme.forceDark !== undefined,
onChange: (value: boolean) => theme = { ...theme, forceDark: value ? false : undefined }
state: shouldForceThemeAppearance(theme),
onChange: (value: boolean) => {
if (value) {
theme = { ...theme, forceTheme: true, forceDark: false };
} else {
theme = { ...theme, forceTheme: false, forceDark: undefined };
}
}
}
},
{
type: 'conditional',
props: {
condition: theme.forceDark !== undefined,
condition: shouldForceThemeAppearance(theme),
children: {
type: 'lightDarkToggle',
title: 'Mode',
description: 'Choose whether to force light or dark mode',
props: {
state: theme.forceDark === true,
onChange: (value: boolean) => theme = { ...theme, forceDark: value }
onChange: (value: boolean) =>
(theme = { ...theme, forceDark: value, forceTheme: true })
}
}
}
+39
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 = {
id: string;
name: string;
@@ -8,4 +22,29 @@ export type Theme = {
is_favorited?: boolean;
favorite_count?: number;
download_count?: number;
author?: string;
featured?: boolean;
tags?: string[];
/** Unix time in seconds (API list/detail). */
created_at?: number;
/** Unix seconds — last server update (GET /api/themes). */
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.
* Defaults to the nearest ShadowRoot so styles remain intact when the app
* 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 dest = target ?? (root instanceof ShadowRoot ? root : document.body);
dest.appendChild(node);
return {
update(newTarget) {
(newTarget ?? dest).appendChild(node);
const nextDest =
newTarget ?? (root instanceof ShadowRoot ? root : document.body);
nextDest.appendChild(node);
},
destroy() {
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";
}
+9 -8
View File
@@ -1,11 +1,12 @@
import * as pdfjs from "pdfjs-dist";
import browser from "webextension-polyfill";
import pdfWorkerHref from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import pdfLegacyHref from "pdfjs-dist/legacy/build/pdf.min.mjs?url";
function extensionAssetUrl(viteAssetHref: string): string {
const path = viteAssetHref.replace(/^\/+/, "");
return browser.runtime.getURL(path);
/** Static copies in `src/public` (see `scripts/copy-pdfjs-assets.mjs`, manifest web_accessible_resources). */
const PDF_WORKER_RESOURCE = "resources/pdfjs/pdf.worker.min.mjs";
const PDF_LEGACY_RESOURCE = "resources/pdfjs/pdf.legacy.min.mjs";
function extensionAssetUrl(relativePath: string): string {
return browser.runtime.getURL(relativePath.replace(/^\/+/, ""));
}
let workerConfigured = false;
@@ -13,14 +14,14 @@ let workerConfigured = false;
/** Required before pdfjs spawns a worker (content-script / extension isolate). */
export function ensurePdfjsWorker(): void {
if (workerConfigured) return;
pdfjs.GlobalWorkerOptions.workerSrc = extensionAssetUrl(pdfWorkerHref);
pdfjs.GlobalWorkerOptions.workerSrc = extensionAssetUrl(PDF_WORKER_RESOURCE);
workerConfigured = true;
}
/** Page-context script on Firefox must load these chrome-extension:// URLs (see web_accessible_resources). */
export function getPdfjsPageContextUrls(): { lib: string; worker: string } {
return {
lib: extensionAssetUrl(pdfLegacyHref),
worker: extensionAssetUrl(pdfWorkerHref),
lib: extensionAssetUrl(PDF_LEGACY_RESOURCE),
worker: extensionAssetUrl(PDF_WORKER_RESOURCE),
};
}
@@ -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)}`;
}
+139 -128
View File
@@ -7,19 +7,21 @@ import {
import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import {
clearStuck,
getClassByPattern,
initStorage,
injectWeightingsTab,
letterToNumber,
parseAssessments,
processAssessments,
} from "./utils.ts";
import { injectRubricCopyButtons } from "./rubricCopy.ts";
interface weightingsStorage {
weightings: Record<string, string>;
assessments: Record<string, string>;
weightingOverrides: Record<string, string>;
}
const settings = defineSettings({
@@ -37,6 +39,8 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
const instance = new AssessmentsAveragePluginClass();
let overrideListenerController: AbortController | null = null;
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
id: "assessments-average",
name: "Assessment Averages",
@@ -58,143 +62,150 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
);
await parseAssessments(api);
const sampleAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___']",
await renderSubjectAverage(api);
overrideListenerController?.abort();
overrideListenerController = new AbortController();
document.addEventListener(
"betterseqta:overrideChanged",
() => renderSubjectAverage(api),
{ signal: overrideListenerController.signal },
);
if (!sampleAssessmentItem) return;
const assessmentItemClass =
Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"),
) || "";
const metaContainerClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__metaContainer___",
);
const metaClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__meta___",
);
const simpleResultClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__simpleResult___",
);
const titleClass = getClassByPattern(
sampleAssessmentItem,
"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(
assessmentsList.querySelectorAll(
`[class*='AssessmentItem__AssessmentItem___']`,
),
).filter(
(item) =>
!item
.querySelector(`[class*='AssessmentItem__title___']`)
?.textContent?.includes("Subject Average"),
);
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
await processAssessments(api, assessmentItems);
if (!count || totalWeight === 0) return;
const avg = weightedTotal / totalWeight;
const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce(
(acc, [k, v]) => {
acc[v] = k;
return acc;
},
{} as Record<number, string>,
);
const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade
? letterAvg
: `${avg.toFixed(2)}%`;
const existing = assessmentsList.querySelector(
`[class*='AssessmentItem__title___']`,
);
if (existing?.textContent === "Subject Average") return;
let warningHTML = "";
if (hasInaccurateWeighting) {
warningHTML = /* html */ `
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
Some weightings unavailable
</div>
`;
}
assessmentsList.insertBefore(
stringToHTML(/* html */ `
<div class="${assessmentItemClass}">
<div class="${metaContainerClass}">
<div class="${metaClass}">
<div class="${simpleResultClass}">
<div class="${titleClass}">Subject Average</div>
${warningHTML}
</div>
</div>
</div>
<div class="${thermoscoreClass}">
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
</div>
</div>
</div>
`).firstChild!,
assessmentsList.firstChild,
);
applySubjectColourToOverallResult();
const observer = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
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(
"[class*='AssessmentItem__AssessmentItem___']",
);
if (!sampleAssessmentItem) return;
const assessmentItemClass =
Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"),
) || "";
const metaContainerClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__metaContainer___",
);
const metaClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__meta___",
);
const simpleResultClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__simpleResult___",
);
const titleClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__title___",
);
const assessmentItems = Array.from(
assessmentsList.querySelectorAll(
`[class*='AssessmentItem__AssessmentItem___']`,
),
).filter(
(item) =>
!item
.querySelector(`[class*='AssessmentItem__title___']`)
?.textContent?.includes("Subject Average"),
);
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
await processAssessments(api, assessmentItems);
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 rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce(
(acc, [k, v]) => {
acc[v] = k;
return acc;
},
{} as Record<number, string>,
);
const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
let warningHTML = "";
if (hasInaccurateWeighting) {
warningHTML = /* html */ `
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
Some weightings unavailable
</div>
`;
}
assessmentsList.insertBefore(
stringToHTML(/* html */ `
<div class="${assessmentItemClass}">
<div class="${metaContainerClass}">
<div class="${metaClass}">
<div class="${simpleResultClass}">
<div class="${titleClass}">Subject Average</div>
${warningHTML}
</div>
</div>
</div>
<div class="${thermoscoreClass}">
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
</div>
</div>
</div>
`).firstChild!,
assessmentsList.firstChild,
);
applySubjectColourToOverallResult();
} finally {
renderInFlight = false;
}
}
function applySubjectColourToOverallResult() {
const selectedAssessmentItem = document.querySelector(
"[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;
}
+499 -78
View File
@@ -1,5 +1,11 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
import {
getEngageAssessmentReportUrl,
requestEngageAssessmentPdf,
} from "./engage.ts";
import {
ensurePdfjsWorker,
getPdfjsPageContextUrls,
@@ -17,6 +23,9 @@ export async function initStorage(api: any) {
if (!api.storage.assessments) {
api.storage.assessments = {};
}
if (!api.storage.weightingOverrides) {
api.storage.weightingOverrides = {};
}
}
export function clearStuck(api: any) {
@@ -75,51 +84,179 @@ function parseGrade(text: string): number {
return letterToNumber[str] ?? 0;
}
function formatWeightDisplay(weighting: string): string {
return `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`;
}
function saveWeightingOverride(
api: any,
assessmentID: string,
raw: string,
): { ok: boolean; error?: string } {
const trimmed = raw.trim();
if (trimmed === "") {
const { [assessmentID]: _, ...rest } = api.storage.weightingOverrides;
api.storage.weightingOverrides = rest;
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
return { ok: true };
}
const val = parseFloat(trimmed);
if (isNaN(val) || val < 0) {
return { ok: false, error: "Invalid. Must be 0 or greater" };
}
api.storage.weightingOverrides = {
...api.storage.weightingOverrides,
[assessmentID]: String(val),
};
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
return { ok: true };
}
function attachWeightInputListeners(
input: HTMLInputElement,
api: any,
assessmentID: string,
) {
const save = () => {
const result = saveWeightingOverride(api, assessmentID, input.value);
input.style.borderColor = result.ok
? "rgba(128,128,128,0.35)"
: "rgba(255,80,80,0.6)";
};
input.addEventListener("blur", save);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") input.blur();
});
}
function updateWeightLabelContent(
weightLabel: HTMLElement,
weighting: string | undefined,
assessmentID: string | undefined,
api: any,
) {
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(
assessmentItem: Element,
weighting: string | undefined,
api: any,
) {
const statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___']`,
) as HTMLElement;
let statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
) as HTMLElement | null;
if (
!statsContainer ||
statsContainer.querySelector(".betterseqta-weight-label")
)
return;
const label = statsContainer.querySelector(
`[class*='Label__Label___']`,
) as HTMLElement;
if (!label) return;
const weightLabel = label.cloneNode(true) as HTMLElement;
weightLabel.classList.add("betterseqta-weight-label");
const innerTextDiv = weightLabel.querySelector(
`[class*='Label__innerText___']`,
);
if (innerTextDiv) innerTextDiv.textContent = "Weight";
const textNodes = Array.from(weightLabel.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE,
);
if (textNodes.length) {
textNodes[0].textContent =
weighting && weighting !== "processing"
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
: "N/A";
if (!statsContainer) {
const statsClass = getClassByPattern(document, "AssessmentItem__stats___");
statsContainer = document.createElement("div");
statsContainer.className = statsClass;
statsContainer.classList.add("betterseqta-stats-container");
const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`);
if (thermoscore) {
thermoscore.insertAdjacentElement("afterend", statsContainer);
} else {
assessmentItem.appendChild(statsContainer);
}
}
statsContainer.style.position = "relative";
weightLabel.style.position = "absolute";
weightLabel.style.right = "0";
weightLabel.style.top = "50%";
weightLabel.style.transform = "translateY(-50%)";
const hasNativeLabel = !!statsContainer.querySelector(
`[class*='Label__Label___']:not(.betterseqta-weight-label)`,
);
statsContainer.style.justifyContent = hasNativeLabel
? "space-between"
: "flex-end";
const title = assessmentItem
.querySelector(`[class*='AssessmentItem__title___']`)
?.textContent?.trim();
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
const existingLabel = statsContainer.querySelector(
".betterseqta-weight-label",
) as HTMLElement | null;
if (existingLabel) {
updateWeightLabelContent(existingLabel, weighting, assessmentID, api);
return;
}
statsContainer.style.display = "flex";
statsContainer.style.alignItems = "center";
statsContainer.style.width = "100%";
// Try to clone an existing label from the stats container first,
// fall back to building from scratch if none exists
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);
}
@@ -223,7 +360,8 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
export async function extractPDFText(url: string): Promise<string> {
try {
if (isFirefox) {
const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls();
const { lib: pdfLibUrl, worker: pdfWorkerUrl } =
getPdfjsPageContextUrls();
const escJsSingleQuoted = (s: string) =>
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
@@ -423,8 +561,6 @@ export async function extractPDFText(url: string): Promise<string> {
async function handleWeightings(mark: any, api: any) {
const assessmentID = mark.id;
const metaclassID = mark.metaclassID;
const userInfo = await getUserInfo();
const userID = userInfo.id;
const title = mark.title;
if (
@@ -445,35 +581,55 @@ async function handleWeightings(mark: any, api: any) {
};
try {
const filename =
"BetterSEQTA-" +
String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
let pdfUrl: string;
const printResponse = await fetch(
`${location.origin}/seqta/student/print/assessment`,
{
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({
fileName: filename,
id: assessmentID,
metaclass: metaclassID,
student: userID,
}),
},
);
if (isSeqtaEngageExperience()) {
const studentID = getEngageAssessmentStudentId();
if (!studentID) {
throw new Error("Could not resolve Engage student ID from URL or storage");
}
if (!printResponse.ok) {
throw new Error(
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
const reportFile = await requestEngageAssessmentPdf({
assessmentID,
metaclassID,
studentID,
});
await new Promise((resolve) => setTimeout(resolve, 1000));
pdfUrl = getEngageAssessmentReportUrl(reportFile);
} else {
const userInfo = await getUserInfo();
const userID = userInfo.id;
const filename =
"BetterSEQTA-" +
String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
const printResponse = await fetch(
`${location.origin}/seqta/student/print/assessment`,
{
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
credentials: "include",
body: JSON.stringify({
fileName: filename,
id: assessmentID,
metaclass: metaclassID,
student: userID,
}),
},
);
if (!printResponse.ok) {
throw new Error(
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
);
}
await new Promise((resolve) => setTimeout(resolve, 1000));
pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
if (pdfUrl.startsWith("blob:")) {
throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`);
}
@@ -514,7 +670,11 @@ export async function parseAssessments(api: any) {
"[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
const marks = [
...(state["marks"] ?? []),
...(state["upcoming"] ?? []),
...(state["pending"] ?? []),
];
if (!marks) return;
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
@@ -527,15 +687,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
let count = 0;
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(
`[class*='AssessmentItem__title___']`,
);
@@ -545,11 +696,22 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
if (!title) continue;
const assessmentID = api.storage.assessments?.[title];
const weighting = assessmentID
const autoWeighting = assessmentID
? api.storage.weightings?.[assessmentID]
: 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 (
weighting === null ||
@@ -558,8 +720,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
weighting === "processing"
) {
hasInaccurateWeighting = true;
weightedTotal += grade;
totalWeight += 1;
continue
} else {
const weight = parseFloat(weighting);
@@ -582,3 +743,263 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
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">
import { determineStatus, formatDate, getGradeValue } from "./utils";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { buildEngageAssessmentPagePath } from "@/seqta/utils/engageAssessmentStudent";
import OverviewIcon from "./OverviewIcon.svelte";
import {
GROUP_SORT_ICONS,
STATUS_COLUMN_ICONS,
type OverviewIconName,
} from "./icons";
import confetti from "canvas-confetti";
export let data: any;
interface FilterOptions {
subject: string;
student: string;
sortBy: "due" | "grade" | "subject" | "title" | "year";
}
@@ -38,12 +47,21 @@
let currentFilters: FilterOptions = {
subject: "all",
student: "all",
sortBy: "due",
};
const isEngage = isSeqtaEngageExperience();
$: showStudentFilter = isEngage && (data?.students?.length ?? 0) > 1;
let filteredAssessments: any[] = [];
let statusGroups: Record<string, any[]> = {};
let columns: { key: string; title: string; className: string; icon: string }[] = [];
let columns: {
key: string;
title: string;
className: string;
icon: OverviewIconName;
}[] = [];
function getAssessmentYear(a: any): number {
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();
}
const STATUS_COLUMNS = [
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "📅" },
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "⏰" },
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "🚨" },
{ key: "SUBMITTED", title: "Submitted", className: "column-submitted", icon: "📝" },
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "✅" },
const STATUS_COLUMNS: {
key: string;
title: string;
className: string;
icon: OverviewIconName;
}[] = [
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "calendar-days" },
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "clock" },
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "exclamation-triangle" },
{ key: "SUBMITTED", title: "Submitted", className: "column-submitted", icon: "document-check" },
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "check-circle" },
];
function groupSortIcon(): OverviewIconName {
return GROUP_SORT_ICONS[currentFilters.sortBy] ?? "queue-list";
}
function buildGroupsAndColumns() {
if (!data?.assessments) return { filteredAssessments: [], statusGroups: {}, columns: [] };
const subjectFilters = settingsState.subjectfilters || {};
@@ -100,7 +127,17 @@
const filtered = data.assessments.filter((a: any) => {
if (hiddenAssessmentIds.has(String(a.id))) 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[]> = {};
@@ -114,18 +151,19 @@
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") {
cols = STATUS_COLUMNS;
} else {
const keys = Object.keys(groups).filter((k) => groups[k]?.length > 0);
const sortIcon = groupSortIcon();
if (currentFilters.sortBy === "year") {
cols = keys.sort((a, b) => Number(b) - Number(a)).map((k) => ({ key: k, title: k, className: "column-custom", icon: "📆" }));
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") {
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 {
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")) {
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}`;
}
@@ -342,16 +393,28 @@
updateAssessments();
void currentFilters.sortBy;
void currentFilters.subject;
void currentFilters.student;
}
</script>
<svelte:window on:click={closeAllMenus} />
<div id="grid-view-container">
<div class="grid-view-header">
<h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters">
<div class="bsplus-overview-page">
<header class="grid-view-header bsplus-overview-animate">
<div class="grid-view-header-text">
<h1 class="grid-view-title">Assessments</h1>
<p class="grid-view-subtitle">Track upcoming tasks, submissions, and released marks</p>
</div>
<div class="grid-view-filters bsplus-overview-toolbar">
{#if showStudentFilter}
<select class="filter-select" bind:value={currentFilters.student}>
<option value="all">All Students</option>
{#each data.students as student}
<option value={String(student.id)}>{student.name}</option>
{/each}
</select>
{/if}
<select class="filter-select" bind:value={currentFilters.subject}>
<option value="all">All Subjects</option>
{#each data.subjects as subject}
@@ -372,14 +435,15 @@
on:click={() => (showVisibilityPanel = !showVisibilityPanel)}
title="Manage hidden subjects and assessments"
>
👁 Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})
<OverviewIcon name="eye" size={18} />
<span>Visibility ({hiddenSubjects.length + hiddenAssessmentsWithInfo.length})</span>
</button>
{/if}
</div>
</div>
</header>
{#if showVisibilityPanel && hasHiddenItems}
<div class="visibility-panel">
<div class="visibility-panel bsplus-overview-animate">
<h4 class="visibility-panel-title">Hidden items</h4>
{#if hiddenSubjects.length > 0}
<div class="visibility-section">
@@ -410,10 +474,10 @@
</div>
{/if}
<div id="main-grid-content">
<div id="main-grid-content" class="bsplus-overview-animate bsplus-overview-delay-1">
{#if filteredAssessments.length === 0}
<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>
</div>
{:else}
@@ -424,9 +488,15 @@
<div class="kanban-column {column.className}">
<div class="column-header">
<div class="column-title">
{column.icon} {column.title}
<span class="column-count">{statusGroups[column.key].length}</span>
</div>
<span class="column-title-main">
<OverviewIcon
name={column.icon ?? STATUS_COLUMN_ICONS[column.key] ?? "queue-list"}
size={18}
/>
{column.title}
</span>
<span class="column-count">{statusGroups[column.key].length}</span>
</div>
</div>
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
{#each statusGroups[column.key] as assessment}
@@ -445,6 +515,9 @@
on:keydown={(e) => e.key === 'Enter' && handleCardClick(assessment, e)}
>
<div class="card-labels">
{#if isEngage && assessment.studentName}
<span class="card-label label-student">{assessment.studentName}</span>
{/if}
<span class="card-label label-subject">{assessment.code}</span>
{#if assessment.submitted}
<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>
@@ -462,11 +535,7 @@
on:click={(e) => toggleMenu(assessment.id, e)}
aria-label="Open menu"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/>
</svg>
<OverviewIcon name="ellipsis-vertical" size={16} />
</button>
<div class="menu-dropdown" style="display: {openMenuId === assessment.id ? 'block' : 'none'};">
{#if status !== "MARKS_RELEASED"}
@@ -493,7 +562,8 @@
{#if !assessment.results && !isCompleted}
<div class="assessment-meta">
<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>
{/if}
@@ -1,8 +1,11 @@
<script lang="ts">
import OverviewIcon from "./OverviewIcon.svelte";
export let error: string;
</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 style="color: #94a3b8; font-size: 0.875rem;">{error}</p>
</div>
<p class="error-detail">{error}</p>
</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">
<div class="grid-view-header">
<h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters">
<script lang="ts">
import OverviewIcon from "./OverviewIcon.svelte";
import type { OverviewIconName } from "./icons";
const columns: {
key: string;
title: string;
className: string;
icon: OverviewIconName;
skeletonCount: number;
}[] = [
{ key: "UPCOMING", title: "Upcoming", className: "column-upcoming", icon: "calendar-days", skeletonCount: 3 },
{ key: "DUE_SOON", title: "Due Soon", className: "column-due-soon", icon: "clock", skeletonCount: 2 },
{ key: "OVERDUE", title: "Overdue", className: "column-overdue", icon: "exclamation-triangle", skeletonCount: 1 },
{ key: "MARKS_RELEASED", title: "Marked", className: "column-marked", icon: "check-circle", skeletonCount: 4 },
];
</script>
<div class="bsplus-overview-page">
<header class="grid-view-header bsplus-overview-animate">
<div class="grid-view-header-text">
<h1 class="grid-view-title">Assessments</h1>
<p class="grid-view-subtitle">Loading your assessment overview…</p>
</div>
<div class="grid-view-filters bsplus-overview-toolbar">
<select class="filter-select" disabled>
<option value="all">Loading subjects...</option>
</select>
@@ -9,17 +30,20 @@
<option value="due">Sort by Due Date</option>
</select>
</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">
{#each columns as column}
<div class="kanban-column-parent">
<div class="kanban-column {column.className}">
<div class="column-header">
<div class="column-title">
{column.icon} {column.title}
<span class="column-count">...</span>
<span class="column-title-main">
<OverviewIcon name={column.icon} size={18} />
{column.title}
</span>
<span class="column-count"></span>
</div>
</div>
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
@@ -43,36 +67,3 @@
</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>
+62 -29
View File
@@ -1,20 +1,26 @@
interface Subject {
code: string;
programme: number;
metaclass: number;
title: string;
}
import {
activeSubjectsFromLearnPayload,
assessmentBelongsToActiveSubjects,
filterAssessmentsForActiveSubjects,
type OverviewSubject,
} from "./utils";
interface PrefItem {
name: string;
value: string;
}
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { getMockAssessmentsData } from "@/seqta/ui/dev/hideSensitiveContent";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import {
getEngageAssessmentsData,
} from "./engageApi";
let cache: { time: number; data: any } | null = null;
let cache: { time: number; engageAll?: boolean; studentId: number; data: any } | null =
null;
const CACHE_MS = 10 * 60 * 1000;
const student = 69;
async function fetchJSON(url: string, body: any) {
const res = await fetch(`${location.origin}${url}`, {
@@ -26,11 +32,9 @@ async function fetchJSON(url: string, body: any) {
return res.json();
}
async function loadSubjects() {
async function loadSubjects(): Promise<OverviewSubject[]> {
const res = await fetchJSON("/seqta/student/load/subjects?", {});
return res.payload
.filter((s: any) => s.active === 1)
.flatMap((s: any) => s.subjects);
return activeSubjectsFromLearnPayload(res.payload);
}
async function loadPrefs(student: number) {
@@ -56,9 +60,8 @@ async function loadUpcoming(student: number) {
return res.payload;
}
function normalizeAssessmentDates(t: any, subject: Subject): any {
function normalizeAssessmentDates(t: any, subject: OverviewSubject): any {
const normalized = { ...t };
// Past API may use different date fields - ensure we have 'due' for year filter & display
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
}
@@ -68,7 +71,7 @@ function normalizeAssessmentDates(t: any, subject: Subject): any {
return normalized;
}
async function loadPast(student: number, subjects: Subject[]) {
async function loadPast(student: number, subjects: OverviewSubject[]) {
const map: Record<number, any> = {};
await Promise.all(
subjects.map(async (s) => {
@@ -128,35 +131,65 @@ async function loadSubmissions(student: number, assessments: any[]) {
return submissionMap;
}
export async function getAssessmentsData() {
if (settingsState.mockNotices) {
return getMockAssessmentsData();
}
if (cache && Date.now() - cache.time < CACHE_MS) return cache.data;
async function getLearnAssessmentsData(studentId: number) {
const [subjects, colors, upcoming] = await Promise.all([
loadSubjects(),
loadPrefs(student),
loadUpcoming(student),
loadPrefs(studentId),
loadUpcoming(studentId),
]);
const pastMap = await loadPast(student, subjects);
const pastMap = await loadPast(studentId, subjects);
const map: Record<number, any> = {};
upcoming.forEach((a: any) => {
map[a.id] = { ...a };
if (assessmentBelongsToActiveSubjects(a, subjects)) {
map[a.id] = { ...a };
}
});
Object.values(pastMap).forEach((t: any) => {
if (!assessmentBelongsToActiveSubjects(t, subjects)) return;
if (map[t.id]) Object.assign(map[t.id], t);
else map[t.id] = t;
});
const allAssessments = Object.values(map);
const submissions = await loadSubmissions(student, allAssessments);
const allAssessments = filterAssessmentsForActiveSubjects(
Object.values(map),
subjects,
);
const submissions = await loadSubmissions(studentId, allAssessments);
allAssessments.forEach((assessment: any) => {
assessment.submitted = submissions[assessment.id] || false;
});
const data = { assessments: allAssessments, subjects, colors };
cache = { time: Date.now(), data };
return { assessments: allAssessments, subjects, colors, studentId };
}
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;
}
@@ -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,9 +1,50 @@
import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api";
import { renderErrorState, renderSkeletonLoader } from "./ui";
import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import {
isEngageAssessmentOverviewRoute,
} from "@/seqta/utils/engageAssessmentStudent";
import { resolveEngageStudentId } from "./engageApi";
const OVERVIEW_MENU_CLASS = "betterseqta-assessments-overview-item";
function ensureOverviewMenuPosition(
menu: HTMLElement,
gridItem: HTMLElement,
) {
if (menu.firstElementChild !== gridItem) {
menu.insertBefore(gridItem, menu.firstChild);
}
}
function isOverviewRoute() {
if (isSeqtaEngageExperience()) {
return isEngageAssessmentOverviewRoute();
}
return window.location.hash.includes("/assessments/overview");
}
async function waitForAssessmentsSubmenu(): Promise<HTMLElement> {
if (!isSeqtaEngageExperience()) {
return (await waitForElm(
'[data-key="assessments"] > .sub > ul',
true,
100,
60,
)) as HTMLElement;
}
return (await waitForElm(
'[data-key="assessments"] .sub ul, [data-key="assessments"] ul',
true,
100,
350,
)) as HTMLElement;
}
const assessmentsOverviewPlugin: Plugin<{}> = {
id: "assessments-overview",
@@ -16,33 +57,46 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
styles,
run: async () => {
const menu = (await waitForElm(
'[data-key="assessments"] > .sub > ul',
true,
100,
60,
)) as HTMLElement;
const menu = await waitForAssessmentsSubmenu();
const gridItem = document.createElement("li");
gridItem.className = "item";
gridItem.classList.add(OVERVIEW_MENU_CLASS);
const label = document.createElement("label");
label.textContent = "Overview";
gridItem.appendChild(label);
menu.insertBefore(gridItem, menu.children[1] || null);
menu.insertBefore(gridItem, menu.firstChild);
if (window.location.hash.includes("/assessments/overview")) {
loadGridView();
const menuObserver = new MutationObserver(() => {
ensureOverviewMenuPosition(menu, gridItem);
});
menuObserver.observe(menu, { childList: true });
if (isOverviewRoute()) {
void loadGridView();
}
const clickHandler = (e: Event) => {
e.preventDefault();
loadGridView();
void loadGridView();
};
gridItem.addEventListener("click", clickHandler);
async function loadGridView() {
await delay(1);
window.history.pushState({}, "", "/#?page=/assessments/overview");
document.title = "Overview ― SEQTA Learn";
if (isSeqtaEngageExperience()) {
const studentId = await resolveEngageStudentId();
window.history.pushState(
{},
"",
`/#?page=/assessments/${studentId}/overview`,
);
document.title = "Overview ― SEQTA Engage";
} else {
window.history.pushState({}, "", "/#?page=/assessments/overview");
document.title = "Overview ― SEQTA Learn";
}
const main = document.getElementById("main");
if (!main) return;
@@ -56,7 +110,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
.querySelector('[data-key="assessments"]')
?.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(
"grid-view-container",
) as HTMLElement;
@@ -65,7 +119,6 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
try {
const data = await getAssessmentsData();
const { renderGrid } = await import("./ui");
renderGrid(container, data);
} catch (err) {
console.error("Failed to load assessments:", err);
@@ -77,6 +130,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
}
return () => {
menuObserver.disconnect();
gridItem.removeEventListener("click", clickHandler);
gridItem.remove();
};
File diff suppressed because it is too large Load Diff
+131 -21
View File
@@ -1,45 +1,155 @@
import renderSvelte from "@/interface/main";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import AssessmentsOverview from "./AssessmentsOverview.svelte";
import SkeletonLoader from "./SkeletonLoader.svelte";
import ErrorState from "./ErrorState.svelte";
import { unmount } from "svelte";
let currentApp: any = null;
let themeObserver: MutationObserver | null = null;
type ThemeSettingKey =
| "selectedColor"
| "DarkMode"
| "adaptiveThemeColour"
| "adaptiveThemeGradient"
| "selectedTheme";
let themeListeners: Array<{ key: ThemeSettingKey; listener: () => void }> = [];
const THEME_CSS_VARS = [
"--better-main",
"--better-pale",
"--better-light",
"--text-color",
"--background-primary",
"--background-secondary",
"--text-primary",
"--theme-offset-bg",
"--better-sub",
] as const;
const ACCENT_CSS_VARS = [
"--better-main",
"--accent-color-value",
"--accentColor",
"--colour-betterseqta-blue",
] as const;
function extractSolidColor(value: string): string | null {
const trimmed = value.trim();
if (!trimmed || trimmed === "initial") return null;
if (
trimmed.startsWith("#") ||
trimmed.startsWith("rgb") ||
trimmed.startsWith("hsl")
) {
return trimmed;
}
if (trimmed.includes("gradient")) {
const match = trimmed.match(
/#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgba?\([^)]+\)/i,
);
return match?.[0] ?? null;
}
return null;
}
function resolvePageAccentColor(): string {
const computed = getComputedStyle(document.documentElement);
for (const name of ACCENT_CSS_VARS) {
const solid = extractSolidColor(computed.getPropertyValue(name));
if (solid) return solid;
}
const fromSettings = settingsState.selectedColor?.trim();
if (fromSettings) {
const solid = extractSolidColor(fromSettings);
if (solid) return solid;
}
return "#007bff";
}
function syncOverviewTheme(target: HTMLElement) {
const computed = getComputedStyle(document.documentElement);
for (const name of THEME_CSS_VARS) {
const value = document.documentElement.style.getPropertyValue(name).trim()
|| computed.getPropertyValue(name).trim();
if (value) target.style.setProperty(name, value);
}
const accent = resolvePageAccentColor();
target.style.setProperty("--bsplus-overview-accent", accent);
target.style.setProperty("--better-main", accent);
target.classList.toggle(
"dark",
document.documentElement.classList.contains("dark"),
);
}
function watchOverviewTheme(root: HTMLElement) {
for (const { key, listener } of themeListeners) {
settingsState.unregister(key, listener);
}
themeListeners = [];
const listener = () => syncOverviewTheme(root);
for (const key of [
"selectedColor",
"DarkMode",
"adaptiveThemeColour",
"adaptiveThemeGradient",
"selectedTheme",
] satisfies ThemeSettingKey[]) {
settingsState.register(key, listener);
themeListeners.push({ key, listener });
}
themeObserver?.disconnect();
themeObserver = new MutationObserver(() => syncOverviewTheme(root));
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style", "class"],
});
}
function prepareContainer(container: HTMLElement) {
container.innerHTML = "";
container.className = "bsplus-overview-host";
container.classList.add("bsplus-overview-root");
syncOverviewTheme(container);
watchOverviewTheme(container);
}
export function renderGrid(container: HTMLElement, data: any) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
if (currentApp) unmount(currentApp);
prepareContainer(container);
currentApp = renderSvelte(AssessmentsOverview, container, { data });
}
export function renderSkeletonLoader(container: HTMLElement) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
if (currentApp) unmount(currentApp);
prepareContainer(container);
currentApp = renderSvelte(SkeletonLoader, container);
}
export function renderLoadingState(container: HTMLElement) {
renderSkeletonLoader(container);
}
export function renderErrorState(container: HTMLElement, error: string) {
if (currentApp) unmount(currentApp);
prepareContainer(container);
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;
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(ErrorState, container, { error });
}
}
@@ -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 {
const d = new Date(dateStr);
const now = new Date();
+39 -69
View File
@@ -5,7 +5,9 @@ import {
defineSettings,
hotkeySetting,
} from "../../core/settingsHelpers";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import styles from "./src/core/styles.css?inline";
import { resetSearchIndexes } from "./src/indexing/resetIndexes";
// Platform-aware default hotkey
const getDefaultHotkey = () => {
@@ -34,85 +36,42 @@ const settings = defineSettings({
title: "Index on Page Load",
description: "Run content indexing when SEQTA loads",
}),
passiveIndexing: booleanSetting({
default: true,
title: "Index Browsed Content",
description:
"Capture safe text from SEQTA pages you visit so they're searchable. Sensitive routes (settings, files, login) are always excluded.",
}),
resetIndex: buttonSetting({
title: "Reset Index",
description: "Reset the search index and storage",
trigger: async () => {
const confirmed = confirm("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) return;
if (confirmed) {
try {
// Dynamically import modules to avoid loading heavy dependencies
const { VectorWorkerManager } = await import("./src/indexing/worker/vectorWorkerManager");
const { resetDatabase } = await import("./src/indexing/db");
// Reset vector worker first
try {
const workerManager = VectorWorkerManager.getInstance();
await workerManager.resetWorker();
console.log("Vector worker reset successfully");
} catch (e) {
console.warn("Failed to reset vector worker:", e);
}
// Close all database connections properly before deletion
try {
await resetDatabase();
console.log("betterseqta-index database closed and reset");
} catch (e) {
console.warn("Failed to reset betterseqta-index database:", e);
}
// Wait a bit for connections to fully close
await new Promise(resolve => setTimeout(resolve, 100));
// Delete embeddiaDB (vector search database)
const deleteDb = (dbName: string) => {
return new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => {
console.log(`Successfully deleted database: ${dbName}`);
resolve();
};
req.onerror = () => {
console.error(`Error deleting database ${dbName}:`, req.error);
reject(req.error);
};
req.onblocked = () => {
console.warn(`Database ${dbName} deletion blocked - connections still open`);
// Wait and retry once
setTimeout(() => {
const retryReq = indexedDB.deleteDatabase(dbName);
retryReq.onsuccess = () => {
console.log(`Successfully deleted database on retry: ${dbName}`);
resolve();
};
retryReq.onerror = () => reject(retryReq.error);
retryReq.onblocked = () => {
reject(new Error(`One database is open, failed to remove: ${dbName}. Please close other tabs and try again.`));
};
}, 500);
};
});
};
try {
await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index");
alert("Search index and storage have been reset successfully.");
} catch (e) {
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
}
} catch (e) {
alert("Failed to reset index: " + String(e));
}
try {
// `resetSearchIndexes` is a tiny statically-imported helper: no
// dynamic chunks to chase, so the button keeps working even when
// the settings page has been open across an extension update.
await resetSearchIndexes();
alert(
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
);
} catch (e) {
alert(
"Failed to reset index: " +
String(e) +
"\n\nTry closing other browser tabs and try again.",
);
}
},
}),
});
// Create the lazy plugin definition - this loads immediately but doesn't import heavy dependencies
export default defineLazyPlugin({
const globalSearchPlugin = defineLazyPlugin({
id: "global-search",
name: "Global Search",
description: "Quick search for everything in SEQTA",
@@ -120,9 +79,20 @@ export default defineLazyPlugin({
settings,
disableToggle: true,
defaultEnabled: false,
beta: true,
styles: styles,
// Lazy loader - only imports the heavy plugin when actually needed
loader: () => import("./src/core/index")
});
const runGlobalSearch = globalSearchPlugin.run!;
globalSearchPlugin.run = async (api) => {
if (isSeqtaEngageExperience()) {
return () => {};
}
return runGlobalSearch(api);
};
export default globalSearchPlugin;
@@ -48,6 +48,13 @@
let calculatorResult = $state<string | null>(null);
let resultsList = $state<HTMLUListElement>();
// Monotonic counter so a slow async search (vector reranking) cannot
// overwrite results from a newer keystroke. Without this guard, the user
// observes results "flickering" — e.g. typing `world w` finds the assessment
// but `world wa` triggers a new search whose vector pass returns later than
// the `world w` pass and clobbers the more relevant matches.
let searchRequestId = 0;
const updateCalculatorState = (hasResult: string | null) => {
calculatorResult = hasResult;
};
@@ -166,20 +173,30 @@
});
const term = searchTerm.trim().toLowerCase();
const requestId = ++searchRequestId;
if (commandsFuse && dynamicContentFuse) {
combinedResults = await doSearch(
term,
commandsFuse,
const results = await doSearch(
term,
commandsFuse,
commandIdToItemMap,
dynamicContentFuse,
dynamicIdToItemMap,
true, // sortByRecent
);
// Drop the result if the user has typed since this search started, or
// if the current term no longer matches what we searched for. This
// keeps the visible list anchored to the latest query.
if (requestId !== searchRequestId) return;
if (searchTerm.trim().toLowerCase() !== term) return;
combinedResults = results;
} else {
if (requestId !== searchRequestId) return;
combinedResults = [];
}
isLoading = false;
};
@@ -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 {
const response = await fetch(`${location.origin}/seqta/student/load/timetable?`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from: todayFormatted,
until: todayFormatted,
student: 69,
}),
});
@@ -15,6 +15,10 @@ import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
import { IndexedDbManager } from "embeddia";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { checkAndHandleUpdate } from "../utils/versionCheck";
import {
getStoredPassiveItems,
installPassiveObserver,
} from "../indexing/passiveObserver";
// Platform-aware default hotkey
const getDefaultHotkey = () => {
@@ -43,11 +47,19 @@ const settings = defineSettings({
title: "Index on Page Load",
description: "Run content indexing when SEQTA loads",
}),
passiveIndexing: booleanSetting({
default: true,
title: "Index Browsed Content",
description:
"Capture safe text from SEQTA pages you visit so they're searchable. Sensitive routes (settings, files, login) are always excluded.",
}),
resetIndex: buttonSetting({
title: "Reset Index",
description: "Reset the search index and storage",
trigger: async () => {
const confirmed = confirm("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 {
@@ -106,7 +118,9 @@ const settings = defineSettings({
try {
await deleteDb("embeddiaDB");
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) {
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)
runIndexingOnLoad!: boolean;
@Setting(settings.passiveIndexing)
passiveIndexing!: boolean;
@Setting(settings.resetIndex)
resetIndex!: () => void;
}
@@ -145,32 +162,40 @@ const globalSearchPlugin: Plugin<typeof settings> = {
settings: settingsInstance.settings,
disableToggle: true,
defaultEnabled: false,
beta: true,
styles: styles,
run: async (api) => {
const appRef = { current: null };
// Check for extension updates and clear caches if needed
// Use a timeout to avoid blocking initialization
setTimeout(async () => {
try {
const wasUpdated = await checkAndHandleUpdate();
if (wasUpdated) {
console.log("[Global Search] Extension updated - caches cleared");
}
} catch (error: any) {
// Handle CSS preload errors and other failures gracefully
// These can happen in Firefox or when assets aren't available
if (error?.message?.includes("preload CSS") ||
error?.message?.includes("MIME type") ||
error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")) {
console.debug("[Global Search] Version check skipped due to asset loading restrictions:", error.message);
} else {
console.warn("[Global Search] Failed to check for updates:", error);
}
// Run the version check BEFORE we open any IndexedDB connections.
// On a normal load (no version change) this is just a string compare
// and a manifest read, so the cost is negligible. On a real update,
// we want the database wipe to complete before `IndexedDbManager`
// grabs a handle on `embeddiaDB`, otherwise the delete request comes
// back blocked.
try {
const wasUpdated = await checkAndHandleUpdate();
if (wasUpdated) {
console.log(
"[Global Search] Extension updated — search index reset; the next indexing pass will repopulate.",
);
}
}, 100);
} catch (error: any) {
// Firefox sometimes refuses CSS preloads or asset reads; we never
// want this path to take the whole plugin down.
if (
error?.message?.includes("preload CSS") ||
error?.message?.includes("MIME type") ||
error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")
) {
console.debug(
"[Global Search] Version check skipped due to asset loading restrictions:",
error.message,
);
} else {
console.warn("[Global Search] Failed to check for updates:", error);
}
}
try {
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
@@ -211,6 +236,17 @@ const globalSearchPlugin: Plugin<typeof settings> = {
const workerManager = VectorWorkerManager.getInstance();
console.log("Streaming active:", workerManager.isStreamingActive());
},
passiveItems: async () => {
const items = await getStoredPassiveItems();
console.log(`Captured ${items.length} passive items`);
return items;
},
runSelfTests: async () => {
const { runGlobalSearchSelfTests } = await import(
"../indexing/selfTests"
);
return runGlobalSearchSelfTests();
},
checkIndexedDBSize: async () => {
try {
const estimate = await navigator.storage.estimate();
@@ -233,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) {
setTimeout(async () => {
await runIndexing();
@@ -8,7 +8,12 @@ import browser from "webextension-polyfill";
export function mountSearchBar(
titleElement: Element,
api: any,
appRef: { current: any; storageChangeHandler?: any; progressHandler?: any },
appRef: {
current: any;
storageChangeHandler?: any;
progressHandler?: any;
clearDoneFlashTimer?: () => void;
},
) {
if (titleElement.querySelector(".search-trigger")) {
return;
@@ -18,74 +23,215 @@ export function mountSearchBar(
let currentHotkey = isValidHotkey(api.settings.searchHotkey) ? api.settings.searchHotkey : "ctrl+k";
let hotkeyDisplay = formatHotkeyForDisplay(currentHotkey);
// Search trigger + progress UI live in one wrapper so the auto-margin
// pushes the whole group to the left edge of the topbar instead of
// stranding the progress text on the far right of the screen.
const searchWrapper = document.createElement("div");
searchWrapper.className = "search-trigger-wrapper";
// Anchor stacks button + slim progress strip in one rounded chip (see
// `.search-trigger-anchor` in styles.css).
const searchAnchor = document.createElement("div");
searchAnchor.className = "search-trigger-anchor";
const searchButton = document.createElement("div");
searchButton.className = "search-trigger";
// Create progress indicator container
const progressContainer = document.createElement("div");
progressContainer.className = "search-progress-container";
progressContainer.style.cssText = "display: flex; align-items: center; gap: 8px; margin-left: 8px; min-width: 120px;";
// Create progress bar
const progressBarWrapper = document.createElement("div");
progressBarWrapper.className = "search-progress-bar-wrapper";
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");
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;";
// Add shimmer effect
const shimmer = document.createElement("div");
shimmer.style.cssText = "position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); animation: shimmer 2s infinite;";
progressBar.appendChild(shimmer);
progressBarWrapper.appendChild(progressBar);
// Create progress text
const progressText = document.createElement("span");
progressTrack.appendChild(progressBar);
progressBarWrapper.appendChild(progressTrack);
// Use a block-level <div> so the label reliably participates in flex
// layout. A <span> defaults to `display: inline`, which silently ignores
// `max-width`, `overflow`, and `text-overflow: ellipsis`, and was the
// reason the label appeared blank when the bar was visible.
const progressText = document.createElement("div");
progressText.className = "search-progress-text";
progressText.style.cssText = "font-size: 11px; color: #666; white-space: nowrap; display: none;";
progressContainer.appendChild(progressBarWrapper);
progressContainer.appendChild(progressText);
progressText.setAttribute("aria-live", "polite");
searchAnchor.appendChild(searchButton);
searchAnchor.appendChild(progressBarWrapper);
searchWrapper.appendChild(searchAnchor);
searchWrapper.appendChild(progressText);
// Indexing state
let isIndexing = false;
/** True while indexing has run until it finishes/fails — used for Done! flash only */
let ranIndexingCycle = false;
let completedJobs = 0;
let totalJobs = 0;
let indexingStatus: string | null = null;
const updateProgressDisplay = () => {
if (isIndexing && totalJobs > 0) {
const percentage = Math.round((completedJobs / totalJobs) * 100);
progressBar.style.width = `${Math.max(2, percentage)}%`;
progressBarWrapper.style.display = "block";
if (indexingStatus) {
progressText.textContent = indexingStatus.length > 20 ? indexingStatus.substring(0, 20) + "..." : indexingStatus;
progressText.style.display = "block";
} else {
progressText.textContent = `${completedJobs}/${totalJobs} (${percentage}%)`;
progressText.style.display = "block";
}
} else {
progressBarWrapper.style.display = "none";
progressText.style.display = "none";
let doneFlashTimer: ReturnType<typeof setTimeout> | null = null;
let doneFadeTimer: ReturnType<typeof setTimeout> | null = null;
/** Captures `wasIndexing && !indexing` for the current dispatcher tick */
let indexingJustStoppedFlag = false;
const DONE_HOLD_MS = 5000;
const DONE_FADE_MS = 550;
/** Treat as failure copy — plain “Done!” would be misleading */
const statusLooksRough = (s: string) =>
/\b(fail|error|cancel)\b/i.test(s);
const truncateStatus = (s: string, max = 44) =>
s.length > max ? s.slice(0, max - 1) + "…" : s;
const clearDoneFlashTimer = () => {
if (doneFlashTimer) {
clearTimeout(doneFlashTimer);
doneFlashTimer = null;
}
if (doneFadeTimer) {
clearTimeout(doneFadeTimer);
doneFadeTimer = null;
}
};
const updateProgressDisplay = () => {
const indexingStoppedThisTick = indexingJustStoppedFlag;
indexingJustStoppedFlag = false;
const active = isIndexing && totalJobs > 0;
// Stray pulses (missing total, 0 completed, etc.) used to hit the idle
// branch and call clearDoneFlashTimer(), killing the Done! hold/fade.
if (doneFlashTimer !== null || doneFadeTimer !== null) {
if (!active) {
return;
}
clearDoneFlashTimer();
}
const completionEligible =
ranIndexingCycle &&
!active &&
totalJobs > 0 &&
(completedJobs >= totalJobs || indexingStoppedThisTick);
if (active) {
clearDoneFlashTimer();
progressBarWrapper.classList.remove("is-rough-complete");
progressText.classList.remove(
"is-rough",
"is-fading-done",
"is-done-message",
);
const percentage = Math.round((completedJobs / totalJobs) * 100);
progressBar.style.width = `${Math.max(2, percentage)}%`;
progressBarWrapper.classList.add("is-active");
searchAnchor.classList.add("is-indexing");
searchButton.classList.add("is-indexing");
if (indexingStatus) {
progressText.textContent = `${truncateStatus(indexingStatus)} · ${percentage}%`;
} else {
progressText.textContent = `Indexing ${completedJobs}/${totalJobs} (${percentage}%)`;
}
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 {
progressText.classList.remove("is-rough");
progressBarWrapper.classList.remove("is-rough-complete");
}
progressText.classList.add("is-active", "is-done-message");
doneFlashTimer = setTimeout(() => {
doneFlashTimer = null;
progressText.classList.add("is-fading-done");
doneFadeTimer = setTimeout(() => {
doneFadeTimer = null;
ranIndexingCycle = false;
indexingStatus = null;
progressBar.style.width = "0%";
progressBarWrapper.classList.remove("is-active");
progressBarWrapper.classList.remove("is-rough-complete");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove(
"is-active",
"is-rough",
"is-fading-done",
"is-done-message",
);
progressText.textContent = "";
}, DONE_FADE_MS);
}, DONE_HOLD_MS);
return;
}
clearDoneFlashTimer();
progressBarWrapper.classList.remove("is-active");
progressBarWrapper.classList.remove("is-rough-complete");
searchAnchor.classList.remove("is-indexing");
searchButton.classList.remove("is-indexing");
progressText.classList.remove(
"is-active",
"is-rough",
"is-fading-done",
"is-done-message",
);
progressBar.style.width = "0%";
progressText.textContent = "";
ranIndexingCycle = false;
indexingStatus = null;
};
// Listen for indexing progress events
const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing, status } = event.detail;
completedJobs = completed || 0;
totalJobs = total || 0;
isIndexing = indexing || false;
indexingStatus = status || null;
const { completed, total, indexing, status } = event.detail as {
completed?: number;
total?: number;
indexing?: boolean;
status?: string;
};
const wasIndexing = isIndexing;
completedJobs = completed ?? 0;
totalJobs = total ?? 0;
isIndexing = Boolean(indexing);
indexingStatus = status ?? null;
indexingJustStoppedFlag = wasIndexing && !isIndexing;
if (!wasIndexing && isIndexing) ranIndexingCycle = true;
if (wasIndexing && !isIndexing) ranIndexingCycle = true;
if (totalJobs > 0 && completedJobs >= totalJobs && !isIndexing) {
ranIndexingCycle = true;
}
updateProgressDisplay();
};
window.addEventListener('indexing-progress', progressHandler as EventListener);
appRef.progressHandler = progressHandler;
appRef.clearDoneFlashTimer = clearDoneFlashTimer;
const updateSearchButtonDisplay = () => {
searchButton.innerHTML = /* html */ `
@@ -99,8 +245,7 @@ export function mountSearchBar(
};
updateSearchButtonDisplay();
titleElement.appendChild(searchButton);
titleElement.appendChild(progressContainer);
titleElement.appendChild(searchWrapper);
// Listen for hotkey setting changes
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) {
try {
unmount(appRef.current);
@@ -149,24 +299,30 @@ export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?:
}
}
try {
appRef.clearDoneFlashTimer?.();
} catch {
/* ignore */
}
appRef.clearDoneFlashTimer = undefined;
// Remove progress event listener
if (appRef.progressHandler) {
window.removeEventListener('indexing-progress', appRef.progressHandler as EventListener);
appRef.progressHandler = null;
}
// Remove search trigger button
const searchTrigger = document.querySelector(".search-trigger");
if (searchTrigger) {
searchTrigger.remove();
}
// Remove progress container
const progressContainer = document.querySelector(".search-progress-container");
if (progressContainer) {
progressContainer.remove();
// Remove search trigger wrapper (which contains the button and progress UI)
const searchWrapper = document.querySelector(".search-trigger-wrapper");
if (searchWrapper) {
searchWrapper.remove();
}
// Defensive cleanup for older mounts that may have left the trigger or
// progress container as direct children of the topbar.
document.querySelector(".search-trigger")?.remove();
document.querySelector(".search-progress-container")?.remove();
// Remove search root
const searchRoot = document.querySelector("div[data-search-root]");
if (searchRoot) {
@@ -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 {
display: flex;
align-items: center;
justify-content: center;
flex: none;
height: 32px;
margin-left: 10px;
border-radius: 8px;
border-radius: 0;
cursor: pointer;
transition: all 0.2s ease;
margin-right: auto !important;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
padding: 3px 12px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
box-shadow: none;
backdrop-filter: blur(4px);
user-select: none;
@@ -28,10 +85,12 @@
}
}
/* Light mode styles */
/* Light mode chip */
.search-trigger {
background-color: rgba(248, 250, 252, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
border-bottom: none;
border-radius: 8px 8px 0 0;
background-color: rgba(248, 250, 252, 0.94) !important;
color: #555 !important;
p {
@@ -44,8 +103,10 @@
}
.dark .search-trigger {
background-color: rgba(0, 0, 0, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-bottom: none;
border-radius: 8px 8px 0 0;
background-color: rgba(24, 24, 27, 0.92) !important;
color: #aaa !important;
p {
@@ -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 {
background-color: rgba(255, 213, 0, 0.3);
font-weight: 500;
@@ -83,57 +154,139 @@
animation: shimmer 2s infinite;
}
/* Progress indicator next to search trigger */
.search-progress-container {
display: flex;
align-items: center;
gap: 8px;
margin-left: 8px;
min-width: 120px;
max-width: 200px;
height: 32px;
}
/*
* Thin track flush under `.search-trigger` same width as chip, shared
* `overflow:hidden` rounding on `.search-trigger-anchor`.
*/
.search-progress-bar-wrapper {
flex: 1;
height: 4px;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
flex: none;
height: 0;
min-height: 0;
border: none;
background: transparent;
border-radius: 0;
overflow: hidden;
display: none;
min-width: 60px;
opacity: 1;
transform: none;
pointer-events: none;
transition: height 0.22s cubic-bezier(0.2, 0.7, 0.3, 1);
}
.dark .search-progress-bar-wrapper {
background: rgba(255, 255, 255, 0.1);
.search-progress-bar-wrapper.is-active {
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 {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6);
transition: width 0.3s ease-out;
width: 0%;
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 {
content: '';
position: absolute;
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;
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 {
font-size: 11px;
color: #666;
white-space: nowrap;
display: none;
font-weight: 500;
/* While indexing: same neutral label colour as default (only “Done!” is green). */
.search-progress-text.is-active {
opacity: 1;
transform: translateX(0);
color: #475569;
}
/* Completed pass — green text only here, not on the strip or chip */
.search-progress-text.is-active.is-done-message {
font-weight: 600;
letter-spacing: 0.02em;
color: #15803d !important;
}
.dark .search-progress-text.is-active.is-done-message {
color: #4ade80 !important;
}
/* After DONE_HOLD_MS, fade out before DOM teardown */
.search-progress-text.is-active.is-fading-done {
opacity: 0;
transform: translateX(-4px);
transition:
opacity 0.5s ease,
transform 0.45s ease,
color 0.25s ease;
}
.dark .search-progress-text {
color: #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;
/**
* Navigate to a SEQTA SPA hash route in the most reliable way available.
*
* Setting `location.hash` works when the destination module is already
* registered with SEQTA's hashchange router (as is the case for the
* existing `message`/`assessment` actions, which then poke at the live
* DOM). For navigations that switch to a module the SPA may not have
* loaded yet (courses, forums, folios, portals, documents, reports,
* goals, notices, ...) we instead assign through `location.href` against
* the canonical `${origin}/` base. The path stays `/`, so the browser
* still treats this as a hash-only change in practice but if anything
* went sideways with the path, we get a clean reload that bootstraps the
* SPA fresh, which is far less surprising than a blank screen.
*/
function navigateToHashRoute(routeWithLeadingSlash: string): void {
const target = `${location.origin}/#?page=${routeWithLeadingSlash}`;
window.location.href = target;
}
function navigateInCurrentSeqtaApp(routeWithLeadingSlash: string): void {
window.location.hash = `#?page=${routeWithLeadingSlash}`;
}
/**
* Final-fallback hub when an item has no usable deep-link metadata.
*
* `/dashboard` is the standard SEQTA Learn landing page and is the
* destination the websiteskimmer recording captured for unknown routes.
* `/home` is BetterSEQTA-Plus's custom replacement which only renders
* after our content script has hooked the SPA using it as a fallback
* from a fresh nav can produce a blank frame.
*/
const FALLBACK_ROUTE = "/dashboard";
export const actionMap: Record<string, ActionHandler<any>> = {
message: (async (item: IndexItem & { metadata: MessageMetadata }) => {
window.location.hash = `#?page=/messages`;
@@ -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 bag = metadata as unknown as Record<string, any>;
try {
// Try direct access first
const value = metadata[key];
const value = bag[key];
if (value !== undefined && value !== null) {
return value;
}
if (altKey) {
const altValue = metadata[altKey];
const altValue = bag[altKey];
if (altValue !== undefined && altValue !== null) {
return altValue;
}
}
// Try accessing via Object.keys iteration (works around XrayWrapper)
try {
const keys = Object.keys(metadata);
const keys = Object.keys(bag);
for (const k of keys) {
if (k === key || k === altKey) {
const val = metadata[k];
const val = bag[k];
if (val !== undefined && val !== null) {
return val;
}
}
}
} catch (e) {
} catch {
// Object.keys might fail on XrayWrapper, that's okay
}
return undefined;
@@ -189,14 +225,218 @@ export const actionMap: Record<string, ActionHandler<any>> = {
}) as ActionHandler<any>,
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>,
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>,
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>,
};
@@ -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 { renderComponentMap } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types";
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
import { loadDynamicItems } from "../utils/dynamicItems";
import { getVectorizedItemIds } from "./utils";
import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion";
const META_STORE = "meta";
const LOCK_KEY = "bsq-indexer-lock";
@@ -12,6 +13,50 @@ const HEARTBEAT_INTERVAL = 10000;
const LOCK_TIMEOUT = 20000;
const LOCK_ACQUIRE_TIMEOUT = 5000;
let schemaCheckPromise: Promise<void> | null = null;
async function ensureSchemaCurrent(): Promise<void> {
if (schemaCheckPromise) return schemaCheckPromise;
schemaCheckPromise = (async () => {
let storedRaw: string | null = null;
try {
storedRaw = localStorage.getItem(SCHEMA_VERSION_KEY);
} catch {
return;
}
const stored = storedRaw ? parseInt(storedRaw, 10) : 0;
if (stored === INDEX_SCHEMA_VERSION) return;
console.warn(
`[Indexer] Schema version changed (${stored} -> ${INDEX_SCHEMA_VERSION}); resetting structured + vector indexes.`,
);
try {
await resetDatabase();
} catch (e) {
console.warn("[Indexer] Failed to reset structured database:", e);
}
try {
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase("embeddiaDB");
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
} catch (e) {
console.warn("[Indexer] Failed to reset embeddiaDB:", e);
}
try {
localStorage.setItem(SCHEMA_VERSION_KEY, String(INDEX_SCHEMA_VERSION));
} catch {
/* ignore */
}
})();
return schemaCheckPromise;
}
/* ─────────── Progressmeta helpers ─────────── */
async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
const rec = await get(META_STORE, `progress:${jobId}`);
@@ -162,6 +207,8 @@ export async function loadAllStoredItems(): Promise<IndexItem[]> {
}
export async function runIndexing(): Promise<void> {
await ensureSchemaCurrent();
if (!(await acquireLock())) {
console.debug(
"%c[Indexer] Could not acquire lock - another tab is indexing or this tab is already indexing",
@@ -178,8 +225,6 @@ export async function runIndexing(): Promise<void> {
const totalSteps = jobIds.length + 1;
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs");
let hasStreamingJobs = false;
for (const jobId of jobIds) {
dispatchProgress(
completedJobs,
@@ -255,10 +300,6 @@ export async function runIndexing(): Promise<void> {
await setStoredItems(merged);
await updateLastRunMeta(jobId);
if (jobId === 'messages' || jobId === 'notifications') {
hasStreamingJobs = true;
}
console.debug(
`%c[Indexer] ${job.label}: ${newItemsRaw.length} new items reported by run, ${merged.length} total items now in '${jobId}' store.`,
"color: #00c46f",
@@ -4,6 +4,14 @@ import { notificationsJob } from "./jobs/notifications";
import { forumsJob } from "./jobs/forums";
import { subjectsJob } from "./jobs/subjects";
import { assignmentsJob } from "./jobs/assignments";
import { coursesJob } from "./jobs/courses";
import { noticesJob } from "./jobs/notices";
import { documentsJob } from "./jobs/documents";
import { folioJob } from "./jobs/folio";
import { portalsJob } from "./jobs/portals";
import { reportsJob } from "./jobs/reports";
import { goalsJob } from "./jobs/goals";
import { passiveJob } from "./jobs/passive";
export const jobs: Record<string, Job> = {
messages: messagesJob,
@@ -11,4 +19,12 @@ export const jobs: Record<string, Job> = {
forums: forumsJob,
subjects: subjectsJob,
assignments: assignmentsJob,
courses: coursesJob,
notices: noticesJob,
documents: documentsJob,
folio: folioJob,
portals: portalsJob,
reports: reportsJob,
goals: goalsJob,
passive: passiveJob,
};
@@ -37,7 +37,7 @@ const fetchSubjects = async () => {
const fetchPastAssessments = async (student: number = 69, subjects: any[]) => {
const map: Record<number, any> = {};
// Fetch past assessments for all subjects in parallel (like assessmentsOverview does)
// This is much faster than sequential fetching
await Promise.all(
@@ -49,7 +49,7 @@ const fetchPastAssessments = async (student: number = 69, subjects: any[]) => {
metaclass: subject.metaclass,
student,
});
// Past assessments API can return data in payload.tasks OR payload.pending (or both)
// Based on analytics.rs fetch_past_assessments, we need to check both arrays
const processAssessment = (assessment: any) => {
@@ -65,13 +65,13 @@ const fetchPastAssessments = async (student: number = 69, subjects: any[]) => {
};
}
};
// Match analytics.rs: Check both pending and tasks arrays
// Check for pending array first (matching Rust code order)
if (res.payload?.pending && Array.isArray(res.payload.pending)) {
res.payload.pending.forEach(processAssessment);
}
// Check for tasks array
if (res.payload?.tasks && Array.isArray(res.payload.tasks)) {
res.payload.tasks.forEach(processAssessment);
@@ -81,7 +81,7 @@ const fetchPastAssessments = async (student: number = 69, subjects: any[]) => {
}
})
);
return Object.values(map);
};
@@ -127,7 +127,7 @@ export const assignmentsJob: Job = {
const existingIds = new Set(existingItems.map((i) => i.id));
const student = 69; // TODO: Get from context if available
console.debug("[Assignments job] Starting indexing - fetching all assessments (upcoming and past)...");
// Fetch data in parallel
@@ -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 ForumItem from "../components/items/ForumItem.svelte";
import SubjectItem from "../components/items/SubjectItem.svelte";
import GenericItem from "../components/items/GenericItem.svelte";
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
assessment: AssessmentItem as unknown as typeof SvelteComponent,
message: AssessmentItem as unknown as typeof SvelteComponent,
forum: ForumItem as unknown as typeof SvelteComponent,
subject: SubjectItem as unknown as typeof SvelteComponent,
};
// New categories share a generic, category-aware row component to keep
// the palette consistent without bespoke layouts for every job. The
// component reads `item.metadata.icon` and the `category` to pick a
// sensible default visual treatment.
course: GenericItem as unknown as typeof SvelteComponent,
notice: GenericItem as unknown as typeof SvelteComponent,
document: GenericItem as unknown as typeof SvelteComponent,
folio: GenericItem as unknown as typeof SvelteComponent,
portal: GenericItem as unknown as typeof SvelteComponent,
report: GenericItem as unknown as typeof SvelteComponent,
goal: GenericItem as unknown as typeof SvelteComponent,
passive: GenericItem as unknown as typeof SvelteComponent,
};
@@ -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 idleTimer: 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: {
isActive: boolean;
@@ -92,6 +94,12 @@ export class VectorWorkerManager {
break;
case "progress":
if (
data.status === "processing" ||
data.status === "started"
) {
this.bumpActivityDuringVectorization();
}
if (this.progressCallback) {
this.progressCallback(data);
@@ -120,6 +128,7 @@ export class VectorWorkerManager {
break;
case "streamingProgress":
this.bumpActivityDuringVectorization();
if (this.progressCallback && this.streamingSession?.isActive) {
const { processed } = data;
this.progressCallback({
@@ -150,6 +159,7 @@ export class VectorWorkerManager {
this.readyPromise = null;
this.progressCallback = null;
this.initializationMutex = false;
this.vectorizationLockCount = 0;
this.clearIdleTimer();
this.clearUnloadTimer();
if (this.streamingSession?.isActive) {
@@ -158,15 +168,27 @@ export class VectorWorkerManager {
}
private startIdleTimer() {
if (this.vectorizationLockCount > 0 || this.streamingSession?.isActive) {
return;
}
this.clearIdleTimer();
this.idleTimer = setTimeout(() => {
if (!this.streamingSession?.isActive && this.isInitialized) {
console.debug("[VectorWorker] Auto-shutting down due to 2 minutes of inactivity");
this.resetWorkerState();
}
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");
this.resetWorkerState();
}, 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() {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
@@ -184,6 +206,7 @@ export class VectorWorkerManager {
private scheduleUnload(delay: number = 10000) {
this.clearUnloadTimer();
this.unloadTimer = setTimeout(() => {
if (this.vectorizationLockCount > 0) return;
if (!this.streamingSession?.isActive && this.isInitialized) {
console.debug("[VectorWorker] Auto-unloading after processing complete");
this.resetWorkerState();
@@ -193,6 +216,9 @@ export class VectorWorkerManager {
private updateActivity() {
this.clearUnloadTimer();
if (this.vectorizationLockCount > 0 || this.streamingSession?.isActive) {
return;
}
this.startIdleTimer();
}
@@ -298,17 +324,50 @@ export class VectorWorkerManager {
return;
}
this.progressCallback = onProgress || null;
this.updateActivity();
// Wait until the worker reports a terminal status. Previously this method
// 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();
console.debug(
`Sending ${uniqueItems.length} unique items to worker for processing.`,
);
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;
this.worker!.postMessage({
type: "process",
data: { items: uniqueItems },
});
console.debug(
`Sending ${uniqueItems.length} unique items to worker for processing.`,
);
this.worker!.postMessage({
type: "process",
data: { items: uniqueItems },
});
});
} finally {
this.vectorizationLockCount = Math.max(0, this.vectorizationLockCount - 1);
if (
this.vectorizationLockCount === 0 &&
!this.streamingSession?.isActive
) {
this.startIdleTimer();
}
}
}
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 { searchVectors, type VectorSearchResult } from "./vector/vectorSearch";
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
@@ -36,14 +62,6 @@ const DEFAULT_OPTIONS: Required<HybridSearchOptions> = {
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
*/
@@ -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 {
// For now, boost based on category and metadata
let boost = 0;
// Boost assignments/assessments
if (item.category === "assignments") {
boost += 0.1;
switch (item.category) {
case "assignments":
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.15;
}
// Boost items with subject codes (more structured)
if (item.metadata?.subjectCode) {
boost += 0.05;
}
return Math.min(boost, 0.3); // Cap at 0.3
if (item.metadata?.isUpcoming) boost += 0.12;
if (item.metadata?.subjectCode) boost += 0.04;
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;
return Math.max(-0.2, Math.min(boost, 0.3));
}
/**
@@ -97,11 +143,7 @@ export async function hybridSearch(
// Limit BM25 results to top K
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) {
try {
// 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
const now = Date.now();
const rerankedResults = topBm25Results.map(result => {
const rerankedResults: CombinedResult[] = topBm25Results.map(result => {
const item = result.item;
// Normalize BM25 score to 0-1
// Fuse.js scores: lower is better (0 = perfect match)
// We need to invert: higher score = better match
// Result.score is typically 0-100, where higher = better
// So we normalize it to 0-1
// Static command items don't have dateAdded/metadata/category to score
// against — pass them through untouched so palette commands still
// surface correctly.
if (!isIndexItem(item)) {
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));
// Get vector similarity (0-1, already normalized)
// If item wasn't in vector results, use a default low score
const vectorSimilarity = vectorMap.get(item.id) || 0.3; // Default to 0.3 if not found
// Calculate recency boost (0-1 range)
const recencyBoost = opts.recencyBoost
// Get vector similarity (0-1, already normalized). If item wasn't in
// vector results, use a default mid-low score.
const vectorSimilarity = vectorMap.get(item.id) || 0.3;
const recencyBoost = opts.recencyBoost
? calculateRecencyBoost(item, now) * opts.recencyWeight
: 0;
// Calculate popularity boost (0-1 range)
const popularityBoost = calculatePopularityBoost(item);
// Apply job-specific boost if available
const job = jobs[item.category];
let jobBoost = 0;
if (job && typeof job.boostCriteria === 'function') {
const boost = job.boostCriteria(item, trimmedQuery);
if (boost) {
jobBoost = boost / 100; // Normalize boost to 0-1
jobBoost = boost / 100;
}
}
// Combine scores using weighted average
// BM25 and vector are weighted, boosts are additive
const hybridScore =
// Lexical guardrail: title matches must outweigh fuzzy vector/content
// 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 =
(normalizedBm25Score * opts.bm25Weight) +
(vectorSimilarity * opts.vectorWeight) +
recencyBoost +
popularityBoost +
jobBoost;
jobBoost +
lexicalBonus;
return {
...result,
score: hybridScore * 100, // Scale back to 0-100 for consistency
// Store component scores for debugging (optional, can be removed in production)
_hybridScores: {
bm25: normalizedBm25Score,
vector: vectorSimilarity,
recency: recencyBoost,
popularity: popularityBoost,
jobBoost: jobBoost,
final: hybridScore,
},
score: hybridScore * 100,
};
});
@@ -200,20 +242,27 @@ export async function hybridSearch(
export async function hybridSearchWithExpansion(
bm25Results: CombinedResult[],
query: string,
allItems: IndexItem[],
_allItems: IndexItem[],
options: HybridSearchOptions = {},
): Promise<CombinedResult[]> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const trimmedQuery = query.trim().toLowerCase();
// First, rerank BM25 results
const rerankedBm25 = await hybridSearch(bm25Results, query, options);
// If query is too short, skip vector expansion
if (trimmedQuery.length <= 2) {
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
let vectorResults: VectorSearchResult[] = [];
try {
@@ -222,59 +271,88 @@ export async function hybridSearchWithExpansion(
console.warn("[Hybrid Search] Vector search failed:", e);
return rerankedBm25;
}
// Find vector results that weren't in BM25 results
const bm25Ids = new Set(bm25Results.map(r => r.item.id));
const vectorOnlyResults: CombinedResult[] = [];
const now = Date.now();
vectorResults.forEach(v => {
if (!bm25Ids.has(v.object.id)) {
// This is a semantic match that BM25 missed
const item = v.object;
// Calculate boosts
const recencyBoost = opts.recencyBoost
? calculateRecencyBoost(item, now) * opts.recencyWeight
: 0;
const popularityBoost = calculatePopularityBoost(item);
// Vector-only results get lower base score but high vector similarity
const vectorScore = v.similarity * opts.vectorWeight + recencyBoost + popularityBoost;
// Apply job-specific boost if available
const job = jobs[item.category];
let jobBoost = 0;
if (job && typeof job.boostCriteria === 'function') {
const boost = job.boostCriteria(item, trimmedQuery);
if (boost) {
jobBoost = boost / 100; // Normalize boost
}
// 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;
}
vectorOnlyResults.push({
id: item.id,
type: "dynamic" as const,
score: (vectorScore + jobBoost) * 100,
item,
_hybridScores: {
bm25: 0,
vector: v.similarity,
recency: recencyBoost,
popularity: popularityBoost,
final: vectorScore + jobBoost,
},
});
}
}
// Vector-only results may sit at most at this score:
const vectorOnlyCeiling = strongLexicalFloor === -Infinity
? Infinity
: strongLexicalFloor - 1;
vectorResults.forEach(v => {
if (bm25Ids.has(v.object.id)) return;
// This is a semantic match that BM25 missed
const item = v.object;
// Calculate boosts
const recencyBoost = opts.recencyBoost
? calculateRecencyBoost(item, now) * opts.recencyWeight
: 0;
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
const vectorScore =
v.similarity * opts.vectorWeight + recencyBoost + popularityBoost + vectorOnlyPenalty;
// Apply job-specific boost if available
const job = jobs[item.category];
let jobBoost = 0;
if (job && typeof job.boostCriteria === 'function') {
const boost = job.boostCriteria(item, trimmedQuery);
if (boost) {
jobBoost = boost / 100; // Normalize boost
}
}
let finalScore = (vectorScore + jobBoost) * 100;
if (finalScore > vectorOnlyCeiling) finalScore = vectorOnlyCeiling;
vectorOnlyResults.push({
id: item.id,
type: "dynamic" as const,
score: finalScore,
item,
});
});
// Combine reranked BM25 results with vector-only results
const allResults = [...rerankedBm25, ...vectorOnlyResults];
// Sort by score and return top results
allResults.sort((a, b) => b.score - a.score);
return allResults.slice(0, opts.finalLimit);
}

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