Compare commits

..

171 Commits

Author SHA1 Message Date
SethBurkart123 1b938e2748 feat: 3.4.14 update details 2026-02-16 13:44:15 +11:00
Seth Burkart 652e84783b Merge pull request #369 from StroepWafel/globalSearch-improvements
Global search improvements
2026-02-16 12:30:22 +11:00
StroepWafel 17c2685cae replaced loading font 2026-02-03 15:07:58 +10:30
Seth Burkart 5e89507276 Merge pull request #378 from Jones8683/main
Fix tutorial room not showing in the upcoming lessons block on homepage
2026-02-01 15:00:54 +11:00
Jones8683 0204b97de8 Merge branch 'main' of https://github.com/Jones8683/BetterSEQTA-Plus 2026-02-01 11:37:11 +10:30
Jones8683 d849951a66 fix: tutorial room doesn't show on upcoming lessons 2026-02-01 11:37:05 +10:30
Seth Burkart 1531afd046 Merge pull request #375 from Jones8683/main
Add tutorial room to home page and begin cleaning up some stuff
2026-02-01 10:45:58 +11:00
Jones Jankovic add8a90c77 Merge branch 'BetterSEQTA:main' into main 2026-02-01 09:51:38 +10:30
Jones8683 b9c3c2b5c5 Reset LoadHomePage.ts to upstream version 2026-02-01 09:51:11 +10:30
Seth Burkart 1cc34c38a8 Merge pull request #377 from StroepWafel/theme-docs
Update THEME_CREATION.md
2026-02-01 10:02:21 +11:00
Seth Burkart e5859f419a Merge pull request #374 from Jaxx7594/weightings
feat: Assessments Average weightings
2026-02-01 10:00:25 +11:00
Seth Burkart aadc295bdb Merge branch 'main' into weightings 2026-02-01 09:59:48 +11:00
Seth Burkart af2ef23078 Merge pull request #373 from Jaxx7594/icon-race
fix: Favicon race condition
2026-02-01 09:53:14 +11:00
StroepWafel 7150f03d77 Update THEME_CREATION.md 2026-01-31 21:46:36 +10:30
Jones8683 a381de7c9b final shorthand fix 2026-01-30 20:27:59 +10:30
Jones8683 ce6a5cfdc4 fix: background shorthand warning 2026-01-30 20:11:09 +10:30
Jones8683 de75468f2b most of the rest of the dups 2026-01-30 20:00:30 +10:30
Jones8683 011c1eddb4 remove more duplicates and slight css touch ups 2026-01-30 19:45:44 +10:30
Jones8683 c9443bad27 git please work 2026-01-30 19:29:14 +10:30
Jones8683 445aa9d071 Revert "resolve codefactor warnings about animation shorthand"
This reverts commit e0009ad8dc.
2026-01-30 19:26:38 +10:30
Jones8683 14a2e93b3a styff 2026-01-30 19:25:47 +10:30
Jones8683 3746b05af2 Reapply "Add back GPL license header to iframe.scss"
This reverts commit 1d215d8c75.
2026-01-30 19:19:07 +10:30
Jones8683 1d215d8c75 Revert "Add back GPL license header to iframe.scss"
This reverts commit 401947031b.
2026-01-30 19:17:50 +10:30
Jones8683 b4b1fed576 Merge branch 'main' of https://github.com/Jones8683/BetterSEQTA-Plus 2026-01-30 19:16:14 +10:30
Jones8683 e0009ad8dc resolve codefactor warnings about animation shorthand 2026-01-30 19:16:01 +10:30
Jones Jankovic 401947031b Add back GPL license header to iframe.scss 2026-01-30 16:41:39 +10:30
Jones Jankovic 9da8e104a8 Update src/seqta/utils/Loaders/LoadHomePage.ts
Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
2026-01-30 16:37:52 +10:30
SethBurkart123 3aef2312d0 feat: cleanup and comment removal 2026-01-30 16:07:12 +11:00
Jones8683 b402221477 fix build 2026-01-30 14:58:24 +10:30
Jones8683 8b6bda6dff more cleanup 2026-01-30 08:37:38 +10:30
Jones8683 eed8bac45a fix: clean up more duplicate selectors 2026-01-29 20:53:12 +10:30
Jones Jankovic 3b7bbc9bc6 Merge Codefactor fixes
Apply fixes from CodeFactor
2026-01-29 20:29:52 +10:30
codefactor-io 9820595a70 [CodeFactor] Apply fixes 2026-01-29 09:56:34 +00:00
Jones8683 0a33ca7f6e trigger codefactor for fork 2026-01-29 20:25:22 +10:30
Jones8683 bba96d5159 format: remove some duplicate selectors 2026-01-29 20:21:30 +10:30
Jones8683 d9fe70f442 fix: remove duplicate css block 2026-01-29 20:07:13 +10:30
Jones8683 32c5c8392b fix: tutorials show room as N/A even if room is specified on timetable 2026-01-29 19:40:28 +10:30
Jaxon Lewis-Wilson cc3f06b383 Small refactor (hopefully appease CodeFactor?) 2026-01-29 14:50:48 +08:00
codefactor-io 9ad90e9416 [CodeFactor] Apply fixes to commit 87fdda4 2026-01-28 16:08:12 +00:00
Jaxon Lewis-Wilson 87fdda459a Small refactor 2026-01-29 00:07:36 +08:00
Jaxx7594 6c11bb8143 Merge pull request #2 from StroepWafel/weightings
fix try catch error
2026-01-28 23:16:42 +08:00
Jaxon Lewis-Wilson 391fcfb9dd Various changes/fixes/polish
- Adds weight label below each assessment
 - Correlates assessment name with its ID, so each dom element can be identified: {trimmed_title: assessmentID}
 - Slightly changed regex to be a little more permissive
 - Changed pdfjs src/type due to CORS issues on firefox
 - Permitted weightings to be zero, but not less than zero
 - Modified how assessment items are iterated through, as the previous approach assumed they're in the same order as they are in react
 - Changed parseAssessments() to immediately dispatch parsing for all pdfs asynchronously, as doing it serially is painstakingly slow
 - Discard useless decimals when displaying weight (.0)
2026-01-28 23:06:02 +08:00
StroepWafel 355c5f2d46 fix try catch error 2026-01-28 19:38:20 +10:30
Jaxx7594 afdb4336f8 Merge pull request #1 from StroepWafel/weightings
Weightings
2026-01-28 16:01:51 +08:00
StroepWafel 7a04b22b22 Update index.ts 2026-01-28 17:21:32 +10:30
StroepWafel f594ed4902 i think i fixed it? 2026-01-28 17:19:08 +10:30
StroepWafel f1afa74ee6 Add average grade display and Fix CORS violation
Add average grade display and also fix the CORS violation caused by pdfjs trying to load PDFs from URLs that Firefox extensions can't access.

fixed by instead:
- Fetching the PDF as an ArrayBuffer directly from the URL
- Passing the ArrayBuffer to pdfjs using { data: arrayBuffer } instead of passing a URL
2026-01-28 16:36:16 +10:30
StroepWafel db3f0e0d81 Merge pull request #2 from Jaxx7594/weightings
feat: Assessments Average weightings parsing
2026-01-28 16:12:16 +10:30
Jaxon Lewis-Wilson aceefa16c0 feat: Assessments Average weightings parsing
ONLY PARSING SIDE IS COMPLETE. Does not factor into the average yet.
2026-01-28 13:25:58 +08:00
StroepWafel 89589fe3dc Merge branch 'BetterSEQTA:main' into globalSearch-improvements 2026-01-27 21:25:07 +10:30
Jaxon Lewis-Wilson 2afe2364e4 Narrowed trigger criteria
Instead of running every time document.head mutates, only run when mutation is an attribute change, target is a link, rel includes icon, and attribute is href.
2026-01-27 16:09:15 +08:00
Jaxon Lewis-Wilson 2c0f48877f Appease codefactor 2026-01-27 14:40:02 +08:00
Jaxon Lewis-Wilson 8791038bcf fix: Favicon race condition
Fixes a race condition due to the way the Tes logo has been implemented.
2026-01-27 14:31:33 +08:00
Alphons Joseph aba2ba5bfa Merge pull request #371 from BetterSEQTA/tutor-fix
fix [BUG] Seqta Tutor Lessons Color Not In Home Page And Assesments/Courses showing when not meant to
2026-01-24 18:02:42 +08:00
Alphons Joseph 2b01834765 Merge pull request #372 from BetterSEQTA/copilot/sub-pr-371
[WIP] Fix Seqta Tutor lessons color and course visibility
2026-01-24 12:27:58 +08:00
copilot-swe-agent[bot] 79c4fb511b Remove console.info(lesson) to prevent logging sensitive data
Co-authored-by: Crazypersonalph <93847055+Crazypersonalph@users.noreply.github.com>
2026-01-24 04:26:14 +00:00
Alphons Joseph 14823dcc91 Update src/seqta/utils/Loaders/LoadHomePage.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-24 12:24:14 +08:00
copilot-swe-agent[bot] 9d3494eb56 Initial plan 2026-01-24 04:23:49 +00:00
Alphons Joseph 6af7c32c88 fix [BUG] Seqta Tutor Lessons Color Not In Home Page And Assesments/Courses showing when not meant to
Fixes #343
2026-01-24 12:14:39 +08:00
Seth Burkart c205a52f03 Merge pull request #370 from Jaxx7594/icon
fix: Favicon not showing
2026-01-23 15:33:17 +11:00
SethBurkart123 a6d95f27ed chore: update publish-browser extension 2026-01-23 14:13:11 +11:00
Jaxon Lewis-Wilson f05cd66e88 fix: Favicon not showing 2026-01-23 09:27:14 +08:00
SethBurkart123 a151e7a07e feat: 3.4.13 2026-01-23 08:10:38 +11:00
StroepWafel 1f49fa4bae Update src/plugins/built-in/globalSearch/src/indexing/actions.ts
Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
2026-01-22 19:35:26 +10:30
StroepWafel 86d9cfe50c Update src/plugins/built-in/globalSearch/src/core/index.ts
Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
2026-01-22 19:34:55 +10:30
StroepWafel ae59640162 Update src/plugins/built-in/globalSearch/src/indexing/actions.ts
Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
2026-01-22 19:34:30 +10:30
StroepWafel 5f935cd819 Update src/plugins/built-in/globalSearch/src/indexing/actions.ts
Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
2026-01-22 19:34:18 +10:30
codefactor-io 90e3a946bf [CodeFactor] Apply fixes 2026-01-22 08:42:29 +00:00
StroepWafel 51c940cdd9 Fixed CSS Preload Error 2026-01-22 19:07:48 +10:30
StroepWafel 07ff6d25ca indexing progressbar fixes 2026-01-22 19:05:39 +10:30
StroepWafel 89fd9bbd89 styles 2026-01-22 18:58:33 +10:30
StroepWafel c7033e61fb show indexing progress 2026-01-22 18:58:20 +10:30
StroepWafel 39f8cb1634 include all past assessments 2026-01-22 18:58:03 +10:30
StroepWafel 705c106da8 updates reset cache (clears dead cache from old updates causing issues)
YESS I FINALLY GOT DATABASE INDEXING WORKING TOO
2026-01-22 18:42:45 +10:30
StroepWafel 9b52bae404 update search to hopefully index correctly this time 2026-01-22 18:42:23 +10:30
StroepWafel 9a002d18b0 audit command 2026-01-22 18:42:13 +10:30
StroepWafel 3847ef4269 hopefully fix the issues 2026-01-22 18:41:42 +10:30
StroepWafel f0d0068a2e Add command for compile 2026-01-22 18:40:57 +10:30
StroepWafel b89a6c634c fix vector initialisation? 2026-01-22 11:37:09 +10:30
StroepWafel 29cfb4c792 improve global search 2026-01-22 11:28:43 +10:30
Seth Burkart 5b590512ee Merge pull request #365 from Jaxx7594/line
fix: House/year box hard failing when house_colour does not exist
2026-01-21 21:52:43 +11:00
Seth Burkart 3ff8ef144a Merge pull request #366 from Jones8683/main
Fix the message of the day being unreadable in light mode
2026-01-21 21:52:20 +11:00
Seth Burkart d9abed1c5d Merge pull request #367 from Jaxx7594/bar
fix: Incorrect styling due to SEQTA update
2026-01-21 21:51:40 +11:00
Jaxon Lewis-Wilson 82a789bbec fix: Global fonts
Prior commit was not actually functional upon review
2026-01-21 12:22:45 +08:00
Jaxon Lewis-Wilson ce6538f850 fix: Global font
Overrides SEQTA's new important tag for font under *{}
2026-01-21 11:40:16 +08:00
Jaxon Lewis-Wilson 979ae7149f fix: Incorrect styling due to SEQTA update 2026-01-21 11:17:49 +08:00
codefactor-io 6e71437fe8 [CodeFactor] Apply fixes to commit 940ecf8 2026-01-19 01:41:43 +00:00
Jones8683 940ecf8714 fix: message of the day unreadable in light mode 2026-01-19 12:09:57 +10:30
Jaxon Lewis-Wilson e0cc2e0fdf fix: House/year box hard failing when house_colour does not exist 2026-01-18 00:11:09 +08:00
SethBurkart123 5a19ef92e8 feat: v3.4.12 2025-12-19 17:04:08 +11:00
SethBurkart123 0a3781e9c2 fix: video aspect changing on load 2025-12-19 16:30:38 +11:00
SethBurkart123 a2e39c9d84 feat: add DisclaimerModal component to assessment averages switch 2025-12-19 14:50:55 +11:00
SethBurkart123 520abbb5c3 chore: hide the minecraft server icon 2025-12-19 14:30:26 +11:00
SethBurkart123 d0a11da15f feat: updated privacy statement 2025-12-19 14:29:11 +11:00
Seth Burkart fd5802f9a3 Merge pull request #362 from Jones8683/main
Fix some popup stuff
2025-12-12 13:26:52 +11:00
Jones8683 380d829d19 fix: bad spacing and ordering of popup buttons 2025-12-04 11:52:48 +10:30
Alphons Joseph 702528fb0c Merge pull request #361 from StroepWafel/Privacy-statement
re-add Privacy statement stuff
2025-12-03 18:17:28 +08:00
StroepWafel 2c077bc755 Add dynamic privacy policy notification with API fetch
Implements fetching the privacy policy from the BetterSEQTA+ API and displays a notification if the policy has been updated. Adds sanitization for HTML content, updates settings state to track last shown timestamp, and provides a manual trigger in settings. Refactors notification logic for improved security and maintainability.
2025-11-29 19:47:30 +10:30
StroepWafel fd86e57442 re-add privacy statement
Re-Added privacy statement and ported it over to jones' new system
2025-11-29 16:51:59 +10:30
Alphons Joseph 60ce18280e Merge pull request #357 from Jones8683/main 2025-11-29 09:38:26 +08:00
Jones8683 668dbfd78b fix: remove a comment 2025-11-29 11:57:58 +10:30
Jones8683 810aa17f15 Merge branch 'main' of https://github.com/Jones8683/BetterSEQTA-Plus 2025-11-29 11:46:22 +10:30
Jones8683 b64558e50a fix: whatsnew not scrolling 2025-11-29 11:46:14 +10:30
Jones 9b969bd708 fix: add back the contribute link
Added a section inviting contributions to the README.
2025-11-29 11:37:43 +10:30
Jones 1945f7c592 Merge branch 'BetterSEQTA:main' into main 2025-11-29 11:31:13 +10:30
Alphons Joseph 3e26d9af3c Merge pull request #360 from BetterSEQTA/revert-359-Privacy-statement
Revert "add privacy statement popup"
2025-11-29 08:54:03 +08:00
Alphons Joseph 3c8d7e246b Revert "add privacy statement popup" 2025-11-29 08:53:50 +08:00
Alphons Joseph 2e56518330 Merge pull request #359 from StroepWafel/Privacy-statement
add privacy statement popup
2025-11-29 07:42:45 +08:00
Alphons Joseph e67f3110e0 Update monofile.ts 2025-11-28 22:24:37 +08:00
Alphons Joseph a67f4d2e25 Update monofile.ts 2025-11-28 22:22:11 +08:00
StroepWafel d6025140fd add privacy statement popup 2025-11-28 14:03:17 +10:30
Jones8683 88e9ddf29c fix: uneven spacing on popup buttons 2025-11-18 10:59:43 +10:30
Jones8683 11adc4f933 Merge branch 'main' of https://github.com/Jones8683/BetterSEQTA-Plus 2025-11-12 13:53:47 +10:30
Jones8683 15691e8d94 fix: bottom corners of custom timetable events arent roudned 2025-11-12 13:52:28 +10:30
Jones 754b8d0589 fix: indent in readme 2025-11-11 11:30:40 +10:30
Jones8683 1d634d0da1 feat: seperate file to manage the 3 popups 2025-11-10 19:05:08 +10:30
Jones8683 7136de90be fix: crash in notificatio fetcher 2025-11-10 17:10:53 +10:30
Jones8683 466628479e feat: import close popup function from whatsnew instead of having its own 2025-11-10 16:57:53 +10:30
Jones8683 9c08d0bac2 fix: spam clicking outside a popup restarts closing animation, and remove multiple close popup functions 2025-11-10 16:57:14 +10:30
Jones8683 6c5320007f fix: empty scrollbar in about server popup 2025-11-10 16:29:41 +10:30
Jones8683 4734a443b4 fix: border applying to bottom most item 2025-11-10 16:18:55 +10:30
Jones 7c38e1dc29 fix: no border between submissions when transparency effects on 2025-11-10 11:26:53 +10:30
SethBurkart123 f3f90ef2a8 bump(version): 3.4.11 2025-10-13 15:00:08 +11:00
SethBurkart123 9bcc94aa8a feat(homepage): add empty state for assessments 2025-10-13 14:36:23 +11:00
SethBurkart123 ff2431f269 style(homepage): increased max width on days timetable 2025-10-13 14:28:44 +11:00
SethBurkart123 b442194bc5 fix: move custom shortcuts above regular shortcuts 2025-10-13 14:24:27 +11:00
SethBurkart123 b59c0eae25 feat: make edit mode on themes tab more plain #240 2025-10-13 14:11:55 +11:00
SethBurkart123 e895ce9f6b feat: add colorPicker hex/rgba controls back #351 2025-10-13 13:37:26 +11:00
SethBurkart123 7192f41535 feat: improvements to background music plugin 2025-10-13 13:26:15 +11:00
SethBurkart123 f1b707ab25 style: add line clamp 2025-09-15 11:28:18 +10:00
Seth Burkart 7f47cb8183 Merge pull request #348 from BetterSEQTA/goto-fix
Go to popup not scrolling #342
2025-09-15 11:27:09 +10:00
SethBurkart123 7f5d138bc9 fix: Go to popup not scrolling #342 2025-09-15 11:26:31 +10:00
Seth Burkart cef0f29640 Merge pull request #346 from StroepWafel/Fix-dropdowns
fix: Drop down menu styling
2025-09-15 11:20:32 +10:00
SethBurkart123 157343dda9 fix: remove excess arrow 2025-09-15 11:20:22 +10:00
Seth Burkart 7705c0a3cd Merge pull request #347 from StroepWafel/Fix-wrapping
fix: Text now wraps correctly in most divs
2025-09-15 11:11:24 +10:00
SethBurkart123 7def7b190c fix: duplicate #menu selectors 2025-09-15 11:11:05 +10:00
Alphons Joseph c294fb7369 Merge pull request #344 from StroepWafel:main
feat(plugin):Background Music plugin
2025-09-14 09:29:43 +08:00
StroepWafel 0dbbef0eb1 fix: Text now wraps correctly in most divs
Adjusted divs to wrap text, this can cause some issues where substantially long words get chopped up, but scaling down the font size makes it look weird.
2025-09-12 16:17:18 +09:30
StroepWafel c3c747d996 fix: Drop down menu styling
Fix for drop down menu styling so it doesn't look abhorrent
2025-09-12 15:50:24 +09:30
SethBurkart123 cdc8062275 fix: broken shortcut rendering logic #345 2025-09-11 19:14:53 +10:00
codefactor-io 1857b5ff01 [CodeFactor] Apply fixes to commit 700e3eb 2025-09-08 10:21:47 +00:00
StroepWafel 700e3ebb48 feat(plugin):Background Music plugin
Added a plugin so users can upload and play a .wav audio file as background music. added volume setting, made sure the file is stored across version updates/downdates, made the music stop when the tab is unfocussed, and registered the plugin as official.
2025-09-08 19:12:35 +09:30
Seth Burkart 16b9610301 Merge pull request #340 from Jones8683/main
Bump crxjs version
2025-08-28 14:56:35 +10:00
Jones8683 7d11e203a6 bump: more packages 2025-08-27 19:58:54 +09:30
Jones8683 530f07e640 bump(deps): bump more deps 2025-08-23 10:52:42 +09:30
Jones8683 08586781ce feat: proper gramatical naming for news sources 2025-08-19 12:24:03 +09:30
Jones8683 3ca5a49769 bump(deps): updated deps to match 2025-08-19 11:49:19 +09:30
Jones8683 886c79b3ee fix(deps): crxjs beta being used 2025-08-19 11:39:28 +09:30
Jones 30aa39142d fix: icons not loading 2025-08-19 11:26:59 +09:30
Jones8683 4188ef0d67 format: remove extra lines 2025-08-19 08:14:12 +09:30
Jones8683 ad9a013b00 update injected.scss 2025-08-19 08:13:20 +09:30
Jones8683 cd1f954cc7 feat: remove ugly line in transparency effects 2025-08-18 16:24:02 +09:30
Jones8683 6ef6c986dc fix (build): stop duplicate icon bundling warnings temp 2025-08-18 16:04:53 +09:30
Jones8683 f2e28175a0 feat: update crxjs 2025-08-18 15:53:35 +09:30
SethBurkart123 3ddcb204ef bump(version): 3.4.10.2 2025-08-17 21:27:57 +10:00
Alphons Joseph 766f0e6d3f undebump it 2025-08-17 18:48:09 +08:00
Alphons Joseph f1fcba58ef debump vite plugin 2025-08-17 18:30:19 +08:00
SethBurkart123 dba2d13bb3 bump(deps): publish-browser-extension to 3.0.1 2025-08-17 16:17:24 +10:00
SethBurkart123 30bf345b86 feat: add changelog for 3.4.10 2025-08-17 14:29:26 +10:00
SethBurkart123 0e98f52058 fix: UIfile styling applying on documents 2025-08-17 14:15:24 +10:00
SethBurkart123 f89508deb2 bump(version): 3.4.10 2025-08-17 14:14:44 +10:00
SethBurkart123 c7b69ad97b chore: import updates 2025-08-17 11:27:23 +10:00
SethBurkart123 2ef8bb215a perf: improved efficiency of element scanning in eventmanager 2025-08-17 11:02:41 +10:00
SethBurkart123 16273cf012 style: show icon on image files 2025-08-17 09:07:26 +10:00
Seth Burkart 13d3ccd8e4 Merge pull request #334 from Jones8683/main
Fix windows dev script???
2025-08-17 09:05:29 +10:00
SethBurkart123 7ebc4db9db fix: global search missing styles #335 2025-08-17 09:00:37 +10:00
Jones ed9d662ba4 Update README.md 2025-08-16 20:16:45 +09:30
Jones8683 8647e0b272 feat: update crxjs to out of beta 2025-08-16 19:51:38 +09:30
SethBurkart123 d93abec615 docs: update architecture 2025-08-15 17:32:05 +10:00
Seth Burkart 339b409937 Merge pull request #333 from Jones8683/main
Apply rounded corners when dragging event
2025-08-15 17:28:19 +10:00
Jones8683 860916a5b8 feat: apply rounded corners when dragging event 2025-08-13 10:16:05 +09:30
77 changed files with 6700 additions and 1765 deletions
+2
View File
@@ -0,0 +1,2 @@
legacy-peer-deps=true
+4 -2
View File
@@ -8,7 +8,7 @@
<p align="center"> <p align="center">
<a target="_blank" href="https://chrome.google.com/webstore/detail/betterseqta%20/afdgaoaclhkhemfkkkonemoapeinchel"><img src="https://user-images.githubusercontent.com/95666457/149519713-159d7ef7-2c21-4034-a616-f037ff46d9a4.png" alt="ChromeDownload" width="250"></a> <a target="_blank" href="https://chrome.google.com/webstore/detail/betterseqta%20/afdgaoaclhkhemfkkkonemoapeinchel"><img src="https://user-images.githubusercontent.com/95666457/149519713-159d7ef7-2c21-4034-a616-f037ff46d9a4.png" alt="ChromeDownload" width="250"></a>
<a target="_blank" href="https://discord.gg/YzmbnCDkat"><img src="https://github.com/SethBurkart123/EvenBetterSEQTA/assets/108050083/23055730-b16e-44c0-9bef-221d8545af92" width="240" style="border-radius:10%;" /></a> <a target="_blank" href="https://discord.gg/YzmbnCDkat"><img src="https://github.com/BetterSEQTA/BetterSEQTA-Plus/assets/108050083/23055730-b16e-44c0-9bef-221d8545af92" width="240" style="border-radius:10%;" /></a>
</p> </p>
<div> <div>
@@ -64,7 +64,7 @@ Don't worry- if you get stuck feel free to ask around in the [discord](https://d
- **🐛 Found a bug?** Open an [issue](https://github.com/BetterSEQTA/BetterSEQTA-plus/issues) or fix it yourself! - **🐛 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) - **💬 Need help?** Join our [Discord community](https://discord.gg/YzmbnCDkat)
We have lots of [`good first issue`](https://github.com/BetterSEQTA/BetterSEQTA-plus/labels/good%20first%20issue) labels perfect for beginners! We have lots of https://github.com/BetterSEQTA/BetterSEQTA-Plus/labels/good%20first%20issue labels perfect for beginners!
## Quick Development Setup ## Quick Development Setup
@@ -85,6 +85,8 @@ npm run dev
2. Enable "Developer mode" 2. Enable "Developer mode"
3. Click "Load unpacked" → Select `dist` folder 3. Click "Load unpacked" → Select `dist` folder
4. Visit a SEQTA page to see it work! 🎉 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.
📚 **Need more details?** Check our [detailed setup guide](./docs/GETTING_STARTED_CONTRIBUTING.md#your-first-30-minutes) 📚 **Need more details?** Check our [detailed setup guide](./docs/GETTING_STARTED_CONTRIBUTING.md#your-first-30-minutes)
+1951
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -230,6 +230,6 @@ Ready to contribute? Here's what to do next:
Still confused about something? That's totally normal! Here are your options: Still confused about something? That's totally normal! Here are your options:
- 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat) - 💬 Ask in our [Discord server](https://discord.gg/YzmbnCDkat)
- 🐛 Open an issue on GitHub - 🐛 Open an issue on GitHub
- 📧 Email us at betterseqta@betterseqta.com - 📧 Email us at betterseqta.plus@gmail.com
Remember: **Every expert was once a beginner!** We're here to help you learn and contribute. 🚀 Remember: **Every expert was once a beginner!** We're here to help you learn and contribute. 🚀
+585
View File
@@ -0,0 +1,585 @@
# Theme Creation Guide
This guide covers everything you need to know about creating custom themes for BetterSEQTA+.
## Table of Contents
1. [Overview](#overview)
2. [Theme Structure](#theme-structure)
3. [CSS Variables](#css-variables)
4. [CSS Selectors & Classes](#css-selectors--classes)
5. [Custom Images](#custom-images)
6. [Theme Settings](#theme-settings)
7. [Best Practices](#best-practices)
8. [Examples](#examples)
## Overview
Themes in BetterSEQTA+ allow you to completely customize the appearance of SEQTA Learn. A theme consists of:
- **Custom CSS**: CSS rules that override default styles
- **Custom Images**: Images that can be referenced via CSS variables
- **Theme Metadata**: Name, description, default color, etc.
- **Theme Settings**: Options like forcing dark/light mode
Themes are applied by injecting CSS into the SEQTA page and setting CSS custom properties (variables) on the document root.
## CSS Variables
BetterSEQTA+ provides a comprehensive set of CSS variables that you can use in your themes. These variables automatically adapt to light/dark mode and user preferences.
### Core Background Variables
| Variable | Light Mode | Dark Mode | Description |
|----------|------------|-----------|-------------|
| `--background-primary` | `#ffffff` | `#232323` | Main background color |
| `--background-secondary` | `#e5e7eb` | `#1a1a1a` | Secondary background color |
| `--theme-primary` | `#ffffff` | `#232323` | Primary theme color (same as background-primary) |
| `--theme-secondary` | `#e5e7eb` | `#1a1a1a` | Secondary theme color (same as background-secondary) |
| `--text-primary` | `black` | `white` | Primary text color |
| `--text-color` | `black` | `white` | Text color (alias for text-primary) |
### BetterSEQTA+ Specific Variables
| Variable | Description | Notes |
|----------|-------------|-------|
| `--better-main` | User's selected accent color | Dynamically set based on color picker |
| `--better-sub` | Dark navy color | Always `#161616` |
| `--better-pale` | Lightened version of accent color | Only available in light mode |
| `--better-light` | Lighter version of accent color | Calculated based on brightness |
| `--better-alert-highlight` | Alert/highlight color | `#c61851` |
| `--betterseqta-logo` | Logo URL | Changes based on dark/light mode |
| `--auto-background` | Auto background color | Falls back to `--better-pale` or `--background-secondary` |
| `--navy` | Navy color | `#1a1a1a` |
| `--theme-fg-parts` | Theme foreground parts | `white` |
### Subject/Item Color Variables
| Variable | Description |
|----------|-------------|
| `--item-colour` | Subject/item color | Set dynamically per subject/item |
| `--colour` | Generic color variable | Used in various contexts |
| `--person-colour` | Person/avatar color | `var(--better-light)` for staff |
### Transparency Effects
When transparency effects are enabled, background variables become semi-transparent:
| Variable | Light Mode (Transparent) | Dark Mode (Transparent) |
|----------|--------------------------|-------------------------|
| `--background-primary` | `rgba(255, 255, 255, 0.6)` | `rgba(35, 35, 35, 0.6)` |
| `--background-secondary` | `rgba(229, 231, 235, 0.6)` | `rgba(26, 26, 26, 0.6)` |
### Using CSS Variables
You can use these variables in your custom CSS:
```css
/* Example: Style a custom element */
.my-custom-element {
background: var(--background-primary);
color: var(--text-primary);
border: 1px solid var(--better-main);
}
/* Example: Create a gradient */
.gradient-box {
background: linear-gradient(
to bottom,
var(--better-main),
var(--background-secondary)
);
}
```
## CSS Selectors & Classes
BetterSEQTA+ uses specific CSS selectors and classes that you can target in your themes. Here are the most important ones:
### Main Layout Elements
| Selector | Description |
|----------|-------------|
| `#container` | Main container element |
| `#content` | Content area |
| `#main` | Main content wrapper |
| `#title` | Top title bar |
| `#menu` | Sidebar menu |
### Dark Mode
The `dark` class is added to `html` when dark mode is active:
```css
/* Target dark mode specifically */
html.dark #main {
background: var(--background-primary);
}
/* Target light mode */
html:not(.dark) #main {
background: var(--background-primary);
}
```
### Transparency Effects
When transparency effects are enabled, the `transparencyEffects` class is added to `html`:
```css
html.transparencyEffects .notice {
backdrop-filter: blur(80px);
}
```
### Common SEQTA Classes
| Class/Selector | Description |
|----------------|-------------|
| `.notice` | Notice cards |
| `.day` | Day containers in timetable |
| `.dashboard` | Dashboard sections |
| `.dashlet` | Dashboard widgets |
| `.document` | Document elements |
| `.quickbar` | Quick action bar |
| `.calendar` | Calendar elements |
| `.message` | Message elements |
| `.thread` | Forum threads |
| `.shortcut` | Shortcut buttons |
| `.upcoming-assessment` | Upcoming assessments |
| `.entry.class` | Timetable entries |
### BetterSEQTA+ Specific Classes
| Class | Description |
|-------|-------------|
| `.addedButton` | BetterSEQTA+ added buttons |
| `.tooltip` | Tooltip elements |
| `.notice-unified-content` | Unified notice content |
| `.home-container` | Home page container |
| `.timetable-container` | Timetable container |
| `.notices-container` | Notices container |
### Attribute Selectors
SEQTA uses data attributes that you can target:
```css
/* Target specific data types */
[data-type="student"] .header {
color: var(--text-primary);
}
/* Target specific labels */
[data-label="inbox"] {
/* Styles */
}
```
### CSS Modules
SEQTA uses CSS modules with hashed class names. You can target them using attribute selectors:
```css
/* Target CSS module classes */
[class*="MessageList__MessageList___"] {
background: var(--background-primary);
}
[class*="BasicPanel__BasicPanel___"] {
border-radius: 16px;
}
```
## Custom Images
Themes can include custom images that are made available as CSS variables.
### Adding Images
1. Upload an image in the theme creator
2. Set a CSS variable name (e.g., `custom-background`)
3. The image will be available as `var(--custom-background)`
### Using Image Variables
```css
/* Use as background */
.my-element {
background-image: var(--custom-background);
background-size: cover;
background-position: center;
}
/* Use in content */
.my-icon::before {
content: '';
background-image: var(--custom-icon);
width: 24px;
height: 24px;
}
```
### Image Variable Format
Images are stored as `url()` values:
```css
/* The variable contains: url(blob:...) */
--custom-background: url(blob:chrome-extension://...);
```
## Theme Settings
### Force Dark/Light Mode
You can force a theme to always use dark or light mode:
```typescript
forceDark: true // Force dark mode
forceDark: false // Force light mode
forceDark: undefined // Use user's preference (default)
```
When `forceDark` is set, users cannot toggle dark/light mode while the theme is active.
### Default Color
Set a default accent color for your theme:
```typescript
defaultColour: "rgba(0, 123, 255, 1)" // Blue
defaultColour: "#ff6b6b" // Red (hex format)
```
### Allow Color Changes
Control whether users can change the accent color:
```typescript
CanChangeColour: true // Users can change color
CanChangeColour: false // Color is locked
```
## Best Practices
### 1. Use CSS Variables
Always use CSS variables instead of hardcoded colors:
```css
/* Good */
.my-element {
background: var(--background-primary);
color: var(--text-primary);
}
/* Bad */
.my-element {
background: #ffffff;
color: #000000;
}
```
### 2. Support Both Light and Dark Modes
Unless your theme forces a specific mode, ensure it works in both:
```css
/* Use variables that adapt automatically */
.my-element {
background: var(--background-primary);
color: var(--text-primary);
}
/* Or explicitly handle both modes */
html.dark .my-element {
background: #1a1a1a;
}
html:not(.dark) .my-element {
background: #ffffff;
}
```
### 3. Use !important Sparingly
Only use `!important` when necessary to override SEQTA's default styles:
```css
/* Good - necessary override */
#title {
background: var(--background-primary) !important;
}
/* Bad - unnecessary */
.my-element {
color: var(--text-primary) !important;
}
```
### 4. Test Responsive Design
SEQTA is responsive. Test your theme at different screen sizes:
```css
/* Example: Mobile-specific styles */
@media (max-width: 900px) {
#menu {
transform: translate(-270px);
}
}
```
### 5. Use Semantic Selectors
Prefer semantic selectors over fragile ones:
```css
/* Good - stable selector */
#main > .dashboard > section {
border-radius: 16px;
}
/* Caution - CSS module classes may change */
[class*="Dashboard__Dashboard___"] {
border-radius: 16px;
}
```
### 6. Optimize Images
Keep image file sizes reasonable:
- Use appropriate formats (PNG for transparency, JPG for photos)
- Compress images before uploading
- Consider using CSS for simple graphics instead of images
### 7. Document Your Theme
Include comments in your CSS explaining complex styles:
```css
/*
* Custom gradient background for dashboard
* Uses the user's accent color for a cohesive look
*/
#main > .dashboard {
background: linear-gradient(
135deg,
var(--better-main),
var(--background-secondary)
);
}
```
## Examples
### Example 1: Simple Color Theme
```css
/* Change accent color throughout */
:root {
--better-main: #ff6b6b;
}
/* Style the menu */
#menu {
background: var(--background-primary);
border-right: 3px solid var(--better-main);
}
/* Style buttons */
.uiButton {
background: var(--better-main);
color: var(--text-color);
border-radius: 8px;
}
```
### Example 2: Custom Background Image
```css
/* Use a custom background image */
body {
background-image: var(--custom-background);
background-size: cover;
background-attachment: fixed;
}
/* Add overlay for readability */
#main::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: -1;
}
```
### Example 3: Rounded Corners Theme
```css
/* Make everything more rounded */
#main > .dashboard > section,
.dashlet,
.notice,
.document {
border-radius: 20px !important;
}
/* Round buttons */
.uiButton {
border-radius: 25px !important;
}
```
### Example 4: Minimal Theme
```css
/* Remove shadows and borders */
#main > .dashboard > section,
.dashlet,
.notice {
box-shadow: none !important;
border: 1px solid var(--background-secondary) !important;
}
/* Simplify colors */
#menu {
background: var(--background-primary) !important;
}
/* Remove gradients */
.day {
background: var(--background-primary) !important;
}
```
### Example 5: High Contrast Theme
```css
/* Increase contrast */
:root {
--background-primary: #000000;
--background-secondary: #1a1a1a;
--text-primary: #ffffff;
}
html:not(.dark) {
--background-primary: #ffffff;
--background-secondary: #f0f0f0;
--text-primary: #000000;
}
/* Add borders for clarity */
.dashlet,
.notice,
.document {
border: 2px solid var(--better-main) !important;
}
```
## Advanced Techniques
### CSS Custom Properties Override
You can override CSS variables in your theme:
```css
/* Override a variable */
:root {
--better-main: #your-color;
}
/* Override conditionally */
html.dark {
--background-primary: #your-dark-color;
}
```
### Animations
Add smooth transitions:
```css
/* Smooth color transitions */
#menu li {
transition: background-color 0.3s ease;
}
/* Hover effects */
.dashlet:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
}
```
### Pseudo-elements
Use pseudo-elements for decorative elements:
```css
/* Add decorative border */
.notice::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--better-main);
}
```
## Troubleshooting
### Theme Not Applying
1. Check browser console for CSS errors
2. Verify CSS syntax is correct
3. Ensure selectors are specific enough
4. Check if `!important` is needed
### Colors Not Changing
1. Verify you're using CSS variables
2. Check if `forceDark` is overriding your styles
3. Ensure variables are set on `:root` or `html`
### Images Not Showing
1. Verify image variable name matches CSS
2. Check image format is supported
3. Ensure image size is reasonable
4. Verify `url()` wrapper in CSS
### Dark Mode Issues
1. Test with `forceDark: true` and `forceDark: false`
2. Check if transparency effects are interfering
3. Verify `html.dark` selector is correct
## Resources
- **Theme Creator**: Access via BetterSEQTA+ settings
- **CSS Variables Reference**: See [CSS Variables](#css-variables) section above
- **SEQTA DOM Structure**: Inspect SEQTA pages in browser DevTools
- **BetterSEQTA+ Source**: Check `src/css/injected.scss` for default styles
## Contributing Themes
If you create a great theme, consider sharing it:
1. Export your theme (Share button in theme creator)
2. Submit to the BetterSEQTA+ theme store
3. Or share on GitHub/Discord
---
**Note**: This documentation is based on BetterSEQTA+ v3.4.13. Some details may change in future versions.
+15 -11
View File
@@ -1,12 +1,14 @@
{ {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"version": "3.4.9", "version": "3.4.14",
"type": "module", "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 add add heaps more features!",
"browserslist": "> 0.5%, last 2 versions, not dead", "browserslist": "> 0.5%, last 2 versions, not dead",
"scripts": { "scripts": {
"autoaudit": "npm audit && npm audit fix && npm run build",
"dev": "cross-env MODE=chrome vite dev", "dev": "cross-env MODE=chrome vite dev",
"dev:firefox": "cross-env MODE=firefox vite build --watch", "dev:firefox": "cross-env MODE=firefox vite build --watch",
"compile": "npm i && npm run build",
"build": "cross-env MODE=chrome vite build && cross-env MODE=firefox vite build", "build": "cross-env MODE=chrome vite build && cross-env MODE=firefox vite build",
"build:chrome": "cross-env MODE=chrome vite build", "build:chrome": "cross-env MODE=chrome vite build",
"build:firefox": "cross-env MODE=firefox vite build", "build:firefox": "cross-env MODE=firefox vite build",
@@ -35,19 +37,19 @@
"devDependencies": { "devDependencies": {
"@babel/plugin-transform-runtime": "^7.26.9", "@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9", "@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.91", "@bedframe/cli": "^0.0.95",
"@crxjs/vite-plugin": "2.0.0-beta.32", "@crxjs/vite-plugin": "^2.2.0",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^3.0.1",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"cross-env": "^7.0.3", "cross-env": "^10.0.0",
"dependency-cruiser": "^16.10.0", "dependency-cruiser": "^17.0.1",
"eslint": "9.22.0", "eslint": "^9.33.0",
"glob": "^11.0.1", "glob": "^11.0.1",
"mime-types": "^2.1.35", "mime-types": "^3.0.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"process": "^0.11.10", "process": "^0.11.10",
"publish-browser-extension": "^3.0.0", "publish-browser-extension": "^4.0.0",
"sass": "^1.85.1", "sass": "^1.85.1",
"sass-loader": "^16.0.5", "sass-loader": "^16.0.5",
"semver": "^7.7.1", "semver": "^7.7.1",
@@ -55,6 +57,7 @@
"url": "^0.11.4" "url": "^0.11.4"
}, },
"dependencies": { "dependencies": {
"@bedframe/core": "^0.0.46",
"@codemirror/autocomplete": "^6.18.6", "@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.0", "@codemirror/commands": "^6.8.0",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
@@ -65,10 +68,10 @@
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tsconfig/svelte": "^5.0.4", "@tsconfig/svelte": "^5.0.4",
"@types/chrome": "^0.0.308", "@types/chrome": "^0.1.4",
"@types/color": "^4.2.0", "@types/color": "^4.2.0",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/node": "^22.13.10", "@types/node": "^24.3.0",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.12.3", "@types/webextension-polyfill": "^0.12.3",
@@ -92,6 +95,7 @@
"mathjs": "^14.4.0", "mathjs": "^14.4.0",
"million": "^3.1.11", "million": "^3.1.11",
"motion": "^12.4.12", "motion": "^12.4.12",
"pdfjs-dist": "^5.4.530",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"react": "17", "react": "17",
"react-best-gradient-color-picker": "3.0.11", "react-best-gradient-color-picker": "3.0.11",
+36 -22
View File
@@ -25,24 +25,8 @@ if (document.childNodes[1]) {
init(); init();
} }
/**
* Initializes BetterSEQTA+ on a SEQTA page.
*
* This function performs the following steps:
* 1. Verifies that the current page is a SEQTA page.
* 2. Injects CSS styles for document loading.
* 3. Changes the page's favicon.
* 4. Initializes the extension's settings state.
* 5. Sets default storage if settings are not already defined.
* 6. Calls the main function to apply core BetterSEQTA+ modifications.
* 7. Initializes legacy and new plugins if the extension is enabled.
* 8. Logs success or error messages during initialization.
*/
async function init() { async function init() {
const hasSEQTATitle = document.title.includes("SEQTA Learn"); if (hasSEQTAText && document.title.includes("SEQTA Learn") && !IsSEQTAPage) {
if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) {
// Verify we are on a SEQTA page
IsSEQTAPage = true; IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page"); console.info("[BetterSEQTA+] Verified SEQTA Page");
@@ -50,10 +34,30 @@ async function init() {
documentLoadStyle.textContent = documentLoadCSS; documentLoadStyle.textContent = documentLoadCSS;
document.head.appendChild(documentLoadStyle); document.head.appendChild(documentLoadStyle);
const icon = document.querySelector( replaceIcons();
'link[rel*="icon"]',
)! as HTMLLinkElement; const observer = new MutationObserver((mutations) => {
icon.href = icon48; // Change the icon for (const mutation of mutations) {
if (
mutation.type === "attributes" &&
mutation.target instanceof HTMLLinkElement &&
mutation.target.rel.includes("icon") &&
mutation.attributeName === "href"
) {
replaceIcons();
return;
}
}
});
observer.observe(document.head, {
subtree: true,
attributes: true,
attributeFilter: ["href"],
});
try { try {
await initializeSettingsState(); await initializeSettingsState();
@@ -78,8 +82,18 @@ async function init() {
console.info( console.info(
"[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.", "[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.",
); );
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
} }
} }
} }
function replaceIcons() {
document
.querySelectorAll<HTMLLinkElement>('link[rel*="icon"]')
.forEach((link) => {
if (link.href !== icon48) {
link.href = icon48;
}
});
}
+1 -1
View File
@@ -108,7 +108,7 @@ function getDefaultValues(): SettingsState {
originalSelectedColor: "", originalSelectedColor: "",
DarkMode: true, DarkMode: true,
animations: !isLowEndDevice, animations: !isLowEndDevice,
assessmentsAverage: true, assessmentsAverage: false,
defaultPage: "home", defaultPage: "home",
shortcuts: [ shortcuts: [
{ {
-1
View File
@@ -116,7 +116,6 @@ body {
} }
.cke_panel_listItem > a { .cke_panel_listItem > a {
&:hover { &:hover {
background: #3d3d3e !important; background: #3d3d3e !important;
} }
+171 -281
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
{
"last_updated": "2024-06-15T12:00:00Z",
"whatsnew_html": "<div class=\"whatsnewTextContainer\" style=\"overflow-y: auto; font-size: 1.3rem; line-height: 1.6;\"><p>It has come to our attention that several schools have expressed concerns about BetterSEQTA+. This is very disheartening, so we have decided to release a statement on the situation.</p><p>To view our privacy policy, please click the <strong>shield icon</strong> in the settings&nbsp;menu, or <a href=\"https://betterseqta.org/privacy\" target=\"_blank\" rel=\"noopener noreferrer\" id=\"privacy-link\" style=\"color: inherit; text-decoration: underline; cursor: pointer; white-space: nowrap;\">click here</a>.</p><p style=\"font-weight: bold; margin-top: 15px;\">We never collect any information from you, and aim to provide the best features possible.</p></div>"
}
+10
View File
@@ -2,6 +2,16 @@ div:has(> #rbgcp-wrapper) {
background: transparent !important; background: transparent !important;
} }
#rbgcp-inputs-wrap {
padding-top: 4px !important;
margin-bottom: -8px;
#rbgcp-hex-input,
#rbgcp-input {
height: 28px !important;
}
}
.dark { .dark {
#rbgcp-wrapper { #rbgcp-wrapper {
div[style="padding-top: 11px; position: relative;"] div { div[style="padding-top: 11px; position: relative;"] div {
@@ -108,7 +108,6 @@ export default function Picker({
<ColorPicker <ColorPicker
disableDarkMode={true} disableDarkMode={true}
presets={presets} presets={presets}
hideInputs={customOnChange ? false : true}
value={customThemeColor ?? ""} value={customThemeColor ?? ""}
onChange={(color: string) => { onChange={(color: string) => {
if (customOnChange) { if (customOnChange) {
@@ -0,0 +1,73 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { animate } from 'motion';
let { onConfirm, onCancel, title, message } = $props<{
onConfirm: () => void;
onCancel: () => void;
title: string;
message: string;
}>();
let modalElement: HTMLElement;
$effect(() => {
if (modalElement) {
animate(
modalElement,
{ scale: [0.9, 1], opacity: [0, 1] },
{
type: 'spring',
stiffness: 300,
damping: 25
}
);
}
});
</script>
<div
class="flex fixed inset-0 z-[10000] justify-center items-center bg-black/50"
style="position: fixed; top: 0; left: 0; right: 0; bottom: 0;"
onclick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
onkeydown={(e) => {
if (e.key === 'Escape') onCancel();
}}
role="button"
tabindex="-1"
transition:fade={{ duration: 150 }}
>
<!-- 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"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2 class="mb-3 text-xl font-bold text-gray-900 dark:text-white">
{title}
</h2>
<div class="mb-6 text-lg text-gray-700 whitespace-pre-line dark:text-gray-300">
{message}
</div>
<div class="flex gap-3 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg transition-colors hover:bg-gray-200 dark:bg-zinc-700 dark:text-gray-200 dark:hover:bg-zinc-600"
>
Cancel
</button>
<button
onclick={onConfirm}
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg shadow-inner transition-colors hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600"
>
Enable
</button>
</div>
</div>
</div>
+18 -2
View File
@@ -8,12 +8,12 @@
let select: HTMLSelectElement; let select: HTMLSelectElement;
</script> </script>
<div class="border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 shadow-2xl rounded-lg w-full overflow-clip"> <div class="border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 shadow-2xl rounded-xl w-full overflow-clip">
<select <select
bind:this={select} bind:this={select}
value={state} value={state}
onchange={() => onChange(select.value)} onchange={() => onChange(select.value)}
class="px-4 py-1 text-[0.75rem] dark:text-white w-full border-none bg-transparent focus:ring-0 focus:bg-white/20 dark:focus:bg-black/10" class="px-4 py-2 pr-9 text-[0.875rem] font-medium text-black dark:text-white w-full border-none bg-white/80 dark:bg-zinc-800/70 hover:bg-white/90 dark:hover:bg-zinc-800/80 focus:bg-white/90 dark:focus:bg-zinc-800/80 focus:ring-0 rounded-md appearance-none transition-colors"
> >
{#each options as option} {#each options as option}
<option value={option.value}> <option value={option.value}>
@@ -22,3 +22,19 @@
{/each} {/each}
</select> </select>
</div> </div>
<style>
/* Make native dropdown list readable on Windows */
select option {
background-color: #ffffff;
color: #111827; /* zinc-900 */
}
:global(.dark) select option {
background-color: #1f2937; /* zinc-800 */
color: #ffffff;
}
:global(.dark) div::after {
color: rgba(255, 255, 255, 0.6);
}
</style>
+55 -8
View File
@@ -11,13 +11,16 @@
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"; import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
import { OpenAboutPage } from "@/seqta/utils/Openers/OpenAboutPage"; import { OpenAboutPage } from "@/seqta/utils/Openers/OpenAboutPage";
import { OpenWhatsNewPopup } from "@/seqta/utils/Whatsnew"; import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
import { OpenMinecraftServerPopup } from "@/seqta/utils/AboutMinecraftServer"; //import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
import ColourPicker from "../components/ColourPicker.svelte"; import ColourPicker from "../components/ColourPicker.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "../hooks/SettingsPopup"; import { settingsPopup } from "../hooks/SettingsPopup";
let devModeSequence = ""; let devModeSequence = "";
let showDisclaimerModal = $state(false);
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
const handleDevModeToggle = () => { const handleDevModeToggle = () => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -50,14 +53,24 @@
closeExtensionPopup(); closeExtensionPopup();
}; };
const openMinecraftServer = () => { /* const openMinecraftServer = () => {
OpenMinecraftServerPopup(); OpenMinecraftServerPopup();
closeExtensionPopup(); closeExtensionPopup();
}; */
const openPrivacyStatement = () => {
window.open("https://betterseqta.org/privacy", "_blank");
closeExtensionPopup();
}; };
let { standalone } = $props<{ standalone?: boolean }>(); let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state<boolean>(false); let showColourPicker = $state<boolean>(false);
const showDisclaimer = (onConfirm: () => void, onCancel: () => void) => {
disclaimerCallbacks = { onConfirm, onCancel };
showDisclaimerModal = true;
};
onMount(async () => { onMount(async () => {
settingsPopup.addListener(() => { settingsPopup.addListener(() => {
showColourPicker = false; showColourPicker = false;
@@ -101,23 +114,32 @@
/> />
{#if !standalone} {#if !standalone}
<div class="flex absolute top-1 right-1 gap-1 items-center">
<button <button
onclick={openAbout} onclick={openAbout}
class="absolute top-1 right-[62px] w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700" class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
> >
{"\ueb73"} {"\ueb73"}
</button> </button>
<button <button
onclick={openChangelog} onclick={openChangelog}
class="absolute top-1 right-10 w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700" class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
> >
{"\ue929"} {"\ue929"}
</button> </button>
<button <button
onclick={openPrivacyStatement}
class="flex justify-center items-center w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700"
aria-label="Privacy Statement"
>
{"\uecba"}
</button>
<!-- <button
onclick={openMinecraftServer} onclick={openMinecraftServer}
class="absolute top-1 right-1 w-8 h-8 bg-zinc-100 dark:bg-zinc-700 rounded-xl p-1" class="flex justify-center items-center p-1 w-8 h-8 rounded-xl bg-zinc-100 dark:bg-zinc-700"
aria-label="Open Minecraft Server" aria-label="Open Minecraft Server"
> >
<svg <svg
@@ -247,7 +269,8 @@
transform="translate(18,10)" transform="translate(18,10)"
/> />
</svg> </svg>
</button> </button> -->
</div>
{/if} {/if}
</div> </div>
@@ -256,7 +279,7 @@
{ {
title: "Settings", title: "Settings",
Content: Settings, Content: Settings,
props: { showColourPicker: openColourPicker }, props: { showColourPicker: openColourPicker, showDisclaimer },
}, },
{ title: "Shortcuts", Content: Shortcuts }, { title: "Shortcuts", Content: Shortcuts },
{ title: "Themes", Content: Theme }, { title: "Themes", Content: Theme },
@@ -272,3 +295,27 @@
/> />
{/if} {/if}
</div> </div>
{#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?"
onConfirm={() => {
disclaimerCallbacks?.onConfirm();
showDisclaimerModal = false;
disclaimerCallbacks = null;
}}
onCancel={() => {
disclaimerCallbacks?.onCancel();
showDisclaimerModal = false;
disclaimerCallbacks = null;
}}
/>
{/if}
+41 -3
View File
@@ -10,6 +10,8 @@
import type { SettingsList } from "@/interface/types/SettingsProps" import type { SettingsList } from "@/interface/types/SettingsProps"
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts" import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import PickerSwatch from "@/interface/components/PickerSwatch.svelte" import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getAllPluginSettings } from "@/plugins" import { getAllPluginSettings } from "@/plugins"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types" import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
@@ -90,7 +92,10 @@
loadPluginSettings(); loadPluginSettings();
}) })
const { showColourPicker } = $props<{ showColourPicker: () => void }>(); const { showColourPicker, showDisclaimer } = $props<{
showColourPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
}>();
</script> </script>
{#snippet Setting({ title, description, Component, props }: SettingsList) } {#snippet Setting({ title, description, Component, props }: SettingsList) }
@@ -186,15 +191,16 @@
options: [ options: [
{ value: "australia", label: "Australia" }, { value: "australia", label: "Australia" },
{ value: "usa", label: "USA" }, { value: "usa", label: "USA" },
{ value: "uk", label: "UK" },
{ value: "taiwan", label: "Taiwan" }, { value: "taiwan", label: "Taiwan" },
{ value: "hong_kong", label: "Hong Kong" }, { value: "hong_kong", label: "Hong Kong" },
{ value: "panama", label: "Panama" }, { value: "panama", label: "Panama" },
{ value: "canada", label: "Canada" }, { value: "canada", label: "Canada" },
{ value: "singapore", label: "Singapore" }, { value: "singapore", label: "Singapore" },
{ value: "uk", label: "UK" },
{ value: "japan", label: "Japan" }, { value: "japan", label: "Japan" },
{ value: "netherlands", label: "Netherlands" } { value: "netherlands", label: "Netherlands" }
] ]
} }
} }
] as option} ] as option}
@@ -221,7 +227,20 @@
<div> <div>
<Switch <Switch
state={pluginSettingsValues[plugin.pluginId]?.enabled ?? true} state={pluginSettingsValues[plugin.pluginId]?.enabled ?? true}
onChange={(value) => updatePluginSetting(plugin.pluginId, 'enabled', value)} onChange={async (value) => {
if (plugin.pluginId === 'assessments-average' && value === true) {
showDisclaimer(
async () => {
await updatePluginSetting(plugin.pluginId, 'enabled', true);
},
() => {
// Do nothing on cancel
}
);
return;
}
await updatePluginSetting(plugin.pluginId, 'enabled', value);
}}
/> />
</div> </div>
</div> </div>
@@ -339,6 +358,25 @@
/> />
</div> </div>
</div> </div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Show Privacy Notification</h2>
<p class="text-xs">Show the privacy notification popup on next page load</p>
</div>
<div>
<Button
onClick={async () => {
settingsState.privacyStatementShown = false;
settingsState.privacyStatementLastUpdated = undefined;
closeExtensionPopup();
// Small delay to ensure popup is closed before showing notification
await new Promise(resolve => setTimeout(resolve, 100));
await showPrivacyNotification();
}}
text="Show Now"
/>
</div>
</div>
</div> </div>
{/if} {/if}
</div> </div>
+21 -15
View File
@@ -24,12 +24,18 @@
}); });
const switchChange = (shortcut: any) => { const switchChange = (shortcut: any) => {
const value = $settingsState.shortcuts.find(s => s.name === shortcut); const idx = $settingsState.shortcuts.findIndex(s => s.name === shortcut);
if (value) { if (idx !== -1) {
value.enabled = !value.enabled; // Create a new array with the toggled value to ensure reactivity
settingsState.shortcuts = settingsState.shortcuts; const updated = settingsState.shortcuts.map(s =>
s.name === shortcut ? { ...s, enabled: !s.enabled } : s
);
settingsState.shortcuts = updated;
} else { } else {
settingsState.shortcuts = [...settingsState.shortcuts, { name: shortcut, enabled: true }]; settingsState.shortcuts = [
...settingsState.shortcuts,
{ name: shortcut, enabled: true }
];
} }
} }
@@ -196,16 +202,6 @@
</MotionDiv> </MotionDiv>
</div> </div>
{#each Object.entries(Shortcuts) as shortcut}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<!-- Use DisplayName if it exists, otherwise use the key (shortcut[0]) as a fallback -->
<h2 class="text-sm">{shortcut[1].DisplayName || shortcut[0]}</h2>
</div>
<Switch state={$settingsState.shortcuts.find(s => s.name === shortcut[0])?.enabled ?? false} onChange={() => switchChange(shortcut[0])} />
</div>
{/each}
<!-- Custom Shortcuts Section --> <!-- Custom Shortcuts Section -->
{#each $settingsState.customshortcuts as shortcut, index} {#each $settingsState.customshortcuts as shortcut, index}
<div class="flex justify-between items-center px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
@@ -217,6 +213,16 @@
</button> </button>
</div> </div>
{/each} {/each}
{#each Object.entries(Shortcuts) as shortcut}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<!-- Use DisplayName if it exists, otherwise use the key (shortcut[0]) as a fallback -->
<h2 class="text-sm">{shortcut[1].DisplayName || shortcut[0]}</h2>
</div>
<Switch state={$settingsState.shortcuts.find(s => s.name === shortcut[0])?.enabled ?? false} onChange={() => switchChange(shortcut[0])} />
</div>
{/each}
{:else} {:else}
<div class="p-4 text-center"> <div class="p-4 text-center">
Loading shortcuts... Loading shortcuts...
+5 -2
View File
@@ -21,13 +21,16 @@
<div class="relative w-full"> <div class="relative w-full">
<button <button
onclick={() => editMode = !editMode} onclick={() => editMode = !editMode}
class="absolute top-0 right-0 z-10 w-8 h-8 text-lg rounded-xl font-IconFamily bg-zinc-100 dark:bg-zinc-700">{editMode ? '\ue9e4' : '\uec38'}</button> class="absolute top-0 right-0 z-10 px-2 h-8 text-lg rounded-xl bg-zinc-100 dark:bg-zinc-700">
<span class="mr-2">{editMode ? 'Done' : 'Edit'}</span>
<span class="font-IconFamily">{editMode ? '\ue9e4' : '\uec38'}</span>
</button>
<BackgroundSelector isEditMode={editMode} bind:selectedBackground={selectedBackground} bind:selectNoBackground={selectNoBackground} /> <BackgroundSelector isEditMode={editMode} bind:selectedBackground={selectedBackground} bind:selectNoBackground={selectNoBackground} />
<ThemeSelector isEditMode={editMode} /> <ThemeSelector isEditMode={editMode} />
</div> </div>
{:else} {:else}
<div class="flex items-center justify-center w-full h-full"> <div class="flex justify-center items-center w-full h-full">
<div class="text-lg"> <div class="text-lg">
Open SEQTA and use the embedded settings to access theme settings. 🫠 Open SEQTA and use the embedded settings to access theme settings. 🫠
</div> </div>
+1 -1
View File
@@ -14,7 +14,7 @@ const updatedFirefoxManifest = {
}, },
browser_specific_settings: { browser_specific_settings: {
gecko: { gecko: {
id: pkg.author.email, id: "betterseqta@betterseqta.com",
}, },
}, },
}; };
@@ -7,6 +7,20 @@ import {
import { type Plugin } from "@/plugins/core/types"; import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML"; import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import {
clearStuck,
getClassByPattern,
initStorage,
letterToNumber,
parseAssessments,
processAssessments,
} from "./utils.ts";
interface weightingsStorage {
weightings: Record<string, string>;
assessments: Record<string, string>;
}
const settings = defineSettings({ const settings = defineSettings({
lettergrade: booleanSetting({ lettergrade: booleanSetting({
@@ -23,7 +37,7 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
const instance = new AssessmentsAveragePluginClass(); const instance = new AssessmentsAveragePluginClass();
const assessmentsAveragePlugin: Plugin<typeof settings> = { const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
id: "assessments-average", id: "assessments-average",
name: "Assessment Averages", name: "Assessment Averages",
description: "Adds an average grade to the Assessments page", description: "Adds an average grade to the Assessments page",
@@ -32,8 +46,10 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
settings: instance.settings, settings: instance.settings,
run: async (api) => { run: async (api) => {
await initStorage(api);
clearStuck(api);
api.seqta.onMount(".assessmentsWrapper", async () => { api.seqta.onMount(".assessmentsWrapper", async () => {
// Wait for any assessment item to load first
await waitForElm( await waitForElm(
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']", "#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
true, true,
@@ -41,26 +57,13 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
1000, 1000,
); );
// Helper function to find actual class names by their base pattern await parseAssessments(api);
const getClassByPattern = (
element: Element | Document,
basePattern: string,
): string => {
// Find all classes on the element
const classes = Array.from(element.querySelectorAll("*"))
.flatMap((el) => Array.from(el.classList))
.filter((className) => className.startsWith(basePattern));
return classes.length ? classes[0] : "";
};
// Find actual class names from the DOM
const sampleAssessmentItem = document.querySelector( const sampleAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___']", "[class*='AssessmentItem__AssessmentItem___']",
); );
if (!sampleAssessmentItem) return; if (!sampleAssessmentItem) return;
// Extract all necessary class patterns from a sample assessment item
const assessmentItemClass = const assessmentItemClass =
Array.from(sampleAssessmentItem.classList).find((c) => Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"), c.startsWith("AssessmentItem__AssessmentItem___"),
@@ -83,7 +86,6 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
"AssessmentItem__title___", "AssessmentItem__title___",
); );
// Get Thermoscore classes
const thermoscoreElement = document.querySelector( const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']", "[class*='Thermoscore__Thermoscore___']",
); );
@@ -102,62 +104,34 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
"Thermoscore__text___", "Thermoscore__text___",
); );
// Find assessment list
const assessmentsList = document.querySelector( const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']", "#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
); );
if (!assessmentsList) return; if (!assessmentsList) return;
const gradeElements = document.querySelectorAll( const state = await ReactFiber.find(
"[class*='Thermoscore__text___']", "[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"),
); );
if (!gradeElements.length) return;
// Parse and average grades const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
const letterToNumber: Record<string, number> = { await processAssessments(api, assessmentItems);
"A+": 100,
A: 95,
"A-": 90,
"B+": 85,
B: 80,
"B-": 75,
"C+": 70,
C: 65,
"C-": 60,
"D+": 55,
D: 50,
"D-": 45,
"E+": 40,
E: 35,
"E-": 30,
F: 0,
};
function parseGrade(text: string): number { if (!count || totalWeight === 0) return;
const str = text.trim().toUpperCase();
if (str.includes("/")) {
const [raw, max] = str.split("/").map((n) => parseFloat(n));
return (raw / max) * 100;
}
if (str.includes("%")) {
return parseFloat(str.replace("%", "")) || 0;
}
return letterToNumber[str] ?? 0;
}
let total = 0; const avg = weightedTotal / totalWeight;
let count = 0;
gradeElements.forEach((el) => {
const grade = parseGrade(el.textContent || "");
if (grade > 0) {
total += grade;
count++;
}
});
if (!count) return;
const avg = total / count;
const rounded = Math.ceil(avg / 5) * 5; const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce( const numberToLetter = Object.entries(letterToNumber).reduce(
(acc, [k, v]) => { (acc, [k, v]) => {
@@ -172,31 +146,40 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
? letterAvg ? letterAvg
: `${avg.toFixed(2)}%`; : `${avg.toFixed(2)}%`;
// Prevent duplicate
const existing = assessmentsList.querySelector( const existing = assessmentsList.querySelector(
`[class*='AssessmentItem__title___']`, `[class*='AssessmentItem__title___']`,
); );
if (existing?.textContent === "Subject Average") return; if (existing?.textContent === "Subject Average") return;
// Use the dynamic class names in the HTML template let warningHTML = "";
const averageElement = stringToHTML(/* html */ ` 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="${assessmentItemClass}">
<div class="${metaContainerClass}"> <div class="${metaContainerClass}">
<div class="${metaClass}"> <div class="${metaClass}">
<div class="${simpleResultClass}"> <div class="${simpleResultClass}">
<div class="${titleClass}">Subject Average</div> <div class="${titleClass}">Subject Average</div>
${warningHTML}
</div> </div>
</div> </div>
</div> </div>
<div class="${thermoscoreClass}"> <div class="${thermoscoreClass}">
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%"> <div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
<div class="${textClass}" title="${display}">${display}</div> <div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
</div> </div>
</div> </div>
</div> </div>
`).firstChild; `).firstChild!,
assessmentsList.firstChild,
assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild); );
}); });
}, },
}; };
@@ -0,0 +1,572 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import * as pdfjs from "pdfjs-dist";
pdfjs.GlobalWorkerOptions.workerSrc =
"https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.worker.min.mjs";
export async function initStorage(api: any) {
await api.storage.loaded;
if (!api.storage.weightings) {
api.storage.weightings = {};
}
if (!api.storage.assessments) {
api.storage.assessments = {};
}
}
export function clearStuck(api: any) {
let hasStuckProcessing = false;
for (const key in api.storage.weightings) {
if (api.storage.weightings[key] === "processing") {
delete api.storage.weightings[key];
hasStuckProcessing = true;
}
}
if (hasStuckProcessing) {
api.storage.weightings = { ...api.storage.weightings };
}
}
// Helper function to find actual class names by their base pattern
export const getClassByPattern = (
element: Element | Document,
basePattern: string,
): string => {
const classes = Array.from(element.querySelectorAll("*"))
.flatMap((el) => Array.from(el.classList))
.filter((className) => className.startsWith(basePattern));
return classes.length ? classes[0] : "";
};
export const letterToNumber: Record<string, number> = {
"A+": 100,
A: 95,
"A-": 90,
"B+": 85,
B: 80,
"B-": 75,
"C+": 70,
C: 65,
"C-": 60,
"D+": 55,
D: 50,
"D-": 45,
"E+": 40,
E: 35,
"E-": 30,
F: 0,
};
function parseGrade(text: string): number {
const str = text.trim().toUpperCase();
if (str.includes("/")) {
const [raw, max] = str.split("/").map((n) => parseFloat(n));
return (raw / max) * 100;
}
if (str.includes("%")) {
return parseFloat(str.replace("%", "")) || 0;
}
return letterToNumber[str] ?? 0;
}
function createWeightLabel(
assessmentItem: Element,
weighting: string | undefined,
) {
const statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___']`,
) as HTMLElement;
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";
}
statsContainer.style.position = "relative";
weightLabel.style.position = "absolute";
weightLabel.style.right = "0";
weightLabel.style.top = "50%";
weightLabel.style.transform = "translateY(-50%)";
statsContainer.appendChild(weightLabel);
}
export const isFirefox =
navigator.userAgent.toLowerCase().indexOf("firefox") > -1 &&
!navigator.userAgent.toLowerCase().includes("seamonkey") &&
!navigator.userAgent.toLowerCase().includes("waterfox");
async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
const isBlobUrl = url.startsWith("blob:");
if (isBlobUrl || isFirefox) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`;
const escapedUrl = url.replace(/'/g, "\\'");
script.textContent = `
(function() {
fetch('${escapedUrl}')
.then(response => {
if (!response.ok) {
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
}
return response.arrayBuffer();
})
.then(arrayBuffer => {
window.postMessage({
type: '${requestId}',
success: true,
data: Array.from(new Uint8Array(arrayBuffer))
}, '*');
})
.catch(error => {
window.postMessage({
type: '${requestId}',
success: false,
error: error.message || String(error)
}, '*');
});
})();
`;
const messageHandler = (event: MessageEvent) => {
if (event.data?.type === requestId) {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
if (event.data.success) {
resolve(new Uint8Array(event.data.data).buffer);
} else {
reject(new Error(event.data.error || "Failed to fetch PDF"));
}
}
};
window.addEventListener("message", messageHandler);
(document.head || document.documentElement).appendChild(script);
setTimeout(() => {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
reject(new Error("Timeout fetching PDF"));
}, 30000);
});
}
try {
const response = await fetch(url, {
credentials: "include",
redirect: "follow",
});
if (response.url && response.url.startsWith("blob:")) {
return await fetchPDFAsArrayBuffer(response.url);
}
if (!response.ok) {
throw new Error(
`Failed to fetch PDF: ${response.status} ${response.statusText}`,
);
}
return await response.arrayBuffer();
} catch (error: any) {
if (
error?.message?.includes("blob") ||
error?.message?.includes("Security") ||
error?.message?.includes("CSP")
) {
return await fetchPDFAsArrayBuffer(url);
}
throw error;
}
}
export async function extractPDFText(url: string): Promise<string> {
try {
if (isFirefox) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
const escapedUrl = url
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/"/g, '\\"');
script.textContent = `
(function() {
const requestId = '${requestId}';
const url = '${escapedUrl}';
if (window.pdfjsLib) {
extractPDF();
} else {
const pdfjsScript = document.createElement('script');
pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.min.js';
pdfjsScript.type = 'text/javascript';
pdfjsScript.onload = function() {
extractPDF();
};
pdfjsScript.onerror = function() {
window.postMessage({
type: requestId,
success: false,
error: 'Failed to load pdfjs library'
}, '*');
};
document.head.appendChild(pdfjsScript);
}
function extractPDF() {
try {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = '';
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'arraybuffer';
xhr.withCredentials = true;
xhr.onload = function() {
if (xhr.status !== 200) {
window.postMessage({
type: requestId,
success: false,
error: 'HTTP ' + xhr.status + ': ' + xhr.statusText
}, '*');
return;
}
try {
const arrayBuffer = xhr.response;
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
throw new Error('PDF response is empty');
}
window.pdfjsLib.getDocument({
data: arrayBuffer,
useSystemFonts: true,
verbosity: 0,
useWorkerFetch: false,
isEvalSupported: false
}).promise
.then(pdf => {
const pagePromises = [];
for (let i = 1; i <= pdf.numPages; i++) {
pagePromises.push(
pdf.getPage(i).then(page => {
return page.getTextContent().then(content => {
return content.items.map(item => item.str).join(' ');
});
})
);
}
return Promise.all(pagePromises);
})
.then(pages => {
const text = pages.join('\\n');
window.postMessage({
type: requestId,
success: true,
text: text
}, '*');
})
.catch(error => {
window.postMessage({
type: requestId,
success: false,
error: 'PDF parsing error: ' + (error.message || String(error))
}, '*');
});
} catch (error) {
window.postMessage({
type: requestId,
success: false,
error: 'ArrayBuffer error: ' + (error.message || String(error))
}, '*');
}
};
xhr.onerror = function() {
window.postMessage({
type: requestId,
success: false,
error: 'Network error fetching PDF'
}, '*');
};
xhr.ontimeout = function() {
window.postMessage({
type: requestId,
success: false,
error: 'Timeout fetching PDF'
}, '*');
};
xhr.timeout = 30000;
xhr.send();
} catch (error) {
window.postMessage({
type: requestId,
success: false,
error: 'Setup error: ' + (error.message || String(error))
}, '*');
}
}
})();
`;
const messageHandler = (event: MessageEvent) => {
if (event.data?.type === requestId) {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
if (event.data.success) {
resolve(event.data.text);
} else {
reject(
new Error(event.data.error || "Failed to extract PDF text"),
);
}
}
};
window.addEventListener("message", messageHandler);
(document.head || document.documentElement).appendChild(script);
setTimeout(() => {
window.removeEventListener("message", messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
reject(new Error("Timeout extracting PDF text"));
}, 60000);
});
}
const arrayBuffer = await fetchPDFAsArrayBuffer(url);
if (arrayBuffer.byteLength === 0) {
throw new Error("PDF response is empty");
}
const pdf = await pdfjs.getDocument({
data: arrayBuffer,
useSystemFonts: true,
}).promise;
let text = "";
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
text += content.items.map((item: any) => item.str).join(" ") + "\n";
}
return text;
} catch (error) {
console.error("[BetterSEQTA+] Failed to extract PDF text:", error);
throw error;
}
}
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 (
api.storage.weightings[assessmentID] != undefined &&
api.storage.weightings[assessmentID] !== "processing"
) {
return;
}
api.storage.weightings = {
...api.storage.weightings,
[assessmentID]: "processing",
};
api.storage.assessments = {
...api.storage.assessments,
[title.trim()]: assessmentID,
};
try {
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));
const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
if (pdfUrl.startsWith("blob:")) {
throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`);
}
let text: string;
try {
text = await extractPDFText(pdfUrl);
} catch (error: any) {
if (
isFirefox &&
(error?.message?.includes("blob") ||
error?.message?.includes("Security") ||
error?.message?.includes("CSP"))
) {
await new Promise((resolve) => setTimeout(resolve, 2000));
text = await extractPDFText(pdfUrl);
} else {
throw new Error(`PDF extraction failed: ${error.message}`);
}
}
const match = text.match(/weight:\s*(\d+\.?\d*)/i);
api.storage.weightings = {
...api.storage.weightings,
[assessmentID]: match ? match[1] : "N/A",
};
} catch (error: any) {
api.storage.weightings = {
...api.storage.weightings,
[assessmentID]: "N/A",
};
}
}
export async function parseAssessments(api: any) {
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
if (!marks) return;
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
}
export async function processAssessments(api: any, assessmentItems: Element[]) {
let weightedTotal = 0;
let totalWeight = 0;
let hasInaccurateWeighting = false;
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___']`,
);
if (!titleEl) continue;
const title = titleEl.textContent?.trim();
if (!title) continue;
const assessmentID = api.storage.assessments?.[title];
const weighting = assessmentID
? api.storage.weightings?.[assessmentID]
: undefined;
createWeightLabel(assessmentItem, weighting);
if (
weighting === null ||
weighting === undefined ||
weighting === "N/A" ||
weighting === "processing"
) {
hasInaccurateWeighting = true;
weightedTotal += grade;
totalWeight += 1;
} else {
const weight = parseFloat(weighting);
if (!isNaN(weight) && weight >= 0) {
weightedTotal += grade * weight;
totalWeight += weight;
} else {
weightedTotal += grade;
totalWeight += 1;
hasInaccurateWeighting = true;
}
}
count++;
}
return {
weightedTotal,
totalWeight,
hasInaccurateWeighting,
count,
};
}
@@ -1,7 +1,7 @@
import type { Plugin } from "../../core/types"; import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api"; import { getAssessmentsData } from "./api";
import { renderSkeletonLoader, renderErrorState } from "./ui"; import { renderErrorState, renderSkeletonLoader } from "./ui";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
@@ -106,7 +106,6 @@
max-height: 100%; max-height: 100%;
background: #f8fafc; background: #f8fafc;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 0 0 2px #e2e8f0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
@@ -340,7 +339,7 @@
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: #1a1a1a; color: #1a1a1a;
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem;
line-height: 1.4; line-height: 1.4;
padding-right: 2rem; /* Make room for menu button */ padding-right: 2rem; /* Make room for menu button */
} }
@@ -0,0 +1,120 @@
<script lang="ts">
import localforage from 'localforage'
import { onMount } from 'svelte'
let fileInput = $state<HTMLInputElement | undefined>(undefined)
let dragging = $state(false)
let filename = $state<string | undefined>(undefined)
let durationText = $state<string | undefined>(undefined)
const store = localforage.createInstance({
name: 'background-music-store',
storeName: 'music',
})
async function loadExisting() {
const name = await store.getItem<string>('audio-name')
filename = name ?? undefined
}
onMount(() => { loadExisting() })
function triggerSelect() { fileInput?.click() }
async function handleFiles(files: FileList | null) {
const file = files?.[0]
if (!file) return
// Accept WAV and MP3 files
const isSupported = file.type === 'audio/wav' || file.type === 'audio/mpeg' ||
file.name.toLowerCase().endsWith('.wav') || file.name.toLowerCase().endsWith('.mp3')
if (!isSupported) {
alert('Please select a .wav or .mp3 audio file')
return
}
await store.setItem('audio-blob', file)
await store.setItem('audio-name', file.name)
filename = file.name
// Probe duration
try {
const url = URL.createObjectURL(file)
const audio = new Audio(url)
await new Promise<void>((resolve, reject) => {
audio.onloadedmetadata = () => resolve()
audio.onerror = () => reject()
})
if (!isNaN(audio.duration) && audio.duration !== Infinity) {
const minutes = Math.floor(audio.duration / 60)
const seconds = Math.round(audio.duration % 60)
durationText = `${minutes}:${seconds.toString().padStart(2, '0')}`
} else {
durationText = undefined
}
URL.revokeObjectURL(url)
} catch {
durationText = undefined
}
window.dispatchEvent(new Event('betterseqta-background-music-updated'))
}
function onFileChange() { handleFiles(fileInput?.files || null) }
function onDrop(event: DragEvent) {
event.preventDefault()
dragging = false
handleFiles(event.dataTransfer?.files || null)
}
async function removeAudio() {
await store.removeItem('audio-blob')
await store.removeItem('audio-name')
filename = undefined
durationText = undefined
window.dispatchEvent(new Event('betterseqta-background-music-stop'))
}
</script>
<div
class="relative cursor-pointer select-none"
onclick={() => triggerSelect()}
ondragover={(e) => { e.stopPropagation(); dragging = true }}
ondragleave={() => dragging = false}
ondrop={onDrop}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
triggerSelect()
}
}}
role="button"
tabindex="0"
>
<div class="flex gap-3 items-center">
{#if filename}
<div class="flex items-center px-3 py-1 rounded-lg bg-zinc-200 dark:bg-zinc-800">
<div class="text-xs text-zinc-600 dark:text-zinc-300">
{filename}
<p>{durationText}</p>
</div>
<button
class="flex justify-center items-center m-1 text-lg dark:text-white size-7"
onclick={(e) => { e.stopPropagation(); removeAudio() }}
aria-label="Remove audio"
>&#215;</button>
</div>
{:else}
<div class="flex gap-2 items-center px-3 py-1 text-xs rounded-lg border border-dashed transition border-zinc-300 dark:border-zinc-600 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 text-nowrap">
<span class="text-lg font-IconFamily">{'\ued47'}</span>
<span>Upload audio</span>
</div>
{/if}
</div>
<input type="file" accept="audio/wav,audio/mpeg" class="hidden" bind:this={fileInput} onchange={onFileChange} />
{#if dragging}
<div class="absolute inset-0 rounded-lg bg-zinc-200/40 dark:bg-zinc-700/40"></div>
{/if}
</div>
@@ -0,0 +1,187 @@
import type { Plugin } from "@/plugins/core/types";
import { booleanSetting, componentSetting, defineSettings, numberSetting } from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline";
import BackgroundMusicSetting from "./BackgroundMusicSetting.svelte";
import localforage from "localforage";
const settings = defineSettings({
uploader: componentSetting({
title: "Background Music",
description: "Upload a .wav or .mp3 audio file to play in the background.",
component: BackgroundMusicSetting,
}),
volume: numberSetting({
title: "Volume",
description: "Set background music volume",
default: 0.5,
min: 0,
max: 1,
step: 0.05,
}),
pauseOnHidden: booleanSetting({
title: "Pause when tab hidden",
description: "Pause music when switching to another tab or minimizing the browser",
default: true,
}),
});
const store = localforage.createInstance({
name: "background-music-store",
storeName: "music",
});
let currentAudio: HTMLAudioElement | null = null;
let currentObjectUrl: string | null = null;
let cleanupRegistered = false;
let pendingGestureCancel: (() => void) | null = null;
let visibilityResumeTimeout: number | null = null;
async function loadAudioBlob(): Promise<Blob | null> {
const blob = await store.getItem<Blob>("audio-blob");
return blob && blob instanceof Blob ? blob : null;
}
function stopAndCleanupAudio(): void {
if (currentAudio) {
currentAudio.pause();
currentAudio.src = "";
currentAudio.remove();
currentAudio = null;
}
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = null;
}
}
function ensureGestureStart(handler: () => void): () => void {
const eventTypes = ["pointerdown", "keydown", "touchstart"]; // broad user gesture coverage
const listener = () => {
handler();
for (const type of eventTypes) {
window.removeEventListener(type, listener);
}
};
for (const type of eventTypes) {
window.addEventListener(type, listener, { once: true, passive: true });
}
return () => {
for (const type of eventTypes) {
window.removeEventListener(type, listener);
}
};
}
async function startPlayback(volume: number): Promise<void> {
const blob = await loadAudioBlob();
if (!blob) return;
stopAndCleanupAudio();
currentObjectUrl = URL.createObjectURL(blob);
const audio = new Audio(currentObjectUrl);
audio.loop = true;
audio.volume = Math.max(0, Math.min(1, volume));
audio.preload = "auto";
audio.crossOrigin = "anonymous";
audio.style.display = "none";
document.body.appendChild(audio);
currentAudio = audio;
try {
// Attempt immediate play; may be blocked until gesture
await audio.play();
} catch {
// Ignore; will be started after gesture if enabled
}
}
const backgroundMusicPlugin: Plugin<typeof settings> = {
id: "background-music",
name: "Background Music",
description: "Play your own music in the background while SEQTA is open.",
version: "1.0.0",
settings,
styles,
disableToggle: true,
defaultEnabled: false,
run: async (api) => {
await api.storage.loaded;
// react to specific setting changes
api.settings.onChange("volume" as any, (value: any) => {
const vol = (typeof value === "number" ? value : 0.5) as number;
if (currentAudio) currentAudio.volume = Math.max(0, Math.min(1, vol));
});
api.settings.onChange("pauseOnHidden" as any, (value: any) => {
const pauseOnHidden = (typeof value === "boolean" ? value : true) as boolean;
// If the setting is disabled and audio is currently paused due to tab being hidden, resume it
if (!pauseOnHidden && currentAudio && currentAudio.paused && document.visibilityState === "hidden") {
currentAudio.play().catch(() => {});
}
});
// Note: Stop button/event removed by user; no stop handling needed
// Start if we have audio and autoplay is enabled
const tryStart = async () => {
const vol = (api.settings as any).volume ?? 0.5;
await startPlayback(vol);
};
// Always arm gesture start and attempt immediate start
const cancel = ensureGestureStart(() => { tryStart(); });
cleanupRegistered = true;
(window as any).__betterseqta_bg_music_cancel__ = cancel;
tryStart();
// Pause on tab hide, resume on show with a small delay (if enabled)
const visHandler = () => {
if (!currentAudio) return;
const pauseOnHidden = (api.settings as any).pauseOnHidden ?? true;
if (!pauseOnHidden) return;
if (document.visibilityState === "hidden") {
if (visibilityResumeTimeout !== null) {
clearTimeout(visibilityResumeTimeout);
visibilityResumeTimeout = null;
}
currentAudio.pause();
} else if (document.visibilityState === "visible") {
if (visibilityResumeTimeout !== null) {
clearTimeout(visibilityResumeTimeout);
}
visibilityResumeTimeout = window.setTimeout(() => {
visibilityResumeTimeout = null;
currentAudio?.play().catch(() => {});
}, 200);
}
};
document.addEventListener("visibilitychange", visHandler);
// Allow uploads to trigger refresh
const uploadedHandler = () => {
const vol = (api.settings as any).volume ?? 0.5;
startPlayback(vol);
};
window.addEventListener("betterseqta-background-music-updated", uploadedHandler);
return () => {
document.removeEventListener("visibilitychange", visHandler);
window.removeEventListener("betterseqta-background-music-updated", uploadedHandler);
if (cleanupRegistered && (window as any).__betterseqta_bg_music_cancel__) {
(window as any).__betterseqta_bg_music_cancel__();
(window as any).__betterseqta_bg_music_cancel__ = undefined;
}
if (pendingGestureCancel) { pendingGestureCancel(); pendingGestureCancel = null; }
if (visibilityResumeTimeout !== null) { clearTimeout(visibilityResumeTimeout); visibilityResumeTimeout = null; }
stopAndCleanupAudio();
};
},
};
export default backgroundMusicPlugin;
@@ -0,0 +1,2 @@
.background-music-hidden{display:none}
+47 -7
View File
@@ -5,6 +5,8 @@ import {
defineSettings, defineSettings,
hotkeySetting, hotkeySetting,
} from "../../core/settingsHelpers"; } from "../../core/settingsHelpers";
import styles from "./src/core/styles.css?inline";
// Platform-aware default hotkey // Platform-aware default hotkey
const getDefaultHotkey = () => { const getDefaultHotkey = () => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
@@ -40,8 +42,12 @@ const settings = defineSettings({
if (confirmed) { if (confirmed) {
try { try {
// Dynamically import the worker manager to avoid loading heavy dependencies // Dynamically import modules to avoid loading heavy dependencies
const { VectorWorkerManager } = await import("./src/indexing/worker/vectorWorkerManager"); const { VectorWorkerManager } = await import("./src/indexing/worker/vectorWorkerManager");
const { resetDatabase } = await import("./src/indexing/db");
// Reset vector worker first
try {
const workerManager = VectorWorkerManager.getInstance(); const workerManager = VectorWorkerManager.getInstance();
await workerManager.resetWorker(); await workerManager.resetWorker();
console.log("Vector worker reset successfully"); console.log("Vector worker reset successfully");
@@ -49,23 +55,56 @@ const settings = defineSettings({
console.warn("Failed to reset vector worker:", e); console.warn("Failed to reset vector worker:", e);
} }
// Delete both 'embeddiaDB' and 'betterseqta-index' using native IndexedDB APIs // 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) => { const deleteDb = (dbName: string) => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName); const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve(); req.onsuccess = () => {
req.onerror = () => reject(req.error); console.log(`Successfully deleted database: ${dbName}`);
resolve();
};
req.onerror = () => {
console.error(`Error deleting database ${dbName}:`, req.error);
reject(req.error);
};
req.onblocked = () => { req.onblocked = () => {
reject(new Error(`One database is open, failed to remove: ${dbName}`)); console.warn(`Database ${dbName} deletion blocked - connections still open`);
// Wait and retry once
setTimeout(() => {
const retryReq = indexedDB.deleteDatabase(dbName);
retryReq.onsuccess = () => {
console.log(`Successfully deleted database on retry: ${dbName}`);
resolve();
};
retryReq.onerror = () => reject(retryReq.error);
retryReq.onblocked = () => {
reject(new Error(`One database is open, failed to remove: ${dbName}. Please close other tabs and try again.`));
};
}, 500);
}; };
}); });
}; };
try { try {
await deleteDb("embeddiaDB"); await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index"); await deleteDb("betterseqta-index");
alert("Search index and storage have been reset."); alert("Search index and storage have been reset successfully.");
} catch (e) { } catch (e) {
alert("Failed to reset one or more databases: " + String(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));
} }
} }
}, },
@@ -82,6 +121,7 @@ export default defineLazyPlugin({
disableToggle: true, disableToggle: true,
defaultEnabled: false, defaultEnabled: false,
beta: true, beta: true,
styles: styles,
// Lazy loader - only imports the heavy plugin when actually needed // Lazy loader - only imports the heavy plugin when actually needed
loader: () => import("./src/core/index") loader: () => import("./src/core/index")
@@ -35,6 +35,8 @@
let isIndexing = $state(false); let isIndexing = $state(false);
let completedJobs = $state(0); let completedJobs = $state(0);
let totalJobs = $state(0); let totalJobs = $state(0);
let indexingStatus = $state<string | null>(null);
let indexingDetail = $state<string | null>(null);
let commandPalleteOpen = $state(false); let commandPalleteOpen = $state(false);
let searchTerm = $state(''); let searchTerm = $state('');
@@ -110,10 +112,12 @@
onMount(() => { onMount(() => {
const progressHandler = (event: CustomEvent) => { const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing } = event.detail; const { completed, total, indexing, status, detail } = event.detail;
completedJobs = completed; completedJobs = completed;
totalJobs = total; totalJobs = total;
isIndexing = indexing; isIndexing = indexing;
indexingStatus = status || null;
indexingDetail = detail || null;
}; };
window.addEventListener('indexing-progress', progressHandler as EventListener); window.addEventListener('indexing-progress', progressHandler as EventListener);
@@ -168,6 +172,9 @@
term, term,
commandsFuse, commandsFuse,
commandIdToItemMap, commandIdToItemMap,
dynamicContentFuse,
dynamicIdToItemMap,
true, // sortByRecent
); );
} else { } else {
combinedResults = []; combinedResults = [];
@@ -176,13 +183,19 @@
isLoading = false; isLoading = false;
}; };
const debouncedPerformSearch = debounce(performSearch, 20); // Optimized debounce: shorter delay for better responsiveness
const debouncedPerformSearch = debounce(performSearch, 50);
$effect(() => { $effect(() => {
if (commandPalleteOpen) { if (commandPalleteOpen) {
if (searchTerm === '') { if (searchTerm === '') {
// Immediate search for empty query (shows recent items)
performSearch();
} else if (searchTerm.length <= 2) {
// Immediate search for very short queries
performSearch(); performSearch();
} else { } else {
// Debounced search for longer queries
debouncedPerformSearch(); debouncedPerformSearch();
} }
tick().then(() => searchbar?.focus()); tick().then(() => searchbar?.focus());
@@ -389,19 +402,6 @@
{@render Shortcut({ text: 'Select', keybind: ['↵']})} {@render Shortcut({ text: 'Select', keybind: ['↵']})}
{/if} {/if}
</div> </div>
{#if isIndexing}
<div class="inset-x-0 top-0">
<div class="absolute right-2 -bottom-4 text-[10px] text-zinc-500 dark:text-zinc-400">
Indexing
</div>
<div class="overflow-hidden h-0.5 bg-zinc-200 dark:bg-zinc-700">
<div
class="h-full bg-blue-500 transition-all duration-300 ease-out"
style="width: {(completedJobs / totalJobs) * 100}%"
></div>
</div>
</div>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -4,8 +4,8 @@ import {
booleanSetting, booleanSetting,
buttonSetting, buttonSetting,
defineSettings, defineSettings,
Setting,
hotkeySetting, hotkeySetting,
Setting,
} from "@/plugins/core/settingsHelpers"; } from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
@@ -14,6 +14,7 @@ import { initVectorSearch } from "../search/vector/vectorSearch";
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar"; import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
import { IndexedDbManager } from "embeddia"; import { IndexedDbManager } from "embeddia";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager"; import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { checkAndHandleUpdate } from "../utils/versionCheck";
// Platform-aware default hotkey // Platform-aware default hotkey
const getDefaultHotkey = () => { const getDefaultHotkey = () => {
@@ -50,7 +51,11 @@ const settings = defineSettings({
if (confirmed) { if (confirmed) {
try { try {
// Import resetDatabase function to properly close connections
const { resetDatabase } = await import("../indexing/db");
// Reset the vector worker first // Reset the vector worker first
try {
const workerManager = VectorWorkerManager.getInstance(); const workerManager = VectorWorkerManager.getInstance();
await workerManager.resetWorker(); await workerManager.resetWorker();
console.log("Vector worker reset successfully"); console.log("Vector worker reset successfully");
@@ -58,23 +63,55 @@ const settings = defineSettings({
console.warn("Failed to reset vector worker:", e); console.warn("Failed to reset vector worker:", e);
} }
// Delete both 'embeddiaDB' and 'betterseqta-index' using native IndexedDB APIs // Close all database connections properly before deletion
try {
await resetDatabase();
} catch (e) {
console.warn("Failed to reset betterseqta-index database:", e);
}
// Wait a bit for connections to fully close
await new Promise(resolve => setTimeout(resolve, 100));
// Delete embeddiaDB (vector search database)
const deleteDb = (dbName: string) => { const deleteDb = (dbName: string) => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName); const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve(); req.onsuccess = () => {
req.onerror = () => reject(req.error); console.log(`Successfully deleted database: ${dbName}`);
resolve();
};
req.onerror = () => {
console.error(`Error deleting database ${dbName}:`, req.error);
reject(req.error);
};
req.onblocked = () => { req.onblocked = () => {
reject(new Error(`One database is open, failed to remove: ${dbName}`)); console.warn(`Database ${dbName} deletion blocked - connections still open`);
// Wait and retry once
setTimeout(() => {
const retryReq = indexedDB.deleteDatabase(dbName);
retryReq.onsuccess = () => {
console.log(`Successfully deleted database on retry: ${dbName}`);
resolve();
};
retryReq.onerror = () => reject(retryReq.error);
retryReq.onblocked = () => {
reject(new Error(`One database is open, failed to remove: ${dbName}. Please close other tabs and try again.`));
};
}, 500);
}; };
}); });
}; };
try { try {
await deleteDb("embeddiaDB"); await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index"); await deleteDb("betterseqta-index");
alert("Search index and storage have been reset."); alert("Search index and storage have been reset successfully.");
} catch (e) { } catch (e) {
alert("Failed to reset one or more databases: " + String(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));
} }
} }
}, },
@@ -114,6 +151,27 @@ const globalSearchPlugin: Plugin<typeof settings> = {
run: async (api) => { run: async (api) => {
const appRef = { current: null }; const appRef = { current: null };
// Check for extension updates and clear caches if needed
// 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);
}
}
}, 100);
try { try {
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", { await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
primaryKey: "id", primaryKey: "id",
@@ -126,10 +184,16 @@ const globalSearchPlugin: Plugin<typeof settings> = {
initVectorSearch(); initVectorSearch();
// Warm up vector worker in background to improve initial response time // Warm up vector worker in background to improve initial response time (skip in Firefox)
setTimeout(async () => { setTimeout(async () => {
try { try {
// Only initialize worker if vector search is supported
const { isVectorSearchSupported } = await import("../utils/browserDetection");
if (isVectorSearchSupported()) {
VectorWorkerManager.getInstance(); VectorWorkerManager.getInstance();
} else {
console.debug("[Global Search] Skipping vector worker warm-up (Firefox detected - using text search only)");
}
} catch (error) { } catch (error) {
console.warn("[Global Search] Vector worker warm-up failed:", error); console.warn("[Global Search] Vector worker warm-up failed:", error);
} }
@@ -8,7 +8,7 @@ import browser from "webextension-polyfill";
export function mountSearchBar( export function mountSearchBar(
titleElement: Element, titleElement: Element,
api: any, api: any,
appRef: { current: any; storageChangeHandler?: any }, appRef: { current: any; storageChangeHandler?: any; progressHandler?: any },
) { ) {
if (titleElement.querySelector(".search-trigger")) { if (titleElement.querySelector(".search-trigger")) {
return; return;
@@ -21,6 +21,72 @@ export function mountSearchBar(
const searchButton = document.createElement("div"); const searchButton = document.createElement("div");
searchButton.className = "search-trigger"; searchButton.className = "search-trigger";
// Create progress indicator container
const progressContainer = document.createElement("div");
progressContainer.className = "search-progress-container";
progressContainer.style.cssText = "display: flex; align-items: center; gap: 8px; margin-left: 8px; min-width: 120px;";
// Create progress bar
const progressBarWrapper = document.createElement("div");
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 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");
progressText.className = "search-progress-text";
progressText.style.cssText = "font-size: 11px; color: #666; white-space: nowrap; display: none;";
progressContainer.appendChild(progressBarWrapper);
progressContainer.appendChild(progressText);
// Indexing state
let isIndexing = 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";
}
};
// 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;
updateProgressDisplay();
};
window.addEventListener('indexing-progress', progressHandler as EventListener);
appRef.progressHandler = progressHandler;
const updateSearchButtonDisplay = () => { const updateSearchButtonDisplay = () => {
searchButton.innerHTML = /* html */ ` searchButton.innerHTML = /* html */ `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -34,6 +100,7 @@ export function mountSearchBar(
updateSearchButtonDisplay(); updateSearchButtonDisplay();
titleElement.appendChild(searchButton); titleElement.appendChild(searchButton);
titleElement.appendChild(progressContainer);
// Listen for hotkey setting changes // Listen for hotkey setting changes
const handleStorageChange = (changes: any, area: string) => { const handleStorageChange = (changes: any, area: string) => {
@@ -72,7 +139,7 @@ export function mountSearchBar(
} }
} }
export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any }) { export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any; progressHandler?: any }) {
if (appRef.current) { if (appRef.current) {
try { try {
unmount(appRef.current); unmount(appRef.current);
@@ -82,12 +149,24 @@ export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?:
} }
} }
// Remove progress event listener
if (appRef.progressHandler) {
window.removeEventListener('indexing-progress', appRef.progressHandler as EventListener);
appRef.progressHandler = null;
}
// Remove search trigger button // Remove search trigger button
const searchTrigger = document.querySelector(".search-trigger"); const searchTrigger = document.querySelector(".search-trigger");
if (searchTrigger) { if (searchTrigger) {
searchTrigger.remove(); searchTrigger.remove();
} }
// Remove progress container
const progressContainer = document.querySelector(".search-progress-container");
if (progressContainer) {
progressContainer.remove();
}
// Remove search root // Remove search root
const searchRoot = document.querySelector("div[data-search-root]"); const searchRoot = document.querySelector("div[data-search-root]");
if (searchRoot) { if (searchRoot) {
@@ -69,3 +69,71 @@
.dark .highlight { .dark .highlight {
background-color: rgba(255, 230, 100, 0.4); background-color: rgba(255, 230, 100, 0.4);
} }
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.animate-shimmer {
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;
}
.search-progress-bar-wrapper {
flex: 1;
height: 4px;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
overflow: hidden;
display: none;
min-width: 60px;
}
.dark .search-progress-bar-wrapper {
background: rgba(255, 255, 255, 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;
}
.search-progress-bar::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
animation: shimmer 2s infinite;
border-radius: 2px;
}
.search-progress-text {
font-size: 11px;
color: #666;
white-space: nowrap;
display: none;
font-weight: 500;
}
.dark .search-progress-text {
color: #999;
}
@@ -59,17 +59,132 @@ export const actionMap: Record<string, ActionHandler<any>> = {
}) as ActionHandler<any>, }) as ActionHandler<any>,
assessment: (async (item: IndexItem & { metadata: AssessmentMetadata }) => { assessment: (async (item: IndexItem & { metadata: AssessmentMetadata }) => {
if (item.metadata.isMessageBased) { // Deep clone the entire item to avoid Firefox XrayWrapper issues
// Firefox XrayWrapper prevents direct access to nested properties
let itemClone: IndexItem & { metadata: AssessmentMetadata };
let metadata: AssessmentMetadata;
try {
// First try to clone the entire item
itemClone = JSON.parse(JSON.stringify(item));
metadata = itemClone.metadata || {};
} catch (e) {
console.warn("[Assessment Action] Failed to clone item, trying to clone metadata separately:", e);
try {
// If full clone fails, try cloning just metadata
metadata = JSON.parse(JSON.stringify(item.metadata || {}));
itemClone = { ...item, metadata };
} catch (e2) {
console.warn("[Assessment Action] Failed to clone metadata, using direct access:", e2);
itemClone = item;
metadata = item.metadata || {} as AssessmentMetadata;
}
}
// Try to extract metadata values using multiple methods to handle XrayWrapper
const getMetadataValue = (key: string, altKey?: string): any => {
try {
// Try direct access first
const value = metadata[key];
if (value !== undefined && value !== null) {
return value;
}
if (altKey) {
const altValue = metadata[altKey];
if (altValue !== undefined && altValue !== null) {
return altValue;
}
}
// Try accessing via Object.keys iteration (works around XrayWrapper)
try {
const keys = Object.keys(metadata);
for (const k of keys) {
if (k === key || k === altKey) {
const val = metadata[k];
if (val !== undefined && val !== null) {
return val;
}
}
}
} catch (e) {
// Object.keys might fail on XrayWrapper, that's okay
}
return undefined;
} catch (e) {
console.warn(`[Assessment Action] Failed to access metadata.${key}:`, e);
return undefined;
}
};
if (getMetadataValue('isMessageBased')) {
window.location.hash = `#?page=/messages`; window.location.hash = `#?page=/messages`;
await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20); await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20);
// Select the specific direct message // Select the specific direct message
ReactFiber.find('[class*="Viewer__Viewer___"] > div').setState({ ReactFiber.find('[class*="Viewer__Viewer___"] > div').setState({
selected: new Set([item.metadata.messageId]), selected: new Set([getMetadataValue('messageId')]),
}); });
} else { } else {
window.location.hash = `#?page=/assessments&id=${item.metadata.assessmentId}`; // Extract values - check both camelCase and PascalCase, and try multiple access methods
let programmeId = getMetadataValue('programmeId', 'programmeID');
let metaclassId = getMetadataValue('metaclassId', 'metaclassID');
let assessmentId = getMetadataValue('assessmentId', 'assessmentID');
// Fallback: try to extract assessmentId from item ID if metadata is missing
if ((assessmentId === undefined || assessmentId === null) && itemClone.id && itemClone.id.startsWith('assignment-')) {
const extractedId = itemClone.id.replace('assignment-', '');
assessmentId = Number(extractedId) || extractedId;
console.log("[Assessment Action] Extracted assessmentId from item ID:", assessmentId);
}
// Convert to numbers, but preserve 0 as valid
if (programmeId !== undefined && programmeId !== null && programmeId !== '') {
const num = Number(programmeId);
programmeId = isNaN(num) ? programmeId : num;
}
if (metaclassId !== undefined && metaclassId !== null && metaclassId !== '') {
const num = Number(metaclassId);
metaclassId = isNaN(num) ? metaclassId : num;
}
if (assessmentId !== undefined && assessmentId !== null && assessmentId !== '') {
const num = Number(assessmentId);
assessmentId = isNaN(num) ? assessmentId : num;
}
// Check if values exist (including 0, which is a valid ID)
// Use typeof check to properly handle 0
const hasProgrammeId = programmeId !== undefined && programmeId !== null && programmeId !== '' && typeof programmeId === 'number';
const hasMetaclassId = metaclassId !== undefined && metaclassId !== null && metaclassId !== '' && typeof metaclassId === 'number';
const hasAssessmentId = assessmentId !== undefined && assessmentId !== null && assessmentId !== '' && typeof assessmentId === 'number';
if (hasProgrammeId && hasMetaclassId && hasAssessmentId) {
const url = `#?page=/assessments/${programmeId}:${metaclassId}&item=${assessmentId}`;
console.log("[Assessment Action] ✅ Navigating to:", url);
window.location.hash = url;
} else {
// Fallback: try to navigate to assessments page if metadata is incomplete
console.error("[Assessment Action] ❌ Missing required metadata:", {
programmeId,
metaclassId,
assessmentId,
hasProgrammeId,
hasMetaclassId,
hasAssessmentId,
metadataKeys: Object.keys(metadata),
metadataString: JSON.stringify(metadata),
itemId: itemClone.id,
});
// If we at least have an assessmentId, try to navigate to the general assessments page
if (hasAssessmentId) {
window.location.hash = `#?page=/assessments/upcoming&item=${assessmentId}`;
} else {
console.warn("[Assessment Action] No valid assessment ID, redirecting to upcoming");
window.location.hash = `#?page=/assessments/upcoming`;
}
}
} }
}) as ActionHandler<any>, }) as ActionHandler<any>,
@@ -213,25 +213,54 @@ export async function clear(store: string): Promise<void> {
} }
export async function resetDatabase(): Promise<void> { export async function resetDatabase(): Promise<void> {
// Close cached database connection
if (cachedDb) { if (cachedDb) {
try {
cachedDb.close(); cachedDb.close();
} catch (e) {
console.warn("[DB] Error closing cached database:", e);
}
cachedDb = null; cachedDb = null;
} }
// Close pending database promise
if (dbPromise) { if (dbPromise) {
try { try {
const db = await dbPromise; const db = await dbPromise;
db.close(); db.close();
} catch (e) {} } catch (e) {
// Database might not be open yet, that's okay
}
dbPromise = null; dbPromise = null;
} }
// Wait a bit for connections to fully close
await new Promise(resolve => setTimeout(resolve, 100));
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(DB_NAME); const req = indexedDB.deleteDatabase(DB_NAME);
req.onsuccess = () => { req.onsuccess = () => {
localStorage.removeItem(VERSION_KEY); localStorage.removeItem(VERSION_KEY);
resolve(); resolve();
}; };
req.onerror = () => reject(req.error); req.onerror = () => {
console.error("[DB] Error deleting database:", req.error);
reject(req.error);
};
req.onblocked = () => {
console.warn("[DB] Database deletion blocked - waiting for connections to close");
// Wait a bit longer and try again
setTimeout(() => {
const retryReq = indexedDB.deleteDatabase(DB_NAME);
retryReq.onsuccess = () => {
localStorage.removeItem(VERSION_KEY);
resolve();
};
retryReq.onerror = () => reject(retryReq.error);
retryReq.onblocked = () => {
reject(new Error(`Database is still open. Please close other tabs/windows and try again.`));
};
}, 500);
};
}); });
} }
@@ -1,4 +1,4 @@
import { clear, getAll, get, put, remove } from "./db"; import { clear, get, getAll, put, remove } from "./db";
import { jobs } from "./jobs"; import { jobs } from "./jobs";
import { renderComponentMap } from "./renderComponents"; import { renderComponentMap } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types"; import type { IndexItem, Job, JobContext } from "./types";
@@ -396,18 +396,34 @@ export async function runIndexing(): Promise<void> {
stopHeartbeat(); stopHeartbeat();
allItemsInPrimaryStores = await loadAllStoredItems(); allItemsInPrimaryStores = await loadAllStoredItems();
allItemsInPrimaryStores.forEach(item => { // Create new objects to avoid XrayWrapper issues in Firefox
const itemsWithComponents = allItemsInPrimaryStores.map(item => {
try {
const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId]; const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) { if (jobDef) {
const renderComponent = renderComponentMap[jobDef.renderComponentId]; renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
if (renderComponent) {
item.renderComponent = renderComponent;
}
} else if (renderComponentMap[item.renderComponentId]) { } else if (renderComponentMap[item.renderComponentId]) {
item.renderComponent = renderComponentMap[item.renderComponentId]; renderComponent = renderComponentMap[item.renderComponentId];
}
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
// Use JSON serialization to ensure all nested properties are accessible
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch (e) {
// Fallback to shallow copy if deep clone fails
console.warn("[Indexer] Failed to deep clone item, using shallow copy:", e);
return { ...item, renderComponent };
}
} catch (error) {
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
console.warn("[Indexer] Failed to add render component to item (Firefox XrayWrapper):", error);
return item;
} }
}); });
loadDynamicItems(allItemsInPrimaryStores); loadDynamicItems(itemsWithComponents);
window.dispatchEvent(new Event("dynamic-items-updated")); window.dispatchEvent(new Event("dynamic-items-updated"));
} }
@@ -3,10 +3,12 @@ import { messagesJob } from "./jobs/messages";
import { notificationsJob } from "./jobs/notifications"; import { notificationsJob } from "./jobs/notifications";
import { forumsJob } from "./jobs/forums"; import { forumsJob } from "./jobs/forums";
import { subjectsJob } from "./jobs/subjects"; import { subjectsJob } from "./jobs/subjects";
import { assignmentsJob } from "./jobs/assignments";
export const jobs: Record<string, Job> = { export const jobs: Record<string, Job> = {
messages: messagesJob, messages: messagesJob,
notifications: notificationsJob, notifications: notificationsJob,
forums: forumsJob, forums: forumsJob,
subjects: subjectsJob, subjects: subjectsJob,
assignments: assignmentsJob,
}; };
@@ -0,0 +1,369 @@
import type { IndexItem, Job } from "../types";
const fetchJSON = async (url: string, body: any) => {
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();
};
const fetchUpcomingAssessments = async (student: number = 69) => {
try {
const res = await fetchJSON("/seqta/student/assessment/list/upcoming?", {
student,
});
// Match analytics.rs: payload is an array, return empty array if not found
return Array.isArray(res.payload) ? res.payload : [];
} catch (e) {
console.error("[Assignments job] Failed to fetch upcoming assessments:", e);
return [];
}
};
const fetchSubjects = async () => {
try {
const res = await fetchJSON("/seqta/student/load/subjects?", {});
return res.payload
?.filter((s: any) => s.active === 1)
?.flatMap((s: any) => s.subjects) || [];
} catch (e) {
console.error("[Assignments job] Failed to fetch subjects:", e);
return [];
}
};
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(
subjects.map(async (subject) => {
try {
// Match analytics.rs exactly: parameter order is programme, metaclass, student
const res = await fetchJSON("/seqta/student/assessment/list/past?", {
programme: subject.programme,
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) => {
if (assessment && assessment.id) {
// Ensure programme and metaclass are included from the subject
// Use the assessment's IDs if available, otherwise fall back to subject's
map[assessment.id] = {
...assessment,
programme: assessment.programme || assessment.programmeID || subject.programme,
programmeID: assessment.programmeID || assessment.programme || subject.programme,
metaclass: assessment.metaclass || assessment.metaclassID || subject.metaclass,
metaclassID: assessment.metaclassID || assessment.metaclass || subject.metaclass,
};
}
};
// 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);
}
} catch (e) {
console.warn(`[Assignments job] Failed to fetch past assessments for subject ${subject.code || subject.subject || 'unknown'}:`, e);
}
})
);
return Object.values(map);
};
export const assignmentsJob: Job = {
id: "assignments",
label: "Assignments",
renderComponentId: "assessment",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 }, // Daily
boostCriteria: (item, searchTerm) => {
if (searchTerm === "") {
return -100;
}
let score = 0;
// Boost upcoming assignments
if (item.metadata.dueDate) {
const dueDate = new Date(item.metadata.dueDate).getTime();
const now = Date.now();
const daysUntilDue = (dueDate - now) / (1000 * 60 * 60 * 24);
if (daysUntilDue >= 0 && daysUntilDue <= 7) {
score += 0.05; // Boost assignments due within a week
}
if (daysUntilDue < 0) {
score -= 0.1; // Penalty for overdue assignments
}
}
// Boost if submitted
if (item.metadata.submitted) {
score += 0.02;
}
return score;
},
run: async (ctx) => {
// Don't filter by existing IDs - we want to process ALL assessments (both new and old)
// to ensure metadata is up-to-date and all past assignments are indexed
const existingItems = await ctx.getStoredItems("assignments");
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
const [upcoming, subjects] = await Promise.all([
fetchUpcomingAssessments(student),
fetchSubjects(),
]);
console.debug(`[Assignments job] Fetched ${upcoming.length} upcoming assessments and ${subjects.length} subjects`);
// Fetch past assessments for ALL subjects to ensure we get all historical assignments
const past = await fetchPastAssessments(student, subjects);
console.debug(`[Assignments job] Fetched ${past.length} past assessments`);
// Create a lookup map from subject code to programme/metaclass
const subjectLookup = new Map<string, { programme: number; metaclass: number }>();
subjects.forEach((s: any) => {
if (s.code && s.programme && s.metaclass) {
subjectLookup.set(s.code, { programme: s.programme, metaclass: s.metaclass });
}
});
// Combine and deduplicate
const allAssessments = new Map<number, any>();
upcoming.forEach((a: any) => {
if (a && a.id) {
// Prioritize capital ID fields (programmeID, metaclassID) as that's what the API returns
let programme = a.programmeID || a.programme;
let metaclass = a.metaclassID || a.metaclass;
// If missing, try to get from subject lookup
if ((!programme || !metaclass) && a.code) {
const subjectInfo = subjectLookup.get(a.code);
if (subjectInfo) {
programme = programme || subjectInfo.programme;
metaclass = metaclass || subjectInfo.metaclass;
}
}
allAssessments.set(a.id, {
...a,
programme,
metaclass,
programmeID: programme, // Ensure both formats are available
metaclassID: metaclass,
isUpcoming: true,
});
}
});
past.forEach((a: any) => {
if (a && a.id) {
// Prioritize capital ID fields (programmeID, metaclassID) as that's what the API returns
let programme = a.programmeID || a.programme;
let metaclass = a.metaclassID || a.metaclass;
const existing = allAssessments.get(a.id);
if (existing) {
// Merge past assessment data, ensuring programme/metaclass are preserved
// Use existing values if new ones are missing
programme = programme || existing.programme || existing.programmeID;
metaclass = metaclass || existing.metaclass || existing.metaclassID;
Object.assign(existing, {
...a,
programme,
metaclass,
programmeID: programme,
metaclassID: metaclass,
});
} else {
allAssessments.set(a.id, {
...a,
programme,
metaclass,
programmeID: programme,
metaclassID: metaclass,
isUpcoming: false
});
}
}
});
const items: IndexItem[] = [];
const processedIds = new Set<string>();
// Process assessments in batches to avoid overwhelming the API
const assessmentArray = Array.from(allAssessments.values());
const pastCount = assessmentArray.filter(a => !a.isUpcoming).length;
const upcomingCount = assessmentArray.filter(a => a.isUpcoming).length;
console.debug(`[Assignments job] Processing ${assessmentArray.length} total assessments (${upcomingCount} upcoming, ${pastCount} past)`);
const batchSize = 15; // Increased batch size for better performance
// Skip fetching assessment details - the API endpoint doesn't exist or returns 404
// Details are optional and not critical for search functionality
// Process ALL assessments (both upcoming and past) to ensure everything is indexed
for (let i = 0; i < assessmentArray.length; i += batchSize) {
const batch = assessmentArray.slice(i, i + batchSize);
const batchItems = await Promise.all(
batch.map(async (assessment) => {
const id = `assignment-${assessment.id}`;
// Skip if already processed in this batch
if (processedIds.has(id)) {
return null;
}
processedIds.add(id);
// Process ALL assessments (both new and existing, upcoming and past)
// This ensures all historical assignments are indexed and metadata is up-to-date
// Skip fetching details - API endpoint doesn't exist
const description = "";
const subjectName = assessment.subject || assessment.code || "Unknown Subject";
const dueDate = assessment.due ? new Date(assessment.due).getTime() : null;
// Prioritize capital ID fields (programmeID, metaclassID) as that's what the API returns
const programmeId = assessment.programmeID || assessment.programme;
const metaclassId = assessment.metaclassID || assessment.metaclass;
// Validate that we have the required IDs for navigation
if (!programmeId || !metaclassId || !assessment.id) {
console.warn(`[Assignments job] Skipping assignment ${assessment.id} - missing required IDs:`, {
programmeId,
metaclassId,
assessmentId: assessment.id,
programmeID: assessment.programmeID,
metaclassID: assessment.metaclassID,
programme: assessment.programme,
metaclass: assessment.metaclass,
assessment,
});
return null;
}
// Convert to numbers, preserving 0 as valid
let finalProgrammeId: number | undefined;
let finalMetaclassId: number | undefined;
if (programmeId !== undefined && programmeId !== null && programmeId !== '') {
const num = Number(programmeId);
finalProgrammeId = isNaN(num) ? undefined : num;
}
if (metaclassId !== undefined && metaclassId !== null && metaclassId !== '') {
const num = Number(metaclassId);
finalMetaclassId = isNaN(num) ? undefined : num;
}
// Final validation - check for actual numbers (including 0)
if (finalProgrammeId === undefined || finalMetaclassId === undefined || !assessment.id) {
console.error(`[Assignments job] ❌ Skipping assignment ${assessment.id} - invalid IDs after conversion:`, {
programmeId: finalProgrammeId,
metaclassId: finalMetaclassId,
assessmentId: assessment.id,
rawProgrammeId: programmeId,
rawMetaclassId: metaclassId,
assessment,
});
return null;
}
const item: IndexItem = {
id,
text: assessment.title || assessment.name || "Untitled Assignment",
category: "assignments",
content: `${description}\nSubject: ${subjectName}\nDue: ${assessment.due || "No due date"}`.trim(),
dateAdded: dueDate || Date.now(),
metadata: {
assessmentId: assessment.id,
assessmentID: assessment.id, // Store both variants for compatibility
subject: subjectName,
subjectCode: assessment.code,
dueDate: assessment.due,
programmeId: finalProgrammeId,
programmeID: finalProgrammeId, // Store both variants for compatibility
metaclassId: finalMetaclassId,
metaclassID: finalMetaclassId, // Store both variants for compatibility
submitted: assessment.submitted || false,
isUpcoming: assessment.isUpcoming || false,
term: assessment.term,
timestamp: assessment.due || new Date().toISOString(), // Required by AssessmentMetadata interface
},
actionId: "assessment",
renderComponentId: "assessment",
};
console.debug(`[Assignments job] ✅ Created item for assignment ${assessment.id}:`, {
id: item.id,
programmeId: item.metadata.programmeId,
programmeID: item.metadata.programmeID,
metaclassId: item.metadata.metaclassId,
metaclassID: item.metadata.metaclassID,
assessmentId: item.metadata.assessmentId,
assessmentID: item.metadata.assessmentID,
});
return item;
})
);
// Filter out nulls and add to items
batchItems.forEach(item => {
if (item) {
items.push(item);
}
});
// Small delay between batches to avoid rate limiting
if (i + batchSize < assessmentArray.length) {
await new Promise(resolve => setTimeout(resolve, 50)); // Reduced delay
}
}
const newItemsCount = items.filter(item => !existingIds.has(item.id)).length;
const updatedItemsCount = items.length - newItemsCount;
console.debug(`[Assignments job] Indexed ${items.length} assignment items (${newItemsCount} new, ${updatedItemsCount} updated)`);
return items;
},
purge: (items) => {
// Keep ALL assignments - don't purge old ones as users may want to search for them
// Only remove items that are truly invalid (missing required metadata)
return items.filter((i) => {
// Keep all items that have valid metadata
return i.metadata &&
i.metadata.assessmentId &&
i.metadata.programmeId !== undefined &&
i.metadata.metaclassId !== undefined;
});
},
};
@@ -1,4 +1,4 @@
import type { Job, IndexItem } from "../types"; import type { IndexItem, Job } from "../types";
const fetchForums = async () => { const fetchForums = async () => {
const res = await fetch(`${location.origin}/seqta/student/load/forums`, { const res = await fetch(`${location.origin}/seqta/student/load/forums`, {
@@ -1,4 +1,4 @@
import type { Job, IndexItem } from "../types"; import type { IndexItem, Job } from "../types";
import { htmlToPlainText } from "../utils"; import { htmlToPlainText } from "../utils";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
import { VectorWorkerManager } from "../worker/vectorWorkerManager"; import { VectorWorkerManager } from "../worker/vectorWorkerManager";
@@ -604,22 +604,34 @@ export const messagesJob: Job = {
if (processedItems.length > 0) { if (processedItems.length > 0) {
try { try {
const currentItems = await loadAllStoredItems(); const currentItems = await loadAllStoredItems();
currentItems.forEach((item) => { // Create new objects to avoid XrayWrapper issues in Firefox
const itemsWithComponents = currentItems.map((item) => {
try {
const jobDef = const jobDef =
jobs[item.category] || jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) || Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId]; jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) { if (jobDef) {
const renderComponent = renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
renderComponentMap[jobDef.renderComponentId];
if (renderComponent) {
item.renderComponent = renderComponent;
}
} else if (renderComponentMap[item.renderComponentId]) { } else if (renderComponentMap[item.renderComponentId]) {
item.renderComponent = renderComponentMap[item.renderComponentId]; renderComponent = renderComponentMap[item.renderComponentId];
}
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch (e) {
// Fallback to shallow copy if deep clone fails
return { ...item, renderComponent };
}
} catch (error) {
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
return item;
} }
}); });
loadDynamicItems(currentItems); loadDynamicItems(itemsWithComponents);
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("dynamic-items-updated", { new CustomEvent("dynamic-items-updated", {
detail: { detail: {
@@ -1,4 +1,4 @@
import type { Job, IndexItem } from "../types"; import type { IndexItem, Job } from "../types";
import { htmlToPlainText } from "../utils"; import { htmlToPlainText } from "../utils";
import { fetchMessageContent } from "./messages"; import { fetchMessageContent } from "./messages";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
@@ -372,23 +372,34 @@ export const notificationsJob: Job = {
if (items.length > 0) { if (items.length > 0) {
try { try {
const currentItems = await loadAllStoredItems(); const currentItems = await loadAllStoredItems();
currentItems.forEach((item) => { // Create new objects to avoid XrayWrapper issues in Firefox
const itemsWithComponents = currentItems.map((item) => {
try {
const jobDef = const jobDef =
jobs[item.category] || jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) || Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId]; jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) { if (jobDef) {
const renderComponent = renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
renderComponentMap[jobDef.renderComponentId];
if (renderComponent) {
item.renderComponent = renderComponent;
}
} else if (renderComponentMap[item.renderComponentId]) { } else if (renderComponentMap[item.renderComponentId]) {
item.renderComponent = renderComponent = renderComponentMap[item.renderComponentId];
renderComponentMap[item.renderComponentId]; }
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch (e) {
// Fallback to shallow copy if deep clone fails
return { ...item, renderComponent };
}
} catch (error) {
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
return item;
} }
}); });
loadDynamicItems(currentItems); loadDynamicItems(itemsWithComponents);
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("dynamic-items-updated", { new CustomEvent("dynamic-items-updated", {
detail: { detail: {
@@ -3,9 +3,24 @@ import type { IndexItem } from "../types";
let vectorIndex: EmbeddingIndex | null = null; let vectorIndex: EmbeddingIndex | null = null;
let isInitialized = false; let isInitialized = false;
let initializationFailed = false;
let currentAbortController: AbortController | null = null; let currentAbortController: AbortController | null = null;
let loadedItemIds = new Set<string>(); let loadedItemIds = new Set<string>();
// Detect Firefox in worker context
function isFirefoxWorker(): boolean {
try {
// Check for Firefox-specific APIs or user agent
if (typeof navigator !== "undefined") {
return navigator.userAgent.toLowerCase().includes("firefox");
}
// In worker context, check for Firefox-specific behavior
return false;
} catch {
return false;
}
}
let streamingSession: { let streamingSession: {
isActive: boolean; isActive: boolean;
totalExpected: number; totalExpected: number;
@@ -21,6 +36,16 @@ async function initWorker() {
console.debug("Vector worker already initialized."); console.debug("Vector worker already initialized.");
return; return;
} }
// Skip initialization in Firefox
if (isFirefoxWorker()) {
console.debug("[Vector Worker] Vector search not supported in Firefox - skipping initialization");
isInitialized = true;
initializationFailed = true;
vectorIndex = null;
return;
}
console.debug("Initializing vector worker..."); console.debug("Initializing vector worker...");
try { try {
await initializeModel(); await initializeModel();
@@ -48,8 +73,9 @@ async function initWorker() {
isInitialized = true; isInitialized = true;
console.debug("Vector worker initialized successfully."); console.debug("Vector worker initialized successfully.");
} catch (e) { } catch (e) {
console.error("Failed to initialize vector worker:", e); console.warn("[Vector Worker] Failed to initialize vector worker (will use text search only):", e);
isInitialized = true; isInitialized = true;
initializationFailed = true;
vectorIndex = null; vectorIndex = null;
} }
} }
@@ -80,18 +106,29 @@ async function startStreamingSession(
totalExpected: number, totalExpected: number,
batchSize: number = 5, batchSize: number = 5,
) { ) {
if (initializationFailed || isFirefoxWorker()) {
self.postMessage({
type: "progress",
data: {
status: "complete",
message: "Vector search not available in Firefox - using text search only",
},
});
return;
}
if (!vectorIndex) { if (!vectorIndex) {
console.warn( console.warn(
"Streaming requested but vector index not ready. Attempting init.", "Streaming requested but vector index not ready. Attempting init.",
); );
await initWorker(); await initWorker();
if (!vectorIndex) { if (!vectorIndex || initializationFailed) {
self.postMessage({ self.postMessage({
type: "progress", type: "progress",
data: { data: {
status: "error", status: "complete",
message: message:
"Vector index not available for streaming after init attempt.", "Vector index not available - using text search only",
}, },
}); });
return; return;
@@ -306,18 +343,29 @@ async function endStreamingSession() {
async function processItems(items: IndexItem[], signal: AbortSignal) { async function processItems(items: IndexItem[], signal: AbortSignal) {
console.debug("Worker received process request."); console.debug("Worker received process request.");
if (initializationFailed || isFirefoxWorker()) {
self.postMessage({
type: "progress",
data: {
status: "complete",
message: "Vector search not available - using text search only",
},
});
return;
}
if (!vectorIndex) { if (!vectorIndex) {
console.warn( console.warn(
"Processing requested but vector index not ready. Attempting init.", "Processing requested but vector index not ready. Attempting init.",
); );
await initWorker(); await initWorker();
if (!vectorIndex) { if (!vectorIndex || initializationFailed) {
self.postMessage({ self.postMessage({
type: "progress", type: "progress",
data: { data: {
status: "error", status: "complete",
message: message:
"Vector index not available for processing after init attempt.", "Vector index not available - using text search only",
}, },
}); });
return; return;
@@ -1,5 +1,6 @@
import { refreshVectorCache } from "../../search/vector/vectorSearch"; import { refreshVectorCache } from "../../search/vector/vectorSearch";
import type { IndexItem } from "../types"; import type { IndexItem } from "../types";
import { isVectorSearchSupported } from "../../utils/browserDetection";
import vectorWorker from "./vectorWorker.ts?inlineWorker"; import vectorWorker from "./vectorWorker.ts?inlineWorker";
export type ProgressCallback = (data: { export type ProgressCallback = (data: {
@@ -42,6 +43,13 @@ export class VectorWorkerManager {
} }
private async initWorker(): Promise<void> { private async initWorker(): Promise<void> {
// Skip initialization if vector search is not supported (e.g., Firefox)
if (!isVectorSearchSupported()) {
console.debug("[VectorWorkerManager] Vector search not supported - skipping worker initialization");
this.isInitialized = false;
return Promise.resolve();
}
if (this.isInitialized) return Promise.resolve(); if (this.isInitialized) return Promise.resolve();
if (this.readyPromise) return this.readyPromise; if (this.readyPromise) return this.readyPromise;
@@ -234,6 +242,17 @@ export class VectorWorkerManager {
} }
async processItems(items: IndexItem[], onProgress?: ProgressCallback) { async processItems(items: IndexItem[], onProgress?: ProgressCallback) {
// Skip if vector search is not supported
if (!isVectorSearchSupported()) {
if (onProgress) {
onProgress({
status: "complete",
message: "Vector search not available - using text search only"
});
}
return;
}
// Only initialize worker if we actually have items to process // Only initialize worker if we actually have items to process
if (items.length === 0) { if (items.length === 0) {
if (onProgress) { if (onProgress) {
@@ -298,6 +317,18 @@ export class VectorWorkerManager {
batchSize: number = 10, batchSize: number = 10,
jobId?: string, jobId?: string,
): Promise<void> { ): Promise<void> {
// Skip if vector search is not supported
if (!isVectorSearchSupported()) {
console.debug("[VectorWorker] Vector search not supported - skipping streaming session");
if (onProgress) {
onProgress({
status: "complete",
message: "Vector search not available - using text search only",
});
}
return;
}
// Only initialize if we expect items to process // Only initialize if we expect items to process
if (totalExpectedItems === 0) { if (totalExpectedItems === 0) {
console.debug("[VectorWorker] No items expected, not starting streaming session"); console.debug("[VectorWorker] No items expected, not starting streaming session");
@@ -0,0 +1,280 @@
import type { IndexItem } from "../indexing/types";
import type { CombinedResult } from "../core/types";
import { searchVectors, type VectorSearchResult } from "./vector/vectorSearch";
import { jobs } from "../indexing/jobs";
/**
* Hybrid Search Implementation
*
* Flow:
* 1. BM25 (Fuse.js) gets top N results fast
* 2. Vector search reranks by semantic similarity
* 3. Apply optional boosting (recency, popularity, tags)
*/
export interface HybridSearchOptions {
/** Maximum number of BM25 results to retrieve before reranking */
bm25TopK?: number;
/** Maximum number of final results to return */
finalLimit?: number;
/** Whether to apply recency boost */
recencyBoost?: boolean;
/** Weight for BM25 scores (0-1) */
bm25Weight?: number;
/** Weight for vector similarity scores (0-1) */
vectorWeight?: number;
/** Weight for recency boost */
recencyWeight?: number;
}
const DEFAULT_OPTIONS: Required<HybridSearchOptions> = {
bm25TopK: 50, // Get top 50 from BM25, then rerank
finalLimit: 10,
recencyBoost: true,
bm25Weight: 0.4, // 40% BM25, 60% vector
vectorWeight: 0.6,
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
*/
function calculateRecencyBoost(item: IndexItem, now: number): number {
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
// Exponential decay: newer items get higher boost
// Items from today get boost of 1, items from 30 days ago get ~0.03
return 1 / (1 + ageInDays / 7); // Half-life of 7 days
}
/**
* Calculates popularity boost (can be extended with click tracking, etc.)
*/
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;
}
// 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
}
/**
* Reranks BM25 results using vector search
*/
export async function hybridSearch(
bm25Results: CombinedResult[],
query: string,
options: HybridSearchOptions = {},
): Promise<CombinedResult[]> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const trimmedQuery = query.trim().toLowerCase();
// If no BM25 results, return empty
if (bm25Results.length === 0) {
return [];
}
// 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
// This allows us to find semantic matches that BM25 might have missed
const vectorSearchResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
// Create a map of item ID to vector similarity
const vectorMap = new Map<string, number>();
vectorSearchResults.forEach(v => {
// Use the highest similarity if item appears multiple times
const existing = vectorMap.get(v.object.id);
if (!existing || v.similarity > existing) {
vectorMap.set(v.object.id, v.similarity);
}
});
// Now rerank BM25 results with vector scores
const now = Date.now();
const rerankedResults = 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
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
? 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
}
}
// Combine scores using weighted average
// BM25 and vector are weighted, boosts are additive
const hybridScore =
(normalizedBm25Score * opts.bm25Weight) +
(vectorSimilarity * opts.vectorWeight) +
recencyBoost +
popularityBoost +
jobBoost;
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,
},
};
});
// Sort by hybrid score descending
rerankedResults.sort((a, b) => b.score - a.score);
// Return top results
return rerankedResults.slice(0, opts.finalLimit);
} catch (e) {
console.warn("[Hybrid Search] Vector reranking failed, using BM25 only:", e);
// Fallback to BM25 only
return topBm25Results.slice(0, opts.finalLimit);
}
}
// If query is too short for vector search, just return BM25 results
return topBm25Results.slice(0, opts.finalLimit);
}
/**
* Enhanced hybrid search that also includes vector-only results not found by BM25
*/
export async function hybridSearchWithExpansion(
bm25Results: CombinedResult[],
query: string,
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;
}
// Get vector search results
let vectorResults: VectorSearchResult[] = [];
try {
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK);
} catch (e) {
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
}
}
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,
},
});
}
});
// 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);
}
@@ -6,32 +6,79 @@ import type { IndexItem } from "../indexing/types";
import { searchVectors } from "./vector/vectorSearch"; import { searchVectors } from "./vector/vectorSearch";
import type { VectorSearchResult } from "./vector/vectorTypes"; import type { VectorSearchResult } from "./vector/vectorTypes";
import { jobs } from "../indexing/jobs"; import { jobs } from "../indexing/jobs";
import { hybridSearchWithExpansion } from "./hybridSearch";
// Search result cache for better performance
const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>();
const CACHE_TTL = 1000 * 60 * 5; // 5 minutes
const MAX_CACHE_SIZE = 100;
function getCachedResults(query: string): CombinedResult[] | null {
const cached = searchCache.get(query);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.results;
}
return null;
}
function setCachedResults(query: string, results: CombinedResult[]) {
// Limit cache size
if (searchCache.size >= MAX_CACHE_SIZE) {
const firstKey = searchCache.keys().next().value;
searchCache.delete(firstKey);
}
searchCache.set(query, { results, timestamp: Date.now() });
}
/**
* Clears the search result cache
*/
export function clearSearchCache(): void {
searchCache.clear();
console.debug("[Search] Search result cache cleared");
}
// Listen for cache clear events (e.g., on extension update)
if (typeof window !== 'undefined') {
window.addEventListener('betterseqta-clear-search-cache', () => {
clearSearchCache();
});
}
export function createSearchIndexes() { export function createSearchIndexes() {
const commands = getStaticCommands(); const commands = getStaticCommands();
const dynamicItems = getDynamicItems(); const dynamicItems = getDynamicItems();
// Optimized command search options
const commandOptions = { const commandOptions = {
keys: ["text", "category", "keywords"], keys: ["text", "category", "keywords"],
includeScore: true, includeScore: true,
includeMatches: true, includeMatches: true,
threshold: 0.4, threshold: 0.35, // Slightly more permissive for better recall
minMatchCharLength: 2, minMatchCharLength: 2,
useExtendedSearch: false, useExtendedSearch: false,
ignoreLocation: false,
findAllMatches: false, // Performance optimization
}; };
// Optimized dynamic content search options
const dynamicOptions = { const dynamicOptions = {
keys: [ keys: [
{ name: "text", weight: 2 }, { name: "text", weight: 3 }, // Increased weight for title matches
{ name: "content", weight: 1 }, { name: "content", weight: 1 },
{ name: "category", weight: 1 }, { name: "category", weight: 0.5 }, // Lower weight for category
{ name: "metadata.subjectName", weight: 1.5 }, // Boost subject name matches
{ name: "metadata.subjectCode", weight: 1.5 }, // Boost subject code matches
], ],
includeScore: true, includeScore: true,
includeMatches: true, includeMatches: true,
threshold: 0.4, threshold: 0.5, // More permissive for better partial word matching (increased from 0.4)
minMatchCharLength: 2, minMatchCharLength: 2, // Minimum 2 characters for Fuse.js matches (substring fallback handles shorter queries)
distance: 100, distance: 100, // Increased to allow matches across longer strings
useExtendedSearch: true, useExtendedSearch: true,
ignoreLocation: true, // Allow matches anywhere in the string for better partial word matching
findAllMatches: true, // Enable to find all matches for better partial word support
shouldSort: true,
}; };
return { return {
@@ -105,18 +152,64 @@ export function searchDynamicItems(
} }
const now = Date.now(); const now = Date.now();
const searchResults = dynamicContentFuse.search(query, { limit }); const queryLower = query.toLowerCase();
const queryTrimmed = query.trim();
return searchResults.map((result: FuseResult<IndexItem>) => { // For short queries (3 chars or less), use a more permissive approach
const isShortQuery = queryTrimmed.length <= 3;
const searchLimit = Math.min(limit * 3, 50);
// First, try Fuse.js search
const searchResults = dynamicContentFuse.search(query, { limit: searchLimit });
// For short queries, always do a simple substring match to supplement Fuse.js results
// This ensures we catch partial word matches like "SAT" in "SAT 1: Differential Calculus"
let additionalMatches: IndexItem[] = [];
if (isShortQuery) {
// Always do substring search for short queries to catch partial word matches
for (const item of dynamicIdToItemMap.values()) {
const textLower = item.text.toLowerCase();
const contentLower = (item.content || '').toLowerCase();
const subjectNameLower = (item.metadata?.subjectName || '').toLowerCase();
const subjectCodeLower = (item.metadata?.subjectCode || '').toLowerCase();
// Check if query appears anywhere in the text, content, or metadata
if (textLower.includes(queryLower) ||
contentLower.includes(queryLower) ||
subjectNameLower.includes(queryLower) ||
subjectCodeLower.includes(queryLower)) {
// Only add if not already in Fuse.js results
if (!searchResults.find(r => r.item.id === item.id)) {
additionalMatches.push(item);
}
}
}
}
const results = searchResults.map((result: FuseResult<IndexItem>) => {
const item = result.item; const item = result.item;
const fuseScore = 10 * (1 - (result.score || 0.5)); const fuseScore = 10 * (1 - (result.score || 0.5));
let score = fuseScore; let score = fuseScore;
// Recency boost
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24); const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0; const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
score += recencyBoost; score += recencyBoost;
// Boost for exact text matches (especially at the start)
const textLower = item.text.toLowerCase();
if (textLower.startsWith(queryLower)) {
score += 5; // Strong boost for prefix matches
} else if (textLower.includes(queryLower)) {
score += 2; // Boost for substring matches
}
// Boost for category matches
if (item.category.toLowerCase().includes(queryLower)) {
score += 1;
}
return { return {
id: item.id, id: item.id,
type: "dynamic" as const, type: "dynamic" as const,
@@ -125,60 +218,124 @@ export function searchDynamicItems(
matches: result.matches, matches: result.matches,
}; };
}); });
// Add additional matches from simple substring search
additionalMatches.forEach((item) => {
// Check if already in results
if (!results.find(r => r.id === item.id)) {
const textLower = item.text.toLowerCase();
let score = 5; // Base score for substring matches
// Boost for prefix matches
if (textLower.startsWith(queryLower)) {
score += 5;
}
// Recency boost
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
score += recencyBoost;
results.push({
id: item.id,
type: "dynamic" as const,
score,
item,
});
}
});
// Sort by score and return top results
return results.sort((a, b) => b.score - a.score).slice(0, limit);
} }
export async function performSearch( export async function performSearch(
query: string, query: string,
commandsFuse: Fuse<StaticCommandItem>, commandsFuse: Fuse<StaticCommandItem>,
commandIdToItemMap: Map<string, StaticCommandItem>, commandIdToItemMap: Map<string, StaticCommandItem>,
dynamicContentFuse?: Fuse<IndexItem>,
dynamicIdToItemMap?: Map<string, IndexItem>,
sortByRecent: boolean = true,
): Promise<CombinedResult[]> { ): Promise<CombinedResult[]> {
// Get all results first const trimmedQuery = query.trim().toLowerCase();
// Check cache first
if (trimmedQuery.length > 2) {
const cached = getCachedResults(trimmedQuery);
if (cached) {
return cached;
}
}
// Step 1: Get command results (these don't need hybrid search)
const commandResults = searchCommands( const commandResults = searchCommands(
commandsFuse, commandsFuse,
query, trimmedQuery,
commandIdToItemMap, commandIdToItemMap,
); );
// Get vector results in parallel // Step 2: Get BM25 results for dynamic items
let vectorResults: VectorSearchResult[] = []; let dynamicResults: CombinedResult[] = [];
if (dynamicContentFuse && dynamicIdToItemMap) {
// Get BM25 results first (fast text-based search)
const bm25Results = searchDynamicItems(
dynamicContentFuse,
trimmedQuery,
dynamicIdToItemMap,
50, // Get top 50 for reranking
sortByRecent,
);
// Step 3: Apply hybrid search (BM25 + Vector reranking + boosting)
if (trimmedQuery.length > 2 && bm25Results.length > 0) {
try { try {
vectorResults = await searchVectors(query); // Get all items for expansion
} catch (e) {} const allItems = Array.from(dynamicIdToItemMap.values());
// Create a map to store our final results, using ID as key to avoid duplicates // Apply hybrid search with expansion
const resultMap = new Map<string, CombinedResult>(); dynamicResults = await hybridSearchWithExpansion(
bm25Results,
// Add command results first (they keep their original scores) trimmedQuery,
commandResults.forEach((r) => resultMap.set(r.id, r)); allItems,
{
// Process dynamic results and vector results together bm25TopK: 50,
const seenIds = new Set<string>(); finalLimit: 20, // Return top 20 after reranking
recencyBoost: sortByRecent,
vectorResults.forEach((v) => { bm25Weight: 0.4, // 40% BM25, 60% vector
const id = v.object.id; vectorWeight: 0.6,
recencyWeight: 0.1,
if (!seenIds.has(id)) { },
// This is a semantic match that Fuse missed - add it with the vector similarity as score );
let score = v.similarity * 0.5; // High base score for semantic matches } catch (e) {
const job = jobs[v.object.category]; console.warn("[Search] Hybrid search failed, using BM25 only:", e);
if (job && typeof job.boostCriteria === 'function') { // Fallback to BM25 only
const boost = job.boostCriteria(v.object, query); dynamicResults = bm25Results.slice(0, 20);
if (boost) { }
score += boost; } else {
// For very short queries or no BM25 results, use BM25 only
dynamicResults = bm25Results.slice(0, 20);
} }
} }
resultMap.set(id, {
id, // Step 4: Combine command and dynamic results
type: "dynamic" as const, const allResults = [...commandResults, ...dynamicResults];
score,
item: v.object, // Sort by score (commands typically have higher priority)
}); allResults.sort((a, b) => {
// Commands always come first if scores are similar
if (a.type === "command" && b.type === "dynamic") {
return b.score - a.score - 10; // Commands get +10 boost
} }
if (a.type === "dynamic" && b.type === "command") {
return b.score - a.score + 10; // Commands get +10 boost
}
return b.score - a.score;
}); });
// Convert to array and sort by score // Cache results for queries longer than 2 chars
const results = Array.from(resultMap.values()); if (trimmedQuery.length > 2) {
results.sort((a, b) => b.score - a.score); setCachedResults(trimmedQuery, allResults);
}
return results;
return allResults;
} }
@@ -1,16 +1,36 @@
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia"; import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
import type { IndexItem } from "../../indexing/types"; import type { IndexItem } from "../../indexing/types";
import type { SearchResult } from "embeddia"; import type { SearchResult } from "embeddia";
import { isVectorSearchSupported } from "../../utils/browserDetection";
let vectorIndex: EmbeddingIndex | null = null; let vectorIndex: EmbeddingIndex | null = null;
let initializationAttempted = false;
let initializationFailed = false;
export async function initVectorSearch() { export async function initVectorSearch() {
// Skip initialization if already attempted and failed, or if not supported
if (initializationFailed || !isVectorSearchSupported()) {
if (!isVectorSearchSupported()) {
console.debug("[Vector Search] Vector search not supported in Firefox - using text search only");
}
return;
}
if (initializationAttempted) {
return;
}
initializationAttempted = true;
try { try {
await initializeModel(); await initializeModel();
vectorIndex = new EmbeddingIndex([]); vectorIndex = new EmbeddingIndex([]);
vectorIndex.preloadIndexedDB(); vectorIndex.preloadIndexedDB();
console.debug("[Vector Search] Initialized successfully");
} catch (e) { } catch (e) {
console.error("Error initializing vector search", e); console.warn("[Vector Search] Failed to initialize vector search (will use text search only):", e);
initializationFailed = true;
vectorIndex = null;
} }
} }
@@ -18,28 +38,111 @@ export interface VectorSearchResult extends SearchResult {
object: IndexItem & { embedding: number[] }; object: IndexItem & { embedding: number[] };
} }
// Cache for query embeddings to avoid recomputing
const embeddingCache = new Map<string, number[]>();
const EMBEDDING_CACHE_TTL = 1000 * 60 * 30; // 30 minutes
const MAX_EMBEDDING_CACHE_SIZE = 50;
function getCachedEmbedding(query: string): number[] | null {
const cached = embeddingCache.get(query);
if (cached) {
return cached;
}
return null;
}
function setCachedEmbedding(query: string, embedding: number[]) {
// Limit cache size
if (embeddingCache.size >= MAX_EMBEDDING_CACHE_SIZE) {
const firstKey = embeddingCache.keys().next().value;
embeddingCache.delete(firstKey);
}
embeddingCache.set(query, embedding);
}
/**
* Clears the embedding cache
*/
export function clearEmbeddingCache(): void {
embeddingCache.clear();
console.debug("[Vector Search] Embedding cache cleared");
}
// Listen for cache clear events (e.g., on extension update)
if (typeof window !== 'undefined') {
window.addEventListener('betterseqta-clear-embedding-cache', () => {
clearEmbeddingCache();
});
}
export async function searchVectors( export async function searchVectors(
query: string, query: string,
topK: number = 20, topK: number = 20,
): Promise<VectorSearchResult[]> { ): Promise<VectorSearchResult[]> {
if (!vectorIndex) await initVectorSearch(); // Return empty array if vector search is not supported or failed to initialize
if (!isVectorSearchSupported() || initializationFailed) {
return [];
}
const queryEmbedding = await getEmbedding(query.slice(0, 100)); if (!vectorIndex) {
await initVectorSearch();
if (!vectorIndex) {
return [];
}
}
// Normalize query for caching
const normalizedQuery = query.trim().toLowerCase().slice(0, 100);
// Check cache first
let queryEmbedding = getCachedEmbedding(normalizedQuery);
if (!queryEmbedding) {
try {
queryEmbedding = await getEmbedding(normalizedQuery);
setCachedEmbedding(normalizedQuery, queryEmbedding);
} catch (e) {
console.warn("[Vector Search] Failed to get embedding:", e);
return [];
}
}
try {
const results = await vectorIndex!.search(queryEmbedding, { const results = await vectorIndex!.search(queryEmbedding, {
topK, topK: Math.min(topK * 2, 30), // Get more results, filter later
useStorage: "indexedDB", useStorage: "indexedDB",
dedupeEntries: true, dedupeEntries: true,
}); });
// filter results with a similarity below 0.81 // Filter results with a similarity below 0.80 (slightly more permissive)
const filteredResults = results.filter((r) => r.similarity > 0.81); // and sort by similarity descending
const filteredResults = results
.filter((r) => r.similarity > 0.80)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, topK);
return filteredResults as VectorSearchResult[]; return filteredResults as VectorSearchResult[];
} catch (e) {
console.warn("[Vector Search] Search failed:", e);
return [];
}
} }
export async function refreshVectorCache() { export async function refreshVectorCache() {
if (!vectorIndex) await initVectorSearch(); if (!isVectorSearchSupported() || initializationFailed) {
vectorIndex!.clearIndexedDBCache(); return;
vectorIndex!.preloadIndexedDB(); }
if (!vectorIndex) {
await initVectorSearch();
}
if (vectorIndex) {
try {
vectorIndex.clearIndexedDBCache();
vectorIndex.preloadIndexedDB();
} catch (e) {
console.warn("[Vector Search] Failed to refresh cache:", e);
}
}
} }
@@ -0,0 +1,30 @@
import browser from "webextension-polyfill";
/**
* Detects if the current browser is Firefox
*/
export function isFirefox(): boolean {
try {
// Firefox-specific API
if (typeof (browser.runtime as any).getBrowserInfo === "function") {
return true;
}
// Fallback: check user agent
if (typeof navigator !== "undefined") {
return navigator.userAgent.toLowerCase().includes("firefox");
}
return false;
} catch {
// If we can't detect, assume not Firefox (safer for Chrome/Edge)
return false;
}
}
/**
* Checks if vector search is supported in the current browser
* Currently disabled for Firefox due to security restrictions
*/
export function isVectorSearchSupported(): boolean {
return !isFirefox();
}
@@ -0,0 +1,115 @@
import browser from "webextension-polyfill";
const VERSION_STORAGE_KEY = "betterseqta-global-search-version";
const VERSION_CACHE_KEY = "betterseqta-global-search-cache-version";
/**
* Gets the current extension version from the manifest
*/
export function getCurrentVersion(): string {
try {
return browser.runtime.getManifest().version;
} catch (e) {
console.warn("[Version Check] Failed to get manifest version:", e);
return "0.0.0";
}
}
/**
* Gets the last stored version from localStorage
*/
export function getStoredVersion(): string | null {
try {
return localStorage.getItem(VERSION_STORAGE_KEY);
} catch (e) {
console.warn("[Version Check] Failed to get stored version:", e);
return null;
}
}
/**
* Stores the current version in localStorage
*/
export function storeVersion(version: string): void {
try {
localStorage.setItem(VERSION_STORAGE_KEY, version);
localStorage.setItem(VERSION_CACHE_KEY, version);
} catch (e) {
console.warn("[Version Check] Failed to store version:", e);
}
}
/**
* Checks if the extension has been updated and clears caches if needed
* Returns true if an update was detected
*/
export async function checkAndHandleUpdate(): Promise<boolean> {
const currentVersion = getCurrentVersion();
const storedVersion = getStoredVersion();
// If no stored version, this is first run - store current version
if (!storedVersion) {
console.debug(`[Version Check] First run detected, storing version ${currentVersion}`);
storeVersion(currentVersion);
return false;
}
// If versions match, no update
if (storedVersion === currentVersion) {
return false;
}
// Version mismatch detected - extension was updated
console.log(`[Version Check] Extension updated from ${storedVersion} to ${currentVersion}, clearing caches...`);
// Clear all caches
await clearAllCaches();
// Store new version
storeVersion(currentVersion);
return true;
}
/**
* Clears all search-related caches
*/
export async function clearAllCaches(): Promise<void> {
try {
// Clear search result cache (in-memory Map)
if (typeof window !== 'undefined') {
// Dispatch event to clear caches in other modules
window.dispatchEvent(new CustomEvent('betterseqta-clear-search-cache'));
window.dispatchEvent(new CustomEvent('betterseqta-clear-embedding-cache'));
}
// Also try to directly clear caches if modules are already loaded
// Use setTimeout to avoid blocking and handle CSS preload errors
setTimeout(async () => {
try {
const { clearSearchCache } = await import("../search/searchUtils");
clearSearchCache();
} catch (e: any) {
// Module might not be loaded yet, or CSS preload error - that's okay
if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) {
console.debug("[Version Check] Could not clear search cache:", e);
}
}
try {
const { clearEmbeddingCache } = await import("../search/vector/vectorSearch");
clearEmbeddingCache();
} catch (e: any) {
// Module might not be loaded yet, or CSS preload error - that's okay
if (!e?.message?.includes("preload CSS") && !e?.message?.includes("MIME type")) {
console.debug("[Version Check] Could not clear embedding cache:", e);
}
}
}, 50);
console.debug("[Version Check] All caches cleared");
} catch (e) {
console.error("[Version Check] Error clearing caches:", e);
}
}
@@ -39,7 +39,7 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
"[class*='notifications__bubble___']", "[class*='notifications__bubble___']",
) as HTMLElement; ) as HTMLElement;
if (api.storage.lastNotificationCount !== 0) { if (alertDiv && api.storage.lastNotificationCount !== 0) {
alertDiv.textContent = api.storage.lastNotificationCount.toString(); alertDiv.textContent = api.storage.lastNotificationCount.toString();
} }
@@ -74,13 +74,17 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
} }
} catch (error) { } catch (error) {
console.error("[BetterSEQTA+] Error fetching notifications:", error); console.error("[BetterSEQTA+] Error fetching notifications:", error);
api.storage.consecutiveErrors = (api.storage.consecutiveErrors || 0) + 1; api.storage.consecutiveErrors =
(api.storage.consecutiveErrors || 0) + 1;
} }
}; };
const getNextInterval = () => { const getNextInterval = () => {
// Exponential backoff on errors, max 5 minutes // Exponential backoff on errors, max 5 minutes
const errorMultiplier = Math.min(Math.pow(2, api.storage.consecutiveErrors || 0), 10); const errorMultiplier = Math.min(
Math.pow(2, api.storage.consecutiveErrors || 0),
10,
);
return Math.min(baseInterval * errorMultiplier, maxInterval); return Math.min(baseInterval * errorMultiplier, maxInterval);
}; };
@@ -92,7 +96,8 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
const interval = getNextInterval(); const interval = getNextInterval();
pollInterval = window.setTimeout(() => { pollInterval = window.setTimeout(() => {
checkNotifications().then(() => { checkNotifications().then(() => {
if (pollInterval) { // Only continue if not stopped if (pollInterval) {
// Only continue if not stopped
scheduleNext(); scheduleNext();
} }
}); });
@@ -124,14 +129,16 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
isVisible = !document.hidden; isVisible = !document.hidden;
if (isVisible && !pollInterval) { if (isVisible && !pollInterval) {
// Resume polling when tab becomes visible // Resume polling when tab becomes visible
const alertDiv = document.querySelector("[class*='notifications__bubble___']"); const alertDiv = document.querySelector(
"[class*='notifications__bubble___']",
);
if (alertDiv) { if (alertDiv) {
startPolling(); startPolling();
} }
} }
}; };
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener("visibilitychange", handleVisibilityChange);
api.seqta.onMount("[class*='notifications__bubble___']", (_) => { api.seqta.onMount("[class*='notifications__bubble___']", (_) => {
startPolling(); startPolling();
@@ -139,7 +146,7 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
return () => { return () => {
stopPolling(); stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}, },
}; };
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Plugin } from "@/plugins/core/types"; import type { Plugin } from "@/plugins/core/types";
import { defineSettings, componentSetting } from "@/plugins/core/settingsHelpers"; import { componentSetting, defineSettings } from "@/plugins/core/settingsHelpers";
import ProfilePictureSetting from "./ProfilePictureSetting.svelte"; import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
@@ -8,12 +8,16 @@
object-fit: cover; object-fit: cover;
z-index: 4; z-index: 4;
box-shadow: 0 0 0 3px #000000; box-shadow: 0 0 0 3px #000000;
transition: box-shadow 0.05s ease-in-out;
} }
.dark .userInfoImg { .dark .userInfoImg {
box-shadow: 0 0 0 3px #ffffff; box-shadow: 0 0 0 3px #ffffff;
transition: box-shadow 0.05s ease-in-out;
} }
.userInfosvgdiv { @media (prefers-reduced-motion: reduce) {
filter: invert(0) !important; .userInfoImg {
transition: none !important;
}
} }
+9 -2
View File
@@ -147,14 +147,21 @@ export class ThemeManager {
public async initialize(): Promise<void> { public async initialize(): Promise<void> {
console.debug("[ThemeManager] Starting initialization"); console.debug("[ThemeManager] Starting initialization");
try { try {
// Check if theme creator was open during reload const neumorphicThemeId = "9a9786d1-b5fc-4a91-8c7a-f8bf7f7679ad";
const migrationCSS = "#title {\nbackground: transparent !important;\n}";
const theme = (await localforage.getItem(neumorphicThemeId)) as CustomTheme | null;
if (theme && theme.CustomCSS && !theme.CustomCSS.includes("#title {\nbackground: transparent !important;\n}")) {
theme.CustomCSS = theme.CustomCSS + "\n" + migrationCSS;
await localforage.setItem(neumorphicThemeId, theme);
}
const themeCreatorOpen = localStorage.getItem("themeCreatorOpen"); const themeCreatorOpen = localStorage.getItem("themeCreatorOpen");
if (themeCreatorOpen === "true") { if (themeCreatorOpen === "true") {
console.debug( console.debug(
"[ThemeManager] Theme creator was open, clearing preview state", "[ThemeManager] Theme creator was open, clearing preview state",
); );
this.clearPreview(); this.clearPreview();
// Clean up the flag
localStorage.removeItem("themeCreatorOpen"); localStorage.removeItem("themeCreatorOpen");
} }
+11 -1
View File
@@ -47,7 +47,17 @@ export function createLazyPlugin<T extends PluginSettings = PluginSettings, S =
// Execute the actual plugin's run function // Execute the actual plugin's run function
return await actualPlugin.run(api); return await actualPlugin.run(api);
} catch (error) { } catch (error: any) {
// Handle Firefox MIME type errors gracefully
if (error?.message?.includes("MIME type") || error?.message?.includes("NS_ERROR_CORRUPTED_CONTENT")) {
console.error(
`[BetterSEQTA+] Failed to load plugin "${lazyPlugin.id}" due to Firefox module loading restrictions. ` +
`This may be a build configuration issue. Error:`,
error
);
// Don't throw - allow the extension to continue functioning without this plugin
return;
}
console.error(`[BetterSEQTA+] Failed to dynamically load plugin "${lazyPlugin.id}":`, error); console.error(`[BetterSEQTA+] Failed to dynamically load plugin "${lazyPlugin.id}":`, error);
throw error; throw error;
} }
+3 -3
View File
@@ -1,13 +1,13 @@
import type { import type {
BooleanSetting, BooleanSetting,
ButtonSetting,
ComponentSetting,
HotkeySetting,
NumberSetting, NumberSetting,
Plugin, Plugin,
PluginSettings, PluginSettings,
SelectSetting, SelectSetting,
StringSetting, StringSetting,
ButtonSetting,
HotkeySetting,
ComponentSetting,
} from "./types"; } from "./types";
import { createPluginAPI } from "./createAPI"; import { createPluginAPI } from "./createAPI";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
+3 -3
View File
@@ -1,12 +1,12 @@
import type { import type {
BooleanSetting, BooleanSetting,
ButtonSetting, ButtonSetting,
ComponentSetting,
HotkeySetting,
NumberSetting, NumberSetting,
PluginSettings,
SelectSetting, SelectSetting,
StringSetting, StringSetting,
HotkeySetting,
PluginSettings,
ComponentSetting,
} from "./types"; } from "./types";
/** /**
+2
View File
@@ -8,6 +8,7 @@ import animatedBackgroundPlugin from "./built-in/animatedBackground";
import assessmentsAveragePlugin from "./built-in/assessmentsAverage"; import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
import profilePicturePlugin from "./built-in/profilePicture"; import profilePicturePlugin from "./built-in/profilePicture";
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview"; import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
import backgroundMusicPlugin from "./built-in/backgroundMusic";
//import testPlugin from './built-in/test'; //import testPlugin from './built-in/test';
// Heavy plugins (lazy-loaded only when enabled) // Heavy plugins (lazy-loaded only when enabled)
@@ -24,6 +25,7 @@ pluginManager.registerPlugin(notificationCollectorPlugin);
pluginManager.registerPlugin(timetablePlugin); pluginManager.registerPlugin(timetablePlugin);
pluginManager.registerPlugin(profilePicturePlugin); pluginManager.registerPlugin(profilePicturePlugin);
pluginManager.registerPlugin(assessmentsOverviewPlugin); pluginManager.registerPlugin(assessmentsOverviewPlugin);
pluginManager.registerPlugin(backgroundMusicPlugin);
//pluginManager.registerPlugin(testPlugin); //pluginManager.registerPlugin(testPlugin);
// Register heavy plugins with lazy loading // Register heavy plugins with lazy loading
+9 -6
View File
@@ -23,12 +23,10 @@ import { updateAllColors } from "@/seqta/ui/colors/Manager";
import loading from "@/seqta/ui/Loading"; import loading from "@/seqta/ui/Loading";
import { SendNewsPage } from "@/seqta/utils/SendNewsPage"; import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { OpenWhatsNewPopup } from "@/seqta/utils/Whatsnew"; import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
//import { OpenMinecraftServerPopup } from "@/seqta/utils/AboutMinecraftServer"; import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification";
import { import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
updateTimetableTimes,
} from "@/seqta/utils/updateTimetableTimes";
// JSON content // JSON content
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json"; import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
@@ -96,7 +94,12 @@ export async function finishLoad() {
console.error("Error during loading cleanup:", err); console.error("Error during loading cleanup:", err);
} }
if (settingsState.justupdated && !document.getElementById("whatsnewbk")) { // Check and show privacy statement notification (before what's new)
if (!document.getElementById("privacy-notification")) {
await showPrivacyNotification();
}
if (settingsState.justupdated && !document.getElementById("whatsnewbk") && !document.getElementById("privacy-notification")) {
OpenWhatsNewPopup(); OpenWhatsNewPopup();
} }
} }
+2 -2
View File
@@ -16,9 +16,9 @@ export async function main() {
if (settingsState.onoff) { if (settingsState.onoff) {
injectPageState(); injectPageState();
// TEMP FIX for bug! -> this is a hack to get the injected.css file to have HMR in development mode as this import system is currently broken with crxjs // Rather permanent FIX for bug! -> this is a hack to get the injected.css file to have HMR in development mode as this import system is currently broken with crxjs
if (import.meta.env.MODE === "development") { if (import.meta.env.MODE === "development") {
import("../css/injected.scss"); import("@/css/injected.scss");
} else { } else {
const injectedStyle = document.createElement("style"); const injectedStyle = document.createElement("style");
injectedStyle.textContent = injectedCSS; injectedStyle.textContent = injectedCSS;
+71 -94
View File
@@ -14,7 +14,7 @@ let cachedUserInfo: any = null;
let LightDarkModeSnakeEggButton = 0; let LightDarkModeSnakeEggButton = 0;
async function getUserInfo() { export async function getUserInfo() {
if (cachedUserInfo) return cachedUserInfo; if (cachedUserInfo) return cachedUserInfo;
try { try {
@@ -30,11 +30,10 @@ async function getUserInfo() {
}), }),
}); });
const responseData = await response.json(); cachedUserInfo = (await response.json()).payload;
cachedUserInfo = responseData.payload;
return cachedUserInfo; return cachedUserInfo;
} catch (error) { } catch (error) {
console.error("Error fetching user info:", error); console.error("[BetterSEQTA+] Failed to get user info:", error);
throw error; throw error;
} }
} }
@@ -61,7 +60,7 @@ export async function AddBetterSEQTAElements() {
handleStudentData(), handleStudentData(),
]); ]);
} catch (error) { } catch (error) {
console.error("Error initializing UI elements:", error); console.error("[BetterSEQTA+] Failed to initialize UI elements:", error);
} }
setupEventListeners(); setupEventListeners();
@@ -80,20 +79,18 @@ function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
div.classList.add("titlebar"); div.classList.add("titlebar");
container.append(div); container.append(div);
const NewButton = stringToHTML( fragment.appendChild(
/* html */`<li class="item" data-key="home" id="homebutton" data-path="/home" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" /></svg><span>Home</span></label></li>` stringToHTML(
/* html */ `<li class="item" data-key="home" id="homebutton" data-path="/home" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" /></svg><span>Home</span></label></li>`,
).firstChild!,
); );
if (NewButton.firstChild) {
fragment.appendChild(NewButton.firstChild);
}
} }
async function handleUserInfo() { async function handleUserInfo() {
try { try {
const info = await getUserInfo(); updateUserInfo(await getUserInfo());
updateUserInfo(info);
} catch (error) { } catch (error) {
console.error("Error fetching and processing student data:", error); console.error("[BetterSEQTA+] Failed to handle user info:", error);
} }
} }
@@ -117,15 +114,17 @@ function updateUserInfo(info: {
}) { }) {
const titlebar = document.getElementsByClassName("titlebar")[0]; const titlebar = document.getElementsByClassName("titlebar")[0];
const userInfo = stringToHTML(/* html */ ` titlebar.append(
stringToHTML(/* html */ `
<div class="userInfosvgdiv tooltip"> <div class="userInfosvgdiv tooltip">
<svg class="userInfosvg" viewBox="0 0 24 24"><path fill="var(--text-primary)" d="M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"></path></svg> <svg class="userInfosvg" viewBox="0 0 24 24"><path fill="var(--text-primary)" d="M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"></path></svg>
<div class="tooltiptext topmenutooltip" id="logouttooltip"></div> <div class="tooltiptext topmenutooltip" id="logouttooltip"></div>
</div> </div>
`).firstChild; `).firstChild!,
titlebar.append(userInfo!); );
const userinfo = stringToHTML(/* html */ ` titlebar.append(
stringToHTML(/* html */ `
<div class="userInfo"> <div class="userInfo">
<div class="userInfoText"> <div class="userInfoText">
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center;">
@@ -135,12 +134,12 @@ function updateUserInfo(info: {
<p class="userInfoCode">${info.meta.code} // ${info.meta.governmentID}</p> <p class="userInfoCode">${info.meta.code} // ${info.meta.governmentID}</p>
</div> </div>
</div> </div>
`).firstChild; `).firstChild!,
titlebar.append(userinfo!); );
var logoutbutton = document.getElementsByClassName("logout")[0]; document
var userInfosvgdiv = document.getElementById("logouttooltip")!; .getElementById("logouttooltip")!
userInfosvgdiv.appendChild(logoutbutton); .appendChild(document.getElementsByClassName("logout")[0]);
} }
async function handleStudentData() { async function handleStudentData() {
@@ -156,57 +155,54 @@ async function handleStudentData() {
}, },
); );
const responseData = await response.json(); await updateStudentInfo((await response.json()).payload);
let students = responseData.payload;
await updateStudentInfo(students);
} catch (error) { } catch (error) {
console.error("Error fetching and processing student data:", error); console.error("[BetterSEQTA+] Failed to handle student data:", error);
} }
} }
async function updateStudentInfo(students: any) { async function updateStudentInfo(students: any) {
const info = await getUserInfo(); const info = await getUserInfo();
var index = students.findIndex(function (person: any) { const index = students.findIndex(
return ( (person: any) =>
person.firstname == info.userDesc.split(" ")[0] && person.firstname == info.userDesc.split(" ")[0] &&
person.surname == info.userDesc.split(" ")[1] person.surname == info.userDesc.split(" ")[1],
); );
});
let houseelement1 = document.getElementsByClassName("userInfohouse")[0]; const houseelement = document.getElementsByClassName(
const houseelement = houseelement1 as HTMLElement; "userInfohouse",
)[0] as HTMLElement;
const student = students[index] ?? {};
let text = "N/A";
if (students[index]?.house) { if (student.house) {
if (students[index]?.house_colour) { text = `${student.year ?? ""}${student.house}`;
houseelement.style.background = students[index].house_colour;
if (student.house_colour) {
houseelement.style.background = student.house_colour;
try { try {
let colorresult = GetThresholdOfColor(students[index]?.house_colour); const colorresult = GetThresholdOfColor(student.house_colour);
houseelement.style.color = houseelement.style.color =
colorresult && colorresult > 300 ? "black" : "white"; colorresult && colorresult > 300 ? "black" : "white";
houseelement.innerText = students[index].year + students[index].house; } catch {
} catch (error) { // Invalid color format, leave text color as default
houseelement.innerText = students[index].house;
} }
} }
} else { } else if (student.year) {
try { text = student.year;
houseelement.innerText = students[index].year;
} catch (err) {
houseelement.innerText = "N/A";
}
} }
houseelement.innerText = text;
} }
function createNewsButton(fragment: DocumentFragment, menu: HTMLElement) { function createNewsButton(fragment: DocumentFragment, menu: HTMLElement) {
const NewsButtonStr = fragment.appendChild(
'<li class="item" data-key="news" id="newsbutton" data-path="/news" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M20 3H4C2.89 3 2 3.89 2 5V19C2 20.11 2.89 21 4 21H20C21.11 21 22 20.11 22 19V5C22 3.89 21.11 3 20 3M5 7H10V13H5V7M19 17H5V15H19V17M19 13H12V11H19V13M19 9H12V7H19V9Z" /></svg><span>News</span></label></li>'; stringToHTML(
const NewsButton = stringToHTML(NewsButtonStr); '<li class="item" data-key="news" id="newsbutton" data-path="/news" data-betterseqta="true"><label><svg style="width:24px;height:24px" viewBox="0 0 24 24"><path fill="currentColor" d="M20 3H4C2.89 3 2 3.89 2 5V19C2 20.11 2.89 21 4 21H20C21.11 21 22 20.11 22 19V5C22 3.89 21.11 3 20 3M5 7H10V13H5V7M19 17H5V15H19V17M19 13H12V11H19V13M19 9H12V7H19V9Z" /></svg><span>News</span></label></li>',
).firstChild!,
);
if (NewsButton.firstChild) { const iconCover = document.createElement("div");
fragment.appendChild(NewsButton.firstChild);
}
let iconCover = document.createElement("div");
iconCover.classList.add("icon-cover"); iconCover.classList.add("icon-cover");
iconCover.id = "icon-cover"; iconCover.id = "icon-cover";
menu.appendChild(iconCover); menu.appendChild(iconCover);
@@ -245,46 +241,42 @@ function setupEventListeners() {
} }
async function createSettingsButton() { async function createSettingsButton() {
let SettingsButton = stringToHTML(/* html */ ` document.getElementById("content")!.append(
stringToHTML(/* html */ `
<button class="addedButton tooltip" id="AddedSettings"> <button class="addedButton tooltip" id="AddedSettings">
<svg width="24" height="24" viewBox="0 0 24 24"> <svg width="24" height="24" viewBox="0 0 24 24">
<g><g><path d="M23.182,6.923c-.29,0-3.662,2.122-4.142,2.4l-2.8-1.555V4.511l4.257-2.456a.518.518,0,0,0,.233-.408.479.479,0,0,0-.233-.407,6.511,6.511,0,1,0-3.327,12.107,6.582,6.582,0,0,0,6.148-4.374,5.228,5.228,0,0,0,.333-1.542A.461.461,0,0,0,23.182,6.923Z"></path><path d="M9.73,10.418,7.376,12.883c-.01.01-.021.016-.03.025L1.158,19.1a2.682,2.682,0,1,0,3.793,3.793l4.583-4.582,0,0,4.1-4.005-.037-.037A9.094,9.094,0,0,1,9.73,10.418ZM3.053,21.888A.894.894,0,1,1,3.946,21,.893.893,0,0,1,3.053,21.888Z"></path></g></g> <g><g><path d="M23.182,6.923c-.29,0-3.662,2.122-4.142,2.4l-2.8-1.555V4.511l4.257-2.456a.518.518,0,0,0,.233-.408.479.479,0,0,0-.233-.407,6.511,6.511,0,1,0-3.327,12.107,6.582,6.582,0,0,0,6.148-4.374,5.228,5.228,0,0,0,.333-1.542A.461.461,0,0,0,23.182,6.923Z"></path><path d="M9.73,10.418,7.376,12.883c-.01.01-.021.016-.03.025L1.158,19.1a2.682,2.682,0,1,0,3.793,3.793l4.583-4.582,0,0,4.1-4.005-.037-.037A9.094,9.094,0,0,1,9.73,10.418ZM3.053,21.888A.894.894,0,1,1,3.946,21,.893.893,0,0,1,3.053,21.888Z"></path></g></g>
</svg> </svg>
${settingsState.onoff ? '<div class="tooltiptext topmenutooltip">BetterSEQTA+ Settings</div>' : ""} ${settingsState.onoff ? '<div class="tooltiptext topmenutooltip">BetterSEQTA+ Settings</div>' : ""}
</button> </button>
`); `).firstChild!,
let ContentDiv = document.getElementById("content"); );
ContentDiv!.append(SettingsButton.firstChild!);
} }
function GetLightDarkModeString() { function GetLightDarkModeString() {
if (settingsState.DarkMode) { return settingsState.DarkMode
return "Switch to light theme"; ? "Switch to light theme"
} else { : "Switch to dark theme";
return "Switch to dark theme";
}
} }
async function addDarkLightToggle() { async function addDarkLightToggle() {
const tooltipString = GetLightDarkModeString();
const SUN_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_80"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_80)"><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -4,-2.2100000381469727 -4,0 C-4,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`; const SUN_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_80"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_80)"><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -4,-2.2100000381469727 -4,0 C-4,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(1,0,0,1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
const MOON_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_263"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_263)"><g style="display: block;" transform="matrix(1.5,0,0,1.5,7,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -1.2920000553131104,-2.2100000381469727 -1.2920000553131104,0 C-1.2920000553131104,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(-1,0,0,-1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`; const MOON_ICON_SVG = /* html */ `<defs><clipPath id="__lottie_element_263"><rect width="24" height="24" x="0" y="0"></rect></clipPath></defs><g clip-path="url(#__lottie_element_263)"><g style="display: block;" transform="matrix(1.5,0,0,1.5,7,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,-4 C-2.2100000381469727,-4 -1.2920000553131104,-2.2100000381469727 -1.2920000553131104,0 C-1.2920000553131104,2.2100000381469727 -2.2100000381469727,4 0,4 C2.2100000381469727,4 4,2.2100000381469727 4,0 C4,-2.2100000381469727 2.2100000381469727,-4 0,-4z"></path></g></g><g style="display: block;" transform="matrix(-1,0,0,-1,12,12)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill-opacity="1" d=" M0,6 C-3.309999942779541,6 -6,3.309999942779541 -6,0 C-6,-3.309999942779541 -3.309999942779541,-6 0,-6 C3.309999942779541,-6 6,-3.309999942779541 6,0 C6,3.309999942779541 3.309999942779541,6 0,6z M8,-3.309999942779541 C8,-3.309999942779541 8,-8 8,-8 C8,-8 3.309999942779541,-8 3.309999942779541,-8 C3.309999942779541,-8 0,-11.3100004196167 0,-11.3100004196167 C0,-11.3100004196167 -3.309999942779541,-8 -3.309999942779541,-8 C-3.309999942779541,-8 -8,-8 -8,-8 C-8,-8 -8,-3.309999942779541 -8,-3.309999942779541 C-8,-3.309999942779541 -11.3100004196167,0 -11.3100004196167,0 C-11.3100004196167,0 -8,3.309999942779541 -8,3.309999942779541 C-8,3.309999942779541 -8,8 -8,8 C-8,8 -3.309999942779541,8 -3.309999942779541,8 C-3.309999942779541,8 0,11.3100004196167 0,11.3100004196167 C0,11.3100004196167 3.309999942779541,8 3.309999942779541,8 C3.309999942779541,8 8,8 8,8 C8,8 8,3.309999942779541 8,3.309999942779541 C8,3.309999942779541 11.3100004196167,0 11.3100004196167,0 C11.3100004196167,0 8,-3.309999942779541 8,-3.309999942779541z"></path></g></g></g>`;
const initialSvgContent = settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG; document.getElementById("content")!.append(
stringToHTML(/* html */ `
const LightDarkModeButton = stringToHTML(/* html */ `
<button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton"> <button class="addedButton DarkLightButton tooltip" id="LightDarkModeButton">
<svg xmlns="http://www.w3.org/2000/svg">${initialSvgContent}</svg> <svg xmlns="http://www.w3.org/2000/svg">${settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG}</svg>
<div class="tooltiptext topmenutooltip" id="darklighttooliptext">${tooltipString}</div> <div class="tooltiptext topmenutooltip" id="darklighttooliptext">${GetLightDarkModeString()}</div>
</button> </button>
`); `).firstChild!,
);
let ContentDiv = document.getElementById("content");
ContentDiv!.append(LightDarkModeButton.firstChild!);
updateAllColors(); updateAllColors();
const lightDarkModeButtonElement = document.getElementById("LightDarkModeButton")!; const lightDarkModeButtonElement = document.getElementById(
"LightDarkModeButton",
)!;
lightDarkModeButtonElement.addEventListener("click", async () => { lightDarkModeButtonElement.addEventListener("click", async () => {
const darklightText = document.getElementById("darklighttooliptext"); const darklightText = document.getElementById("darklighttooliptext");
@@ -296,7 +288,6 @@ async function addDarkLightToggle() {
LightDarkModeSnakeEggButton = 0; LightDarkModeSnakeEggButton = 0;
} }
if ( if (
settingsState.originalDarkMode !== undefined && settingsState.originalDarkMode !== undefined &&
settingsState.selectedTheme settingsState.selectedTheme
@@ -307,38 +298,24 @@ async function addDarkLightToggle() {
return; return;
} }
if (!document.startViewTransition || !settingsState.animations || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
settingsState.DarkMode = !settingsState.DarkMode; settingsState.DarkMode = !settingsState.DarkMode;
updateAllColors(); updateAllColors();
const newSvgContent = settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG; const svgElement = lightDarkModeButtonElement.querySelector("svg")!;
const svgElement = lightDarkModeButtonElement.querySelector("svg"); svgElement.innerHTML = settingsState.DarkMode
if (svgElement) svgElement.innerHTML = newSvgContent; ? SUN_ICON_SVG
darklightText!.innerText = GetLightDarkModeString(); : MOON_ICON_SVG;
return;
}
settingsState.DarkMode = !settingsState.DarkMode;
updateAllColors();
const newSvgContent = settingsState.DarkMode ? SUN_ICON_SVG : MOON_ICON_SVG;
const svgElement = lightDarkModeButtonElement.querySelector("svg");
if (svgElement) svgElement.innerHTML = newSvgContent;
darklightText!.innerText = GetLightDarkModeString(); darklightText!.innerText = GetLightDarkModeString();
}); });
} }
function customizeMenuToggle() { function customizeMenuToggle() {
const menuToggle = document.getElementById("menuToggle"); const menuToggle = document.getElementById("menuToggle")!;
if (menuToggle) {
menuToggle.innerHTML = ""; menuToggle.innerHTML = "";
}
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const line = document.createElement("div"); const line = document.createElement("div");
line.className = "hamburger-line"; line.className = "hamburger-line";
menuToggle!.appendChild(line); menuToggle.appendChild(line);
} }
} }
@@ -41,7 +41,11 @@ export function renderSettingsIfNeeded() {
try { try {
const shadow = extensionPopup.attachShadow({ mode: "open" }); const shadow = extensionPopup.attachShadow({ mode: "open" });
if ('requestIdleCallback' in window) {
requestIdleCallback(() => renderSvelte(Settings, shadow)); requestIdleCallback(() => renderSvelte(Settings, shadow));
} else {
renderSvelte(Settings, shadow);
}
isSettingsRendered = true; isSettingsRendered = true;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
+4 -1
View File
@@ -26,6 +26,9 @@ export function addShortcuts(shortcuts: any) {
function createNewShortcut(link: any, icon: any, viewBox: any, title: any) { function createNewShortcut(link: any, icon: any, viewBox: any, title: any) {
// Creates the stucture and element information for each seperate shortcut // Creates the stucture and element information for each seperate shortcut
const container = document.getElementById("shortcuts");
if (!container) return;
let shortcut = document.createElement("a"); let shortcut = document.createElement("a");
shortcut.setAttribute("href", link); shortcut.setAttribute("href", link);
shortcut.setAttribute("target", "_blank"); shortcut.setAttribute("target", "_blank");
@@ -42,5 +45,5 @@ function createNewShortcut(link: any, icon: any, viewBox: any, title: any) {
shortcutdiv.append(text); shortcutdiv.append(text);
shortcut.append(shortcutdiv); shortcut.append(shortcutdiv);
document.getElementById("shortcuts")!.appendChild(shortcut); container.appendChild(shortcut);
} }
@@ -2,6 +2,9 @@ import stringToHTML from "../stringToHTML";
export function CreateCustomShortcutDiv(element: any) { export function CreateCustomShortcutDiv(element: any) {
// Creates the stucture and element information for each seperate shortcut // Creates the stucture and element information for each seperate shortcut
const container = document.getElementById("shortcuts");
if (!container) return;
var shortcut = document.createElement("a"); var shortcut = document.createElement("a");
shortcut.setAttribute("href", element.url); shortcut.setAttribute("href", element.url);
shortcut.setAttribute("target", "_blank"); shortcut.setAttribute("target", "_blank");
@@ -45,5 +48,5 @@ export function CreateCustomShortcutDiv(element: any) {
shortcutdiv.append(text); shortcutdiv.append(text);
shortcut.append(shortcutdiv); shortcut.append(shortcutdiv);
document.getElementById("shortcuts")!.append(shortcut); container.append(shortcut);
} }
@@ -1,28 +0,0 @@
import links from "@/seqta/content/links.json";
export function RemoveShortcutDiv(elements: any) {
if (elements.length === 0) return;
elements.forEach((element: any) => {
const shortcuts = document.querySelectorAll(".shortcut");
shortcuts.forEach((shortcut) => {
const anchorElement = shortcut.parentElement; // the <a> element is the parent
const textElement = shortcut.querySelector("p"); // <p> is a direct child of .shortcut
const title = textElement ? textElement.textContent : "";
const elementName = links[element.name as keyof typeof links]?.DisplayName || element.name;
let shouldRemove = title === elementName;
// Check href only if element.url exists
if (element.url) {
shouldRemove =
shouldRemove && anchorElement!.getAttribute("href") === element.url;
}
if (shouldRemove) {
anchorElement!.remove();
}
});
});
}
+129 -304
View File
@@ -4,12 +4,11 @@ import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
import assessmentsicon from "@/seqta/icons/assessmentsIcon"; import assessmentsicon from "@/seqta/icons/assessmentsIcon";
import coursesicon from "@/seqta/icons/coursesIcon"; import coursesicon from "@/seqta/icons/coursesIcon";
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour"; import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
import { addShortcuts } from "../Adders/AddShortcuts";
import { convertTo12HourFormat } from "../convertTo12HourFormat"; import { convertTo12HourFormat } from "../convertTo12HourFormat";
import { delay } from "../delay"; import { delay } from "../delay";
import { settingsState } from "../listeners/SettingsState"; import { settingsState } from "../listeners/SettingsState";
import stringToHTML from "../stringToHTML"; import stringToHTML from "../stringToHTML";
import { CreateCustomShortcutDiv } from "@/seqta/utils/CreateEnable/CreateCustomShortcutDiv"; import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement"; import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments"; import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent"; import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
@@ -31,15 +30,12 @@ export async function loadHomePage() {
element?.classList.add("active"); element?.classList.add("active");
const main = document.getElementById("main"); const main = document.getElementById("main");
if (!main) { if (!main) return;
console.error("[BetterSEQTA+] Main element not found.");
return;
}
const homeRoot = stringToHTML(`<div id="home-root" class="home-root"></div>`);
main.innerHTML = ""; main.innerHTML = "";
main.appendChild(homeRoot?.firstChild!); main.appendChild(
stringToHTML(`<div id="home-root" class="home-root"></div>`).firstChild!,
);
const homeContainer = document.getElementById("home-root"); const homeContainer = document.getElementById("home-root");
if (!homeContainer) return; if (!homeContainer) return;
@@ -100,97 +96,21 @@ export async function loadHomePage() {
const cleanup = setupTimetableListeners(); const cleanup = setupTimetableListeners();
try { renderShortcuts();
addShortcuts(settingsState.shortcuts);
} catch (err: any) {
console.error("[BetterSEQTA+] Error adding shortcuts:", err.message || err);
}
AddCustomShortcutsToPage();
const date = new Date(); const TodayFormatted = formatDate(new Date());
const TodayFormatted = formatDate(date);
const [timetablePromise, assessmentsPromise, classesPromise, prefsPromise] = [
fetch(`${location.origin}/seqta/student/load/timetable?`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from: TodayFormatted,
until: TodayFormatted,
student: 69,
}),
}).then((res) => res.json()),
const [assessments, classes, prefs] = await Promise.all([
GetUpcomingAssessments(), GetUpcomingAssessments(),
GetActiveClasses(), GetActiveClasses(),
fetch(`${location.origin}/seqta/student/load/prefs?`, { fetch(`${location.origin}/seqta/student/load/prefs?`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ asArray: true, request: "userPrefs" }), body: JSON.stringify({ asArray: true, request: "userPrefs" }),
}).then((res) => res.json()), }).then((res) => res.json()),
];
const [timetableData, assessments, classes, prefs] = await Promise.all([
timetablePromise,
assessmentsPromise,
classesPromise,
prefsPromise,
]); ]);
const dayContainer = document.getElementById("day-container"); callHomeTimetable(TodayFormatted, true);
if (dayContainer && timetableData.payload.items.length > 0) {
const lessonArray = timetableData.payload.items.sort((a: any, b: any) =>
a.from.localeCompare(b.from),
);
const colours = await GetLessonColours();
dayContainer.innerHTML = "";
for (let i = 0; i < lessonArray.length; i++) {
const lesson = lessonArray[i];
const subjectname = `timetable.subject.colour.${lesson.code}`;
const subject = colours.find(
(element: any) => element.name === subjectname,
);
lesson.colour = subject
? `--item-colour: ${subject.value};`
: "--item-colour: #8e8e8e;";
lesson.from = lesson.from.substring(0, 5);
lesson.until = lesson.until.substring(0, 5);
if (settingsState.timeFormat === "12") {
lesson.from = convertTo12HourFormat(lesson.from);
lesson.until = convertTo12HourFormat(lesson.until);
}
lesson.attendanceTitle = CheckUnmarkedAttendance(lesson.attendance);
const div = makeLessonDiv(lesson, i + 1);
if (GetThresholdOfColor(subject?.value) > 300) {
const firstChild = div.firstChild as HTMLElement;
if (firstChild) {
firstChild.classList.add("day-inverted");
}
}
dayContainer.appendChild(div.firstChild!);
}
if (currentSelectedDate.getDate() === date.getDate()) {
for (let i = 0; i < lessonArray.length; i++) {
CheckCurrentLesson(lessonArray[i], i + 1);
}
CheckCurrentLessonAll(lessonArray);
}
} else if (dayContainer) {
dayContainer.innerHTML = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" />
<p>No lessons available.</p>
</div>`;
}
dayContainer?.classList.remove("loading");
const activeClass = classes.find((c: any) => c.hasOwnProperty("active")); const activeClass = classes.find((c: any) => c.hasOwnProperty("active"));
const activeSubjects = activeClass?.subjects || []; const activeSubjects = activeClass?.subjects || [];
@@ -227,20 +147,20 @@ export async function loadHomePage() {
} }
async function GetUpcomingAssessments() { async function GetUpcomingAssessments() {
let func = fetch( try {
`${location.origin}/seqta/student/assessment/list/upcoming?`, return fetch(`${location.origin}/seqta/student/assessment/list/upcoming?`, {
{
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json; charset=utf-8",
}, },
body: JSON.stringify({ student: 69 }), body: JSON.stringify({ student: 69 }),
}, })
);
return func
.then((result) => result.json()) .then((result) => result.json())
.then((response) => response.payload); .then((response) => response.payload);
} catch (error) {
console.error("[BetterSEQTA+] Failed to get upcoming assessments:", error);
return [];
}
} }
function setupTimetableListeners() { function setupTimetableListeners() {
@@ -298,15 +218,10 @@ async function GetActiveClasses() {
body: JSON.stringify({}), body: JSON.stringify({}),
}, },
); );
return (await response.json()).payload;
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
return data.payload;
} catch (error) { } catch (error) {
console.error("Oops! There was a problem fetching active classes:", error); console.error("[BetterSEQTA+] Failed to get active classes:", error);
return [];
} }
} }
@@ -316,28 +231,25 @@ function setupNotices(labelArray: string[], date: string) {
) as HTMLInputElement; ) as HTMLInputElement;
const fetchNotices = async (date: string) => { const fetchNotices = async (date: string) => {
let data; try {
const data = settingsState.mockNotices
if (settingsState.mockNotices) { ? getMockNotices()
data = getMockNotices(); : await (
} else { await fetch(`${location.origin}/seqta/student/load/notices?`, {
const response = await fetch(
`${location.origin}/seqta/student/load/notices?`,
{
method: "POST", method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" }, headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ date }), body: JSON.stringify({ date }),
}, })
); ).json();
data = await response.json();
}
processNotices(data, labelArray); processNotices(data, labelArray);
} catch (error) {
console.error("[BetterSEQTA+] Failed to fetch notices:", error);
}
}; };
const debouncedInputChange = debounce((e: Event) => { const debouncedInputChange = debounce((e: Event) => {
const target = e.target as HTMLInputElement; fetchNotices((e.target as HTMLInputElement).value);
fetchNotices(target.value);
}, 250); }, 250);
dateControl?.addEventListener("input", debouncedInputChange); dateControl?.addEventListener("input", debouncedInputChange);
@@ -358,25 +270,8 @@ function debounce<T extends (...args: any[]) => any>(
} }
function comparedate(obj1: any, obj2: any) { function comparedate(obj1: any, obj2: any) {
if (obj1.date < obj2.date) { return obj1.date < obj2.date ? -1 : obj1.date > obj2.date ? 1 : 0;
return -1;
} }
if (obj1.date > obj2.date) {
return 1;
}
return 0;
}
async function AddCustomShortcutsToPage() {
let customshortcuts: any = settingsState.customshortcuts;
if (customshortcuts.length > 0) {
for (let i = 0; i < customshortcuts.length; i++) {
const element = customshortcuts[i];
CreateCustomShortcutDiv(element);
}
}
}
function processNotices(response: any, labelArray: string[]) { function processNotices(response: any, labelArray: string[]) {
const NoticeContainer = document.getElementById("notice-container"); const NoticeContainer = document.getElementById("notice-container");
if (!NoticeContainer) return; if (!NoticeContainer) return;
@@ -420,13 +315,13 @@ function processNoticeColor(colour: string): string | undefined {
} }
function createNoticeElement(notice: any, colour: string | undefined): Node { function createNoticeElement(notice: any, colour: string | undefined): Node {
const textPreview = notice.contents const textPreview =
notice.contents
.replace(/<[^>]*>/g, "") .replace(/<[^>]*>/g, "")
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "") .replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim() .trim()
.substring(0, 150) .substring(0, 150) + (notice.contents.length > 150 ? "..." : "");
+ (notice.contents.length > 150 ? "..." : "");
const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const noticeId = `notice-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -446,12 +341,10 @@ function createNoticeElement(notice: any, colour: string | undefined): Node {
</div>`; </div>`;
const element = stringToHTML(htmlContent).firstChild as HTMLElement; const element = stringToHTML(htmlContent).firstChild as HTMLElement;
if (element) {
element.addEventListener("click", () => element.addEventListener("click", () =>
openNoticeModal(notice, colour, element), openNoticeModal(notice, colour, element),
); );
} return element;
return element!;
} }
function openNoticeModal( function openNoticeModal(
@@ -463,15 +356,11 @@ function openNoticeModal(
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "") .replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
.replace(/ +/, " "); .replace(/ +/, " ");
const existingModal = document.getElementById("notice-modal"); document.getElementById("notice-modal")?.remove();
if (existingModal) {
existingModal.remove();
}
const sourceRect = sourceElement.getBoundingClientRect(); const sourceRect = sourceElement.getBoundingClientRect();
let scrollY = Math.round(window.scrollY); let scrollY = Math.round(window.scrollY);
let scrollX = Math.round(window.scrollX); let scrollX = Math.round(window.scrollX);
let sourceLeft = sourceRect.left; let sourceLeft = sourceRect.left;
let sourceTop = sourceRect.top; let sourceTop = sourceRect.top;
let sourceWidth = sourceRect.width; let sourceWidth = sourceRect.width;
@@ -553,7 +442,6 @@ function openNoticeModal(
let targetHeight = Math.round( let targetHeight = Math.round(
Math.min(Math.max(measuredHeight, 200), viewportHeight * 0.85), Math.min(Math.max(measuredHeight, 200), viewportHeight * 0.85),
); );
let targetLeft = Math.round((viewportWidth - targetWidth) / 2); let targetLeft = Math.round((viewportWidth - targetWidth) / 2);
let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY; let targetTop = Math.round((viewportHeight - targetHeight) / 2) + scrollY;
@@ -662,13 +550,10 @@ function openNoticeModal(
const newTargetWidth = Math.round( const newTargetWidth = Math.round(
Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40), Math.min(Math.max(newSourceWidth, 800), newViewportWidth - 40),
); );
// Just measure the existing modal content
const currentHeight = unifiedContent.getBoundingClientRect().height; const currentHeight = unifiedContent.getBoundingClientRect().height;
const newTargetHeight = Math.round( const newTargetHeight = Math.round(
Math.min(Math.max(currentHeight, 200), newViewportHeight * 0.85), Math.min(Math.max(currentHeight, 200), newViewportHeight * 0.85),
); );
const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2); const newTargetLeft = Math.round((newViewportWidth - newTargetWidth) / 2);
const newTargetTop = const newTargetTop =
Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY; Math.round((newViewportHeight - newTargetHeight) / 2) + newScrollY;
@@ -733,7 +618,8 @@ function callHomeTimetable(date: string, change?: any) {
xhr.setRequestHeader("Content-Type", "application/json; charset=utf-8"); xhr.setRequestHeader("Content-Type", "application/json; charset=utf-8");
xhr.onreadystatechange = function () { xhr.onreadystatechange = function () {
if (xhr.readyState === 4) { if (xhr.readyState !== 4) return;
if (loadingTimeout) { if (loadingTimeout) {
clearTimeout(loadingTimeout); clearTimeout(loadingTimeout);
loadingTimeout = null; loadingTimeout = null;
@@ -741,7 +627,6 @@ function callHomeTimetable(date: string, change?: any) {
const DayContainer = document.getElementById("day-container")!; const DayContainer = document.getElementById("day-container")!;
try {
var serverResponse = JSON.parse(xhr.response); var serverResponse = JSON.parse(xhr.response);
let lessonArray: Array<any> = []; let lessonArray: Array<any> = [];
@@ -755,20 +640,20 @@ function callHomeTimetable(date: string, change?: any) {
}); });
GetLessonColours().then((colours) => { GetLessonColours().then((colours) => {
let subjects = colours;
for (let i = 0; i < lessonArray.length; i++) { for (let i = 0; i < lessonArray.length; i++) {
let subjectname = `timetable.subject.colour.${lessonArray[i].code}`; let subjectname =
lessonArray[i].type == "tutorial"
let subject = subjects.find( ? `timetable.tutor.${lessonArray[i].tutorID}`
: `timetable.subject.colour.${lessonArray[i].code}`;
let subject = colours.find(
(element: any) => element.name === subjectname, (element: any) => element.name === subjectname,
); );
if (!subject) { if (!subject) {
lessonArray[i].colour = "--item-colour: #8e8e8e;"; lessonArray[i].colour = "--item-colour: #8e8e8e;";
} else { } else {
lessonArray[i].colour = `--item-colour: ${subject.value};`; lessonArray[i].colour = `--item-colour: ${subject.value};`;
let result = GetThresholdOfColor(subject.value); if (GetThresholdOfColor(subject.value) > 300) {
if (result > 300) {
lessonArray[i].invert = true; lessonArray[i].invert = true;
} }
} }
@@ -777,9 +662,7 @@ function callHomeTimetable(date: string, change?: any) {
lessonArray[i].until = lessonArray[i].until.substring(0, 5); lessonArray[i].until = lessonArray[i].until.substring(0, 5);
if (settingsState.timeFormat === "12") { if (settingsState.timeFormat === "12") {
lessonArray[i].from = convertTo12HourFormat( lessonArray[i].from = convertTo12HourFormat(lessonArray[i].from);
lessonArray[i].from,
);
lessonArray[i].until = convertTo12HourFormat( lessonArray[i].until = convertTo12HourFormat(
lessonArray[i].until, lessonArray[i].until,
); );
@@ -792,13 +675,10 @@ function callHomeTimetable(date: string, change?: any) {
DayContainer.innerText = ""; DayContainer.innerText = "";
for (let i = 0; i < lessonArray.length; i++) { for (let i = 0; i < lessonArray.length; i++) {
var div = makeLessonDiv(lessonArray[i], i + 1); const div = makeLessonDiv(lessonArray[i], i + 1);
if (lessonArray[i].invert) { if (lessonArray[i].invert) {
const div1 = div.firstChild! as HTMLElement; (div.firstChild! as HTMLElement).classList.add("day-inverted");
div1.classList.add("day-inverted");
} }
DayContainer.append(div.firstChild as HTMLElement); DayContainer.append(div.firstChild as HTMLElement);
} }
@@ -809,40 +689,22 @@ function callHomeTimetable(date: string, change?: any) {
for (let i = 0; i < lessonArray.length; i++) { for (let i = 0; i < lessonArray.length; i++) {
CheckCurrentLesson(lessonArray[i], i + 1); CheckCurrentLesson(lessonArray[i], i + 1);
} }
CheckCurrentLessonAll(lessonArray); CheckCurrentLessonAll(lessonArray);
} }
}); });
} }
} else { } else {
DayContainer.innerHTML = ""; DayContainer.innerHTML = "";
var dummyDay = document.createElement("div"); const dummyDay = document.createElement("div");
dummyDay.classList.add("day-empty"); dummyDay.classList.add("day-empty");
let img = document.createElement("img"); const img = document.createElement("img");
img.src = browser.runtime.getURL(LogoLight); img.src = browser.runtime.getURL(LogoLight);
let text = document.createElement("p"); const text = document.createElement("p");
text.innerText = "No lessons available."; text.innerText = "No lessons available.";
dummyDay.append(img); dummyDay.append(img, text);
dummyDay.append(text);
DayContainer.append(dummyDay); DayContainer.append(dummyDay);
DayContainer.classList.remove("loading"); DayContainer.classList.remove("loading");
} }
} catch (error) {
console.error("Error loading timetable data:", error);
DayContainer.classList.remove("loading");
DayContainer.innerHTML = "";
const errorDiv = document.createElement("div");
errorDiv.classList.add("day-empty");
errorDiv.innerHTML = `
<img src="${browser.runtime.getURL(LogoLight)}" />
<p>Error loading lessons. Please try again.</p>
`;
DayContainer.append(errorDiv);
}
}
}; };
xhr.send( xhr.send(
JSON.stringify({ JSON.stringify({
@@ -931,8 +793,6 @@ async function CheckCurrentLesson(lesson: any, num: number) {
} }
function makeLessonDiv(lesson: any, num: number) { function makeLessonDiv(lesson: any, num: number) {
if (!lesson) throw new Error("No lesson provided.");
const { const {
code, code,
colour, colour,
@@ -945,17 +805,19 @@ function makeLessonDiv(lesson: any, num: number) {
programmeID, programmeID,
metaID, metaID,
assessments, assessments,
type,
} = lesson; } = lesson;
let lessonString = ` let lessonString = `
<div class="day" id="${code + num}" style="${colour}"> <div class="day" id="${code + num}" style="${colour}">
<h2>${description || "Unknown"}</h2> <h2>${type == "class" ? description : type == "tutorial" ? "Tutorial" : "Unknown"}</h2>
<h3>${staff || "Unknown"}</h3> <h3>${staff || "Unknown"}</h3>
<h3>${room || "Unknown"}</h3> <h3>${room || (type == "tutorial" ? "Unknown" : "N/A")}</h3>
<h4>${from || "Unknown"} - ${until || "Unknown"}</h4> <h4>${from || "Unknown"} - ${until || "Unknown"}</h4>
<h5>${attendanceTitle || "Unknown"}</h5> <h5>${attendanceTitle || "Unknown"}</h5>
`; `;
if (type == "class") {
if (programmeID !== 0) { if (programmeID !== 0) {
lessonString += ` lessonString += `
<div class="day-button clickable" style="right: 5px;" onclick="location.href='${buildAssessmentURL(programmeID, metaID)}'">${assessmentsicon}</div> <div class="day-button clickable" style="right: 5px;" onclick="location.href='${buildAssessmentURL(programmeID, metaID)}'">${assessmentsicon}</div>
@@ -980,6 +842,7 @@ function makeLessonDiv(lesson: any, num: number) {
</div> </div>
`; `;
} }
}
lessonString += "</div>"; lessonString += "</div>";
const element = stringToHTML(lessonString); const element = stringToHTML(lessonString);
@@ -995,64 +858,48 @@ function buildAssessmentURL(programmeID: any, metaID: any, itemID = "") {
} }
function CheckUnmarkedAttendance(lessonattendance: any) { function CheckUnmarkedAttendance(lessonattendance: any) {
if (lessonattendance) { return lessonattendance ? lessonattendance.label : " ";
var lesson = lessonattendance.label;
} else {
lesson = " ";
}
return lesson;
} }
async function CreateUpcomingSection(assessments: any, activeSubjects: any) { async function CreateUpcomingSection(assessments: any, activeSubjects: any) {
let upcomingitemcontainer = document.querySelector("#upcoming-items"); const upcomingitemcontainer = document.querySelector("#upcoming-items");
let overdueDates = []; const overdueDates = [];
let upcomingDates = {}; const upcomingDates = {};
const Today = new Date();
var Today = new Date();
for (let i = 0; i < assessments.length; i++) { for (let i = 0; i < assessments.length; i++) {
const assessment = assessments[i]; const assessmentdue = new Date(assessments[i].due);
let assessmentdue = new Date(assessment.due); if (assessmentdue < Today && !CheckSpecialDay(Today, assessmentdue)) {
overdueDates.push(assessments[i]);
CheckSpecialDay(Today, assessmentdue);
if (assessmentdue < Today) {
if (!CheckSpecialDay(Today, assessmentdue)) {
overdueDates.push(assessment);
assessments.splice(i, 1); assessments.splice(i, 1);
i--; i--;
} }
} }
}
var TomorrowDate = new Date();
TomorrowDate.setDate(TomorrowDate.getDate() + 1);
const colours = await GetLessonColours(); const colours = await GetLessonColours();
let subjects = colours;
for (let i = 0; i < assessments.length; i++) { for (let i = 0; i < assessments.length; i++) {
let subjectname = `timetable.subject.colour.${assessments[i].code}`; const subject = colours.find(
(element: any) =>
let subject = subjects.find((element: any) => element.name === subjectname); element.name === `timetable.subject.colour.${assessments[i].code}`,
);
if (!subject) { if (!subject) {
assessments[i].colour = "--item-colour: #8e8e8e;"; assessments[i].colour = "--item-colour: #8e8e8e;";
} else { } else {
assessments[i].colour = `--item-colour: ${subject.value};`; assessments[i].colour = `--item-colour: ${subject.value};`;
GetThresholdOfColor(subject.value);
} }
} }
for (let i = 0; i < activeSubjects.length; i++) { for (let i = 0; i < activeSubjects.length; i++) {
const element = activeSubjects[i]; const element = activeSubjects[i];
let subjectname = `timetable.subject.colour.${element.code}`; const colour = colours.find(
let colour = colours.find((element: any) => element.name === subjectname); (c: any) => c.name === `timetable.subject.colour.${element.code}`,
);
if (!colour) { if (!colour) {
element.colour = "--item-colour: #8e8e8e;"; element.colour = "--item-colour: #8e8e8e;";
} else { } else {
element.colour = `--item-colour: ${colour.value};`; element.colour = `--item-colour: ${colour.value};`;
let result = GetThresholdOfColor(colour.value); if (GetThresholdOfColor(colour.value) > 300) {
if (result > 300) {
element.invert = true; element.invert = true;
} }
} }
@@ -1060,52 +907,34 @@ async function CreateUpcomingSection(assessments: any, activeSubjects: any) {
CreateFilters(activeSubjects); CreateFilters(activeSubjects);
let type;
let class_;
for (let i = 0; i < assessments.length; i++) { for (let i = 0; i < assessments.length; i++) {
const element: any = assessments[i]; const element: any = assessments[i];
if (!upcomingDates[element.due as keyof typeof upcomingDates]) { if (!upcomingDates[element.due as keyof typeof upcomingDates]) {
let dateObj: any = new Object(); const dateObj: any = {
dateObj.div = CreateElement( div: CreateElement("div", "upcoming-date-container"),
(type = "div"), assessments: [],
(class_ = "upcoming-date-container"), };
);
dateObj.assessments = [];
(upcomingDates[element.due as keyof typeof upcomingDates] as any) = (upcomingDates[element.due as keyof typeof upcomingDates] as any) =
dateObj; dateObj;
} }
let assessmentDateDiv = const assessmentDateDiv =
upcomingDates[element.due as keyof typeof upcomingDates]; upcomingDates[element.due as keyof typeof upcomingDates];
if (assessmentDateDiv) { if (assessmentDateDiv) {
(assessmentDateDiv as any).assessments.push(element); (assessmentDateDiv as any).assessments.push(element);
} }
} }
for (var date in upcomingDates) { for (var date in upcomingDates) {
let assessmentdue = new Date( const assessmentdue = new Date(
( (upcomingDates[date as keyof typeof upcomingDates] as any).assessments[0]
upcomingDates[date as keyof typeof upcomingDates] as any .due,
).assessments[0].due,
); );
let specialcase = CheckSpecialDay(Today, assessmentdue); const specialcase = CheckSpecialDay(Today, assessmentdue);
let assessmentDate; const assessmentDate = createAssessmentDateDiv(
if (specialcase) {
let datecase: string = specialcase!;
assessmentDate = createAssessmentDateDiv(
date, date,
upcomingDates[date as keyof typeof upcomingDates], upcomingDates[date as keyof typeof upcomingDates],
specialcase,
datecase,
); );
} else {
assessmentDate = createAssessmentDateDiv(
date,
upcomingDates[date as keyof typeof upcomingDates],
);
}
if (specialcase === "Yesterday") { if (specialcase === "Yesterday") {
upcomingitemcontainer!.insertBefore( upcomingitemcontainer!.insertBefore(
@@ -1117,80 +946,79 @@ async function CreateUpcomingSection(assessments: any, activeSubjects: any) {
} }
} }
FilterUpcomingAssessments(settingsState.subjectfilters); FilterUpcomingAssessments(settingsState.subjectfilters);
if (assessments.length === 0) {
upcomingitemcontainer!.innerHTML = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" />
<p>No assessments available.</p>
</div>`;
}
} }
function createAssessmentDateDiv(date: string, value: any, datecase?: any) { function createAssessmentDateDiv(date: string, value: any, datecase?: any) {
var options = { const options = {
weekday: "long" as "long", weekday: "long" as "long",
month: "long" as "long", month: "long" as "long",
day: "numeric" as "numeric", day: "numeric" as "numeric",
}; };
const FormattedDate = new Date(date); const FormattedDate = new Date(date);
const assessments = value.assessments; const assessments = value.assessments;
const container = value.div; const container = value.div;
let DateTitleDiv = document.createElement("div"); const DateTitleDiv = document.createElement("div");
DateTitleDiv.classList.add("upcoming-date-title"); DateTitleDiv.classList.add("upcoming-date-title");
if (datecase) { if (datecase) {
let datetitle = document.createElement("h5"); const datetitle = document.createElement("h5");
datetitle.classList.add("upcoming-special-day"); datetitle.classList.add("upcoming-special-day");
datetitle.innerText = datecase; datetitle.innerText = datecase;
DateTitleDiv.append(datetitle); DateTitleDiv.append(datetitle);
container.setAttribute("data-day", datecase); container.setAttribute("data-day", datecase);
} }
let DateTitle = document.createElement("h5"); const DateTitle = document.createElement("h5");
DateTitle.innerText = FormattedDate.toLocaleDateString("en-AU", options); DateTitle.innerText = FormattedDate.toLocaleDateString("en-AU", options);
DateTitleDiv.append(DateTitle); DateTitleDiv.append(DateTitle);
container.append(DateTitleDiv); container.append(DateTitleDiv);
let assessmentContainer = document.createElement("div"); const assessmentContainer = document.createElement("div");
assessmentContainer.classList.add("upcoming-date-assessments"); assessmentContainer.classList.add("upcoming-date-assessments");
for (let i = 0; i < assessments.length; i++) { for (let i = 0; i < assessments.length; i++) {
const element = assessments[i]; const element = assessments[i];
let item = document.createElement("div"); const item = document.createElement("div");
item.classList.add("upcoming-assessment"); item.classList.add("upcoming-assessment");
item.setAttribute("data-subject", element.code); item.setAttribute("data-subject", element.code);
item.id = `assessment${element.id}`; item.id = `assessment${element.id}`;
item.style.cssText = element.colour; item.style.cssText = element.colour;
let titlediv = document.createElement("div"); const titlediv = document.createElement("div");
titlediv.classList.add("upcoming-subject-title"); titlediv.classList.add("upcoming-subject-title");
titlediv.append(
let titlesvg =
stringToHTML(`<svg viewBox="0 0 24 24" style="width:35px;height:35px;fill:white;"> stringToHTML(`<svg viewBox="0 0 24 24" style="width:35px;height:35px;fill:white;">
<path d="M6 20H13V22H6C4.89 22 4 21.11 4 20V4C4 2.9 4.89 2 6 2H18C19.11 2 20 2.9 20 4V12.54L18.5 11.72L18 12V4H13V12L10.5 9.75L8 12V4H6V20M24 17L18.5 14L13 17L18.5 20L24 17M15 19.09V21.09L18.5 23L22 21.09V19.09L18.5 21L15 19.09Z"></path> <path d="M6 20H13V22H6C4.89 22 4 21.11 4 20V4C4 2.9 4.89 2 6 2H18C19.11 2 20 2.9 20 4V12.54L18.5 11.72L18 12V4H13V12L10.5 9.75L8 12V4H6V20M24 17L18.5 14L13 17L18.5 20L24 17M15 19.09V21.09L18.5 23L22 21.09V19.09L18.5 21L15 19.09Z"></path>
</svg>`).firstChild; </svg>`).firstChild!,
titlediv.append(titlesvg!); );
let detailsdiv = document.createElement("div"); const detailsdiv = document.createElement("div");
detailsdiv.classList.add("upcoming-details"); detailsdiv.classList.add("upcoming-details");
let detailstitle = document.createElement("h5"); const detailstitle = document.createElement("h5");
detailstitle.innerText = `${element.subject} assessment`; detailstitle.innerText = `${element.subject} assessment`;
let subject = document.createElement("p"); const subject = document.createElement("p");
subject.innerText = element.title; subject.innerText = element.title;
subject.classList.add("upcoming-assessment-title"); subject.classList.add("upcoming-assessment-title");
subject.onclick = function () { subject.onclick = function () {
document.querySelector("#menu ul")!.classList.add("noscroll"); document.querySelector("#menu ul")!.classList.add("noscroll");
location.href = `../#?page=/assessments/${element.programmeID}:${element.metaclassID}&item=${element.id}`; location.href = `../#?page=/assessments/${element.programmeID}:${element.metaclassID}&item=${element.id}`;
}; };
detailsdiv.append(detailstitle); detailsdiv.append(detailstitle, subject);
detailsdiv.append(subject); item.append(titlediv, detailsdiv);
item.append(titlediv);
item.append(detailsdiv);
assessmentContainer.append(item); assessmentContainer.append(item);
fetch(`${location.origin}/seqta/student/assessment/submissions/get`, { fetch(`${location.origin}/seqta/student/assessment/submissions/get`, {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json; charset=utf-8" },
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({ body: JSON.stringify({
assessment: element.id, assessment: element.id,
metaclass: element.metaclassID, metaclass: element.metaclassID,
@@ -1201,8 +1029,7 @@ function createAssessmentDateDiv(date: string, value: any, datecase?: any) {
.then((response) => { .then((response) => {
if (response.payload.length > 0) { if (response.payload.length > 0) {
const assessment = document.querySelector(`#assessment${element.id}`); const assessment = document.querySelector(`#assessment${element.id}`);
const submittedtext = document.createElement("div");
let submittedtext = document.createElement("div");
submittedtext.classList.add("upcoming-submittedtext"); submittedtext.classList.add("upcoming-submittedtext");
submittedtext.innerText = "Submitted"; submittedtext.innerText = "Submitted";
assessment!.append(submittedtext); assessment!.append(submittedtext);
@@ -1240,36 +1067,37 @@ function CheckSpecialDay(date1: Date, date2: Date) {
} }
async function GetLessonColours() { async function GetLessonColours() {
let func = fetch(`${location.origin}/seqta/student/load/prefs?`, { try {
return fetch(`${location.origin}/seqta/student/load/prefs?`, {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json; charset=utf-8" },
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({ request: "userPrefs", asArray: true, user: 69 }), body: JSON.stringify({ request: "userPrefs", asArray: true, user: 69 }),
}); })
return func
.then((result) => result.json()) .then((result) => result.json())
.then((response) => response.payload); .then((response) => response.payload);
} catch (error) {
console.error("[BetterSEQTA+] Failed to get lesson colours:", error);
return [];
}
} }
function CreateFilters(subjects: any) { function CreateFilters(subjects: any) {
let filteroptions = settingsState.subjectfilters; const filteroptions = settingsState.subjectfilters;
const filterdiv = document.querySelector("#upcoming-filters");
let filterdiv = document.querySelector("#upcoming-filters");
for (let i = 0; i < subjects.length; i++) { for (let i = 0; i < subjects.length; i++) {
const element = subjects[i]; const element = subjects[i];
if (!Object.prototype.hasOwnProperty.call(filteroptions, element.code)) { if (!Object.prototype.hasOwnProperty.call(filteroptions, element.code)) {
filteroptions[element.code] = true; filteroptions[element.code] = true;
settingsState.subjectfilters = filteroptions; settingsState.subjectfilters = filteroptions;
} }
let elementdiv = CreateSubjectFilter( filterdiv!.append(
CreateSubjectFilter(
element.code, element.code,
element.colour, element.colour,
filteroptions[element.code], filteroptions[element.code],
),
); );
filterdiv!.append(elementdiv);
} }
} }
@@ -1278,23 +1106,20 @@ function CreateSubjectFilter(
itemcolour: string, itemcolour: string,
checked: any, checked: any,
) { ) {
let label = CreateElement("label", "upcoming-checkbox-container"); const label = CreateElement("label", "upcoming-checkbox-container");
label.innerText = subjectcode; label.innerText = subjectcode;
let input1 = CreateElement("input"); const input = CreateElement("input") as HTMLInputElement;
const input = input1 as HTMLInputElement;
input.type = "checkbox"; input.type = "checkbox";
input.checked = checked; input.checked = checked;
input.id = `filter-${subjectcode}`; input.id = `filter-${subjectcode}`;
label.style.cssText = itemcolour; label.style.cssText = itemcolour;
let span = CreateElement("span", "upcoming-checkmark"); const span = CreateElement("span", "upcoming-checkmark");
label.append(input); label.append(input, span);
label.append(span);
input.addEventListener("change", function (change) { input.addEventListener("change", function (change) {
let filters = settingsState.subjectfilters; const filters = settingsState.subjectfilters;
let id = (change.target as HTMLInputElement)!.id.split("-")[1]; const id = (change.target as HTMLInputElement).id.split("-")[1];
filters[id] = (change.target as HTMLInputElement)!.checked; filters[id] = (change.target as HTMLInputElement).checked;
settingsState.subjectfilters = filters; settingsState.subjectfilters = filters;
}); });
+12 -66
View File
@@ -1,25 +1,17 @@
import stringToHTML from "../stringToHTML"; import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState"; import { settingsState } from "../listeners/SettingsState";
import { animate, stagger } from "motion"; import { openPopup } from "./PopupManager";
import { DeleteWhatsNew } from "../Whatsnew";
export function OpenAboutPage() { export function OpenAboutPage() {
const background = document.createElement("div"); const header = stringToHTML(
background.id = "whatsnewbk";
background.classList.add("whatsnewBackground");
const container = document.createElement("div");
container.classList.add("whatsnewContainer");
var header: any = stringToHTML(
/* html */ /* html */
`<div class="whatsnewHeader"> `<div class="whatsnewHeader">
<h1>About</h1> <h1>About</h1>
<p>About the extension</p> <p>About the extension</p>
</div>`, </div>`,
).firstChild; ).firstChild as HTMLElement;
let text = stringToHTML(/* html */ ` const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer" style="overflow-y: hidden;"> <div class="whatsnewTextContainer" style="overflow-y: hidden;">
<img src="${settingsState.DarkMode ? "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/dark.jpg" : "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/light.jpg"}" class="aboutImg" /> <img src="${settingsState.DarkMode ? "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/dark.jpg" : "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/light.jpg"}" class="aboutImg" />
<p>BetterSEQTA+ is a fork of BetterSEQTA (originally developed by Nulkem), which was discontinued. BetterSEQTA+ continued development of BetterSEQTA, while incorporating a plethora of features. </p> <p>BetterSEQTA+ is a fork of BetterSEQTA (originally developed by Nulkem), which was discontinued. BetterSEQTA+ continued development of BetterSEQTA, while incorporating a plethora of features. </p>
@@ -33,13 +25,13 @@ export function OpenAboutPage() {
</h1> </h1>
<div style="max-width: 600px; margin: auto;"> <div style="max-width: 600px; margin: auto;">
<img <img
src="https://contrib.rocks/image?repo=BetterSEQTA/BetterSEQTA-Plus&columns=14" src="https://contrib.rocks/image?repo=BetterSEQTA/BetterSEQTA-Plus&columns=10"
style="width: 100%; max-width: 500px; height: auto; object-fit: contain; display: block; margin: -110px auto 0;"> style="width: 100%; max-width: 500px; height: auto; object-fit: contain; display: block; margin: -80px auto 0;">
</div> </div>
</div> </div>
`).firstChild; `).firstChild as HTMLElement;
let footer = stringToHTML(/* html */ ` const footer = stringToHTML(/* html */ `
<div class="whatsnewFooter"> <div class="whatsnewFooter">
<div> <div>
Resources and Feedback: Resources and Feedback:
@@ -67,56 +59,10 @@ export function OpenAboutPage() {
</a> </a>
</div> </div>
</div> </div>
`).firstChild; `).firstChild as HTMLElement;
let exitbutton = document.createElement("div"); openPopup({
exitbutton.id = "whatsnewclosebutton"; header,
content: [text, footer],
container.append(header);
container.append(text as ChildNode);
container.append(footer as ChildNode);
container.append(exitbutton);
background.append(container);
document.getElementById("container")!.append(background);
let bkelement = document.getElementById("whatsnewbk");
let popup = document.getElementsByClassName("whatsnewContainer")[0];
if (settingsState.animations) {
animate(
[popup, bkelement as HTMLElement],
{ scale: [0, 1] },
{
type: "spring",
stiffness: 220,
damping: 18,
},
);
animate(
".whatsnewTextContainer *",
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.05, { startDelay: 0.1 }),
duration: 0.5,
ease: [0.22, 0.03, 0.26, 1],
},
);
}
delete settingsState.justupdated;
bkelement!.addEventListener("click", function (event) {
// Check if the click event originated from the element itself and not any of its children
if (event.target === bkelement) {
DeleteWhatsNew();
}
});
var closeelement = document.getElementById("whatsnewclosebutton");
closeelement!.addEventListener("click", function () {
DeleteWhatsNew();
}); });
} }
@@ -1,24 +1,5 @@
import { settingsState } from "./listeners/SettingsState"; import stringToHTML from "../stringToHTML";
import { animate, stagger } from "motion"; import { openPopup } from "./PopupManager";
import stringToHTML from "./stringToHTML";
export async function DeleteWhatsNew() {
const bkelement = document.getElementById("whatsnewbk");
const popup = document.querySelector(".whatsnewContainer") as HTMLElement;
if (!settingsState.animations) {
bkelement?.remove();
return;
}
animate(
[popup, bkelement!],
{ opacity: [1, 0], scale: [1, 0] },
{ ease: [0.22, 0.03, 0.26, 1] },
).then(() => {
bkelement?.remove();
});
}
export function OpenMinecraftServerPopup() { export function OpenMinecraftServerPopup() {
if (!document.querySelector('link[href*="minecraftia"]')) { if (!document.querySelector('link[href*="minecraftia"]')) {
@@ -28,45 +9,36 @@ export function OpenMinecraftServerPopup() {
document.head.appendChild(fontLink); document.head.appendChild(fontLink);
} }
const background = document.createElement("div"); const header = stringToHTML(
background.id = "whatsnewbk";
background.classList.add("whatsnewBackground");
const container = document.createElement("div");
container.classList.add("whatsnewContainer");
var header: any = stringToHTML(
/* html */ /* html */
`<div class="whatsnewHeader"> `<div class="whatsnewHeader">
<h1>Minecraft Server</h1> <h1>Minecraft Server</h1>
<p>The official BetterSEQTA+ Minecraft Server</p> <p>The official BetterSEQTA+ Minecraft Server</p>
</div>`, </div>`,
).firstChild; ).firstChild as HTMLElement;
let imagecont = document.createElement("div"); const imageContainer = document.createElement("div");
imagecont.classList.add("whatsnewImgContainer"); imageContainer.classList.add("whatsnewImgContainer");
let video = document.createElement("video"); const video = document.createElement("video");
video.style.aspectRatio = "16/9"; video.style.aspectRatio = "16/9";
video.style.background = "black"; video.style.background = "black";
let source = document.createElement("source");
const source = document.createElement("source");
source.setAttribute( source.setAttribute(
"src", "src",
"https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/server-video.mp4", "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/server-video.mp4",
); );
video.autoplay = true; video.autoplay = true;
video.muted = true; video.muted = true;
video.loop = true; video.loop = true;
video.appendChild(source); video.appendChild(source);
video.classList.add("whatsnewImg"); video.classList.add("whatsnewImg");
imagecont.appendChild(video); imageContainer.appendChild(video);
let textcontainer = document.createElement("div"); const text = stringToHTML(/* html */ `
textcontainer.classList.add("whatsnewTextContainer"); <div class="whatsnewTextContainer" style="height: 50%; overflow-y: hidden;">
let text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer" style="height: 50%; overflow-y: scroll;">
<h1>Join our community in Minecraft!</h1> <h1>Join our community in Minecraft!</h1>
<p style="margin-left: 0;">Join the official BetterSEQTA+ Minecraft Server community now!</p> <p style="margin-left: 0;">Join the official BetterSEQTA+ Minecraft Server community now!</p>
@@ -92,8 +64,7 @@ export function OpenMinecraftServerPopup() {
-1px -1px 0 #000, -1px -1px 0 #000,
1px -1px 0 #000, 1px -1px 0 #000,
-1px 1px 0 #000, -1px 1px 0 #000,
1px 1px 0 #000; 1px 1px 0 #000;">
">
mc.betterseqta.org mc.betterseqta.org
</p> </p>
<p style=" <p style="
@@ -107,14 +78,13 @@ export function OpenMinecraftServerPopup() {
-1px -1px 0 #000, -1px -1px 0 #000,
1px -1px 0 #000, 1px -1px 0 #000,
-1px 1px 0 #000, -1px 1px 0 #000,
1px 1px 0 #000; 1px 1px 0 #000;">
">
Version: 1.21.4 Version: 1.21.4
</p> </p>
</div> </div>
`).firstChild; `).firstChild as HTMLElement;
let footer = stringToHTML(/* html */ ` const footer = stringToHTML(/* html */ `
<div class="whatsnewFooter"> <div class="whatsnewFooter">
<div> <div>
Resources and Feedback: Resources and Feedback:
@@ -144,59 +114,10 @@ export function OpenMinecraftServerPopup() {
<div> <div>
</div> </div>
</div> </div>
`).firstChild; `).firstChild as HTMLElement;
let exitbutton = document.createElement("div"); openPopup({
exitbutton.id = "whatsnewclosebutton";
container.append(
header, header,
imagecont, content: [imageContainer, text, footer],
text as HTMLElement,
footer as HTMLElement,
exitbutton,
);
background.append(container);
document.getElementById("container")!.append(background);
let bkelement = document.getElementById("whatsnewbk");
let popup = document.getElementsByClassName("whatsnewContainer")[0];
if (settingsState.animations) {
animate(
[popup, bkelement as HTMLElement],
{ scale: [0, 1] },
{
type: "spring",
stiffness: 220,
damping: 18,
},
);
animate(
".whatsnewTextContainer *",
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.05, { startDelay: 0.1 }),
duration: 0.5,
ease: [0.22, 0.03, 0.26, 1],
},
);
}
delete settingsState.justupdated;
bkelement!.addEventListener("click", function (event) {
// Check if the click event originated from the element itself and not any of its children
if (event.target === bkelement) {
DeleteWhatsNew();
}
});
var closeelement = document.getElementById("whatsnewclosebutton");
closeelement!.addEventListener("click", function () {
DeleteWhatsNew();
}); });
} }
@@ -0,0 +1,52 @@
import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState";
import { openPopup } from "./PopupManager";
export function showPrivacyNotification() {
const lastUpdated = "2025-12-19";
if (document.getElementById("whatsnewbk")) return;
if (settingsState.privacyStatementShown) return;
if (settingsState.privacyStatementLastUpdated && new Date(settingsState.privacyStatementLastUpdated) > new Date(lastUpdated)) return;
const header = stringToHTML(
/* html */
`<div class="whatsnewHeader">
<h1>Privacy Statement</h1>
<p>Important Information</p>
</div>`,
).firstChild as HTMLElement;
const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer privacyStatement" style="overflow-y: auto; font-size: 1.2rem; line-height: 1.6;">
<img style="aspect-ratio: 16/5.8;" src="${settingsState.DarkMode ? "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/dark.jpg" : "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/branding/light.jpg"}" class="aboutImg" />
<p>
<strong>Addressing Recent Concerns About BetterSEQTA+</strong><br>
We appreciate the feedback we've received from several schools regarding BetterSEQTA+. Transparency and trust are core to our mission, and we want to address these concerns directly.
</p>
<p>
<strong>Our Commitment to Privacy:</strong><br>
<span style="display: block; margin-left: 1em;">
We do not collect, store, or share any personal information<br>
All data processing happens locally on your device<br>
Our code is open source and available for review
</span>
</p>
<p>
<strong>What We're Doing:</strong><br>
We're willing to actively work with school administrators to ensure BetterSEQTA+ meets both student needs and institutional requirements. If your school has specific concerns, we encourage them to contact us at <a href="mailto:betterseqta.plus@gmail.com" style="color: inherit; text-decoration: underline;">betterseqta.plus@gmail.com</a> or via github at <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">github.com/BetterSEQTA/BetterSEQTA-Plus</a>.
</p>
<p>
For complete details about our privacy practices, visit our <a href="https://betterseqta.org/privacy" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">privacy policy</a> or click the shield icon in settings.
</p>
</div>
`).firstChild as HTMLElement;
settingsState.privacyStatementLastUpdated = "2025-12-20";
settingsState.privacyStatementShown = true;
openPopup({
header,
content: [text],
});
}
@@ -0,0 +1,48 @@
import stringToHTML from "../stringToHTML";
import { openPopup } from "./PopupManager";
export function OpenPrivacyStatement() {
const header = stringToHTML(
/* html */
`<div class="whatsnewHeader">
<h1>Privacy Statement</h1>
<p>Our commitment to your privacy</p>
</div>`,
).firstChild as HTMLElement;
const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer" style="overflow-y: auto; max-height: 60vh;">
<h2 style="margin-top: 0;">Privacy Policy</h2>
<p>At BetterSEQTA+, we take your privacy seriously. We want to be completely transparent about how we handle your data.</p>
<h3>Data Collection</h3>
<p><strong>We never collect any information from you.</strong> BetterSEQTA+ is designed to work entirely on your device. All processing happens locally in your browser, and we do not send any data to external servers.</p>
<h3>What We Don't Do</h3>
<ul style="text-align: left; margin: 10px 0;">
<li>We do not track your browsing activity</li>
<li>We do not collect personal information</li>
<li>We do not store your SEQTA credentials</li>
<li>We do not send data to third-party services</li>
<li>We do not use analytics or tracking cookies</li>
</ul>
<h3>Local Storage</h3>
<p>BetterSEQTA+ uses your browser's local storage to save your preferences and settings. This data remains on your device and is never transmitted anywhere. You can clear this data at any time through your browser's settings.</p>
<h3>Open Source</h3>
<p>BetterSEQTA+ is an open-source project. You can review our code on <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" style="color: inherit; text-decoration: underline;">GitHub</a> to verify our privacy practices. We believe in transparency and encourage you to inspect the code yourself.</p>
<h3>Our Commitment</h3>
<p>We are committed to providing the best features possible while respecting your privacy. We understand that schools and students have concerns about data privacy, and we want to assure you that BetterSEQTA+ is designed with privacy as a core principle.</p>
<p style="margin-top: 20px; font-weight: bold;">If you have any questions or concerns about our privacy practices, please reach out to us through our <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" style="color: inherit; text-decoration: underline;">GitHub repository</a>.</p>
</div>
`).firstChild as HTMLElement;
openPopup({
header,
content: [text],
});
}
@@ -1,48 +1,22 @@
import { settingsState } from "./listeners/SettingsState"; import stringToHTML from "../stringToHTML";
import { animate, stagger } from "motion";
import stringToHTML from "./stringToHTML";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import kofi from "@/resources/kofi.png?base64"; import kofi from "@/resources/kofi.png?base64";
import { openPopup } from "./PopupManager";
export async function DeleteWhatsNew() {
const bkelement = document.getElementById("whatsnewbk");
const popup = document.getElementsByClassName("whatsnewContainer")[0];
if (!settingsState.animations) {
bkelement?.remove();
return;
}
animate(
[popup, bkelement!],
{ opacity: [1, 0], scale: [1, 0] },
{ ease: [0.22, 0.03, 0.26, 1] },
).then(() => {
bkelement?.remove();
});
}
export function OpenWhatsNewPopup() { export function OpenWhatsNewPopup() {
const background = document.createElement("div"); const header = stringToHTML(
background.id = "whatsnewbk";
background.classList.add("whatsnewBackground");
const container = document.createElement("div");
container.classList.add("whatsnewContainer");
var header: any = stringToHTML(
/* html */ /* html */
`<div class="whatsnewHeader"> `<div class="whatsnewHeader">
<h1>What's New</h1> <h1>What's New</h1>
<p>BetterSEQTA+ V${browser.runtime.getManifest().version}</p> <p>BetterSEQTA+ V${browser.runtime.getManifest().version}</p>
</div>`, </div>`,
).firstChild; ).firstChild as HTMLElement;
let imagecont = document.createElement("div"); const imageContainer = document.createElement("div");
imagecont.classList.add("whatsnewImgContainer"); imageContainer.classList.add("whatsnewImgContainer");
let video = document.createElement("video"); const video = document.createElement("video");
let source = document.createElement("source"); const source = document.createElement("source");
source.setAttribute( source.setAttribute(
"src", "src",
@@ -53,19 +27,50 @@ export function OpenWhatsNewPopup() {
video.loop = true; video.loop = true;
video.appendChild(source); video.appendChild(source);
video.classList.add("whatsnewImg"); video.classList.add("whatsnewImg");
imagecont.appendChild(video); imageContainer.appendChild(video);
/* let whatsnewimg = document.createElement("img"); const text = stringToHTML(/* html */ `
//whatsnewimg.src = "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/update-image.webp"; <div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
whatsnewimg.src = browser.runtime.getURL('../../resources/update-image.webp');
whatsnewimg.classList.add("whatsnewImg");
imagecont.appendChild(whatsnewimg); */
let textcontainer = document.createElement("div"); <h1>3.4.14 - Search & Assessments Update</h1>
textcontainer.classList.add("whatsnewTextContainer"); <li>Global Search improvements: indexing progress, more accurate results, and now includes past assessments/assignments</li>
<li>Assessment Averages now parse weightings when possible for more accurate subject averages</li>
<li>Added weight labels to assessment items (including proper handling of 0% and missing weights)</li>
<li>Fixed homepage tutor lesson colours and assessments/courses visibility issues</li>
<li>Fixed upcoming lessons tutorial room not displaying</li>
<li>Fixed favicon not showing / race condition issues</li>
<li>Other minor styling and stability improvements</li>
<h1>3.4.13 - Bug Fixes & Styling Improvements</h1>
<li>Fixed house/year box hard failing when house_colour does not exist</li>
<li>Fixed message of the day being unreadable in light mode</li>
<li>Fixed global font styling issues due to SEQTA updates</li>
<li>Fixed styling issues with title bar and other elements</li>
<li>Other minor bug fixes and improvements</li>
<h1>3.4.12 - Privacy Updates & Bug Fixes</h1>
<li>Added privacy statement</li>
<li>Added disclaimer modal to assessment averages switch</li>
<li>Improved popup management system</li>
<li>Other minor bug fixes and improvements</li>
<h1>3.4.11 - New Features & Bug Fixes</h1>
<li>Added Background Music plugin</li>
<li>Added empty state for assessments on homepage</li>
<li>Added Colour Picker hex/rgba controls</li>
<li>Fixed custom shortcuts positioning (moved above regular shortcuts)</li>
<li>Fixed Go to popup not scrolling properly</li>
<li>Made theme edit mode more plain</li>
<li>Other minor bug fixes and improvements</li>
<h1>3.4.10 - Minor bug fixes</h1>
<li>Fixed UI file styling incorrectly applying to documents</li>
<li>Fixed missing styles in global search</li>
<li>Added icons for image files in file viewer</li>
<li>Added rounded corners when dragging calendar events</li>
<li>Improved performance of element scanning</li>
<li>Other minor improvements</li>
let text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: scroll;">
<h1>3.4.9 - Bug Fixes and Performance Improvements</h1> <h1>3.4.9 - Bug Fixes and Performance Improvements</h1>
<li>Fixed performance issues with large notices on the homepage</li> <li>Fixed performance issues with large notices on the homepage</li>
<li>Improved performance when global search is disabled</li> <li>Improved performance when global search is disabled</li>
@@ -273,9 +278,9 @@ export function OpenWhatsNewPopup() {
<h1>Create Custom Shortcuts</h1> <h1>Create Custom Shortcuts</h1>
<li>Found in the BetterSEQTA+ Settings menu, custom shortcuts can now be created with a name and URL of your choice.</li> <li>Found in the BetterSEQTA+ Settings menu, custom shortcuts can now be created with a name and URL of your choice.</li>
</div> </div>
`).firstChild; `).firstChild as HTMLElement;
let footer = stringToHTML(/* html */ ` const footer = stringToHTML(/* html */ `
<div class="whatsnewFooter"> <div class="whatsnewFooter">
<div> <div>
Resources and Feedback: Resources and Feedback:
@@ -308,58 +313,10 @@ export function OpenWhatsNewPopup() {
</a> </a>
</div> </div>
</div> </div>
`).firstChild; `).firstChild as HTMLElement;
let exitbutton = document.createElement("div"); openPopup({
exitbutton.id = "whatsnewclosebutton"; header,
content: [imageContainer, text, footer],
container.append(header);
container.append(imagecont);
container.append(textcontainer);
container.append(text as ChildNode);
container.append(footer as ChildNode);
container.append(exitbutton);
background.append(container);
document.getElementById("container")!.append(background);
let bkelement = document.getElementById("whatsnewbk");
let popup = document.getElementsByClassName("whatsnewContainer")[0];
if (settingsState.animations) {
animate(
[popup, bkelement as HTMLElement],
{ scale: [0, 1] },
{
type: "spring",
stiffness: 220,
damping: 18,
},
);
animate(
".whatsnewTextContainer *",
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.05, { startDelay: 0.1 }),
duration: 0.5,
ease: [0.22, 0.03, 0.26, 1],
},
);
}
delete settingsState.justupdated;
bkelement!.addEventListener("click", function (event) {
// Check if the click event originated from the element itself and not any of its children
if (event.target === bkelement) {
DeleteWhatsNew();
}
});
var closeelement = document.getElementById("whatsnewclosebutton");
closeelement!.addEventListener("click", function () {
DeleteWhatsNew();
}); });
} }
+98
View File
@@ -0,0 +1,98 @@
import { settingsState } from "../listeners/SettingsState";
import { animate as motionAnimate, stagger } from "motion";
type AnimationTarget = string | Element | Element[] | NodeList | null;
let isClosing = false;
export async function closePopup() {
if (isClosing) return;
isClosing = true;
const background = document.getElementById("whatsnewbk");
const popup = document.getElementsByClassName("whatsnewContainer")[0] as
| HTMLElement
| undefined;
if (!background || !popup) {
isClosing = false;
return;
}
if (!settingsState.animations) {
background.remove();
isClosing = false;
return;
}
await (motionAnimate as any)(
[popup, background],
{ opacity: [1, 0], scale: [1, 0.95] },
{ duration: 0.25, easing: [0.22, 0.03, 0.26, 1] },
);
background.remove();
isClosing = false;
}
interface OpenPopupOptions {
header?: Node | null;
content?: (Node | null | undefined)[];
animateSelector?: AnimationTarget;
}
export function openPopup({
header,
content = [],
animateSelector = ".whatsnewTextContainer *",
}: OpenPopupOptions = {}) {
const background = document.createElement("div");
background.id = "whatsnewbk";
background.classList.add("whatsnewBackground");
const container = document.createElement("div");
container.classList.add("whatsnewContainer");
if (header) container.append(header);
for (const node of content) if (node) container.append(node);
const closeButton = document.createElement("div");
closeButton.id = "whatsnewclosebutton";
container.append(closeButton);
background.append(container);
document.getElementById("container")!.append(background);
if (settingsState.animations) {
(motionAnimate as any)(
[container, background],
{ scale: [0, 1] },
{ type: "spring", stiffness: 220, damping: 18 },
);
if (animateSelector) {
const targets =
typeof animateSelector === "string"
? document.querySelectorAll(animateSelector)
: animateSelector;
(motionAnimate as any)(
targets!,
{ opacity: [0, 1], y: [10, 0] },
{
delay: stagger(0.05, { startDelay: 0.1 }),
duration: 0.5,
easing: [0.22, 0.03, 0.26, 1],
},
);
}
}
delete settingsState.justupdated;
background.addEventListener("click", (event) => {
if (event.target === background) void closePopup();
});
closeButton.addEventListener("click", () => void closePopup());
}
+26
View File
@@ -0,0 +1,26 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { addShortcuts } from "@/seqta/utils/Adders/AddShortcuts";
import { CreateCustomShortcutDiv } from "@/seqta/utils/CreateEnable/CreateCustomShortcutDiv";
export function renderShortcuts() {
const container = document.getElementById("shortcuts");
if (!container) return;
container.innerHTML = "";
try {
addShortcuts(settingsState.shortcuts || []);
} catch (err: any) {
console.error("[BetterSEQTA+] Error adding built-in shortcuts:", err?.message || err);
}
const custom = settingsState.customshortcuts || [];
for (const element of custom) {
try {
CreateCustomShortcutDiv(element);
} catch (err: any) {
console.error("[BetterSEQTA+] Error adding custom shortcut:", element?.name, err?.message || err);
}
}
}
+16 -1
View File
@@ -18,10 +18,25 @@ export async function SendNewsPage() {
const main = document.getElementById("main"); const main = document.getElementById("main");
main!.innerHTML = ""; main!.innerHTML = "";
const displayCountry = (() => {
switch (settingsState.newsSource?.toLowerCase()) {
case "usa": return "the USA";
case "uk": return "the UK";
case "netherlands": return "the Netherlands";
default:
return settingsState.newsSource
? settingsState.newsSource
.split("_")
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
: "Australia";
}
})();
const html = stringToHTML(/* html */ ` const html = stringToHTML(/* html */ `
<div class="home-root"> <div class="home-root">
<div class="home-container" id="news-container"> <div class="home-container" id="news-container">
<h1 class="border">Latest Headlines in ${settingsState.newsSource ? settingsState.newsSource.charAt(0).toUpperCase() + settingsState.newsSource.slice(1) : "Australia"}</h1> <h1 class="border">Latest Headlines in ${displayCountry}</h1>
</div> </div>
</div>`); </div>`);
+25 -1
View File
@@ -57,13 +57,37 @@ class EventManager {
return { unregister }; return { unregister };
} }
private buildSelector(options: EventListenerOptions): string | null {
if (options.textContent || options.customCheck) return null;
let selector = options.elementType || "";
if (options.id) {
selector += `#${CSS.escape(options.id)}`;
}
if (options.className) {
selector += `.${CSS.escape(options.className)}`;
}
return selector.trim() || null;
}
private async scanExistingElements( private async scanExistingElements(
options: EventListenerOptions, options: EventListenerOptions,
callback: (element: Element) => void, callback: (element: Element) => void,
): Promise<void> { ): Promise<void> {
const root = options.parentElement || document.documentElement; const root = options.parentElement || document.documentElement;
const elements = Array.from(root.getElementsByTagName("*")); const selector = this.buildSelector(options);
let elements: Element[] = [];
if (selector) {
elements = Array.from(root.querySelectorAll(selector));
if (selector && root.matches && root.matches(selector)) {
elements.unshift(root); elements.unshift(root);
}
} else {
elements = Array.from(root.getElementsByTagName("*"));
elements.unshift(root);
}
for (let i = 0; i < elements.length; i += this.chunkSize) { for (let i = 0; i < elements.length; i += this.chunkSize) {
const chunk = elements.slice(i, i + this.chunkSize); const chunk = elements.slice(i, i + this.chunkSize);
+1 -9
View File
@@ -30,18 +30,10 @@ class StorageManager {
set: (target, prop: keyof SettingsState, value) => { set: (target, prop: keyof SettingsState, value) => {
const oldValue = target.data[prop]; const oldValue = target.data[prop];
// Only save if the value actually changed // Only save if the reference actually changed
if (oldValue !== value) { if (oldValue !== value) {
Reflect.set(target.data, prop, value); Reflect.set(target.data, prop, value);
target.saveToStorage(); target.saveToStorage();
// Notify listeners immediately for responsiveness
const listeners = target.listeners.get(prop as string);
if (listeners) {
for (const listener of listeners) {
listener(value, oldValue);
}
}
} }
return true; return true;
}, },
+4 -46
View File
@@ -1,10 +1,9 @@
import { settingsState } from "./SettingsState"; import { settingsState } from "./SettingsState";
import { updateAllColors } from "@/seqta/ui/colors/Manager"; import { updateAllColors } from "@/seqta/ui/colors/Manager";
import { addShortcuts } from "@/seqta/utils/Adders/AddShortcuts"; // Shortcuts rendering
import { CreateCustomShortcutDiv } from "@/seqta/utils/CreateEnable/CreateCustomShortcutDiv"; import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments"; import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
import { RemoveShortcutDiv } from "@/seqta/utils/DisableRemove/RemoveShortcutDiv";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import type { CustomShortcut } from "@/types/storage"; import type { CustomShortcut } from "@/types/storage";
@@ -47,21 +46,7 @@ export class StorageChangeHandler {
oldValue: CustomShortcut[], oldValue: CustomShortcut[],
) { ) {
if (!newValue || !oldValue) return; if (!newValue || !oldValue) return;
renderShortcuts();
if (newValue.length > oldValue.length) {
// New shortcut added - add the last one
CreateCustomShortcutDiv(newValue[oldValue.length]);
} else if (newValue.length < oldValue.length) {
// Shortcut removed - find which one was removed
const newSet = new Set(newValue.map(item => JSON.stringify(item)));
const removedElement = oldValue.find(
(oldItem) => !newSet.has(JSON.stringify(oldItem))
);
if (removedElement) {
RemoveShortcutDiv([removedElement]);
}
}
} }
private handleShortcutsChange( private handleShortcutsChange(
@@ -69,34 +54,7 @@ export class StorageChangeHandler {
oldValue: { enabled: boolean; name: string }[], oldValue: { enabled: boolean; name: string }[],
) { ) {
if (!newValue || !oldValue) return; if (!newValue || !oldValue) return;
renderShortcuts();
// Create map for faster lookup
const oldMap = new Map(oldValue.map(item => [item.name, item.enabled]));
const addedShortcuts: { enabled: boolean; name: string }[] = [];
const removedShortcuts: { enabled: boolean; name: string }[] = [];
// Check for changes in shortcuts
for (const newItem of newValue) {
const oldEnabled = oldMap.get(newItem.name);
// Newly enabled shortcuts
if (newItem.enabled && (oldEnabled === undefined || !oldEnabled)) {
addedShortcuts.push(newItem);
}
// Newly disabled shortcuts
if (!newItem.enabled && oldEnabled === true) {
removedShortcuts.push(newItem);
}
}
if (addedShortcuts.length > 0) {
addShortcuts(addedShortcuts);
}
if (removedShortcuts.length > 0) {
RemoveShortcutDiv(removedShortcuts);
}
} }
private handleTransparencyEffectsChange(newValue: boolean) { private handleTransparencyEffectsChange(newValue: boolean) {
+2
View File
@@ -30,6 +30,8 @@ export interface SettingsState {
subjectfilters: Record<string, any>; subjectfilters: Record<string, any>;
transparencyEffects: boolean; transparencyEffects: boolean;
justupdated?: boolean; justupdated?: boolean;
privacyStatementShown?: boolean;
privacyStatementLastUpdated?: string;
timeFormat?: string; timeFormat?: string;
animations: boolean; animations: boolean;
defaultPage: string; defaultPage: string;
+4
View File
@@ -84,6 +84,10 @@ export default defineConfig(({ command }) => ({
settings: join(__dirname, "src", "interface", "index.html"), settings: join(__dirname, "src", "interface", "index.html"),
pageState: join(__dirname, "src", "pageState.js"), pageState: join(__dirname, "src", "pageState.js"),
}, },
onwarn(warning, warn) {
if (warning.code === "FILE_NAME_CONFLICT") return;
warn(warning);
},
}, },
}, },
})); }));