Compare commits

...

238 Commits

Author SHA1 Message Date
SethBurkart123 b503363d64 fix: assessment tooltips on homepage 2025-06-25 09:46:35 +10:00
SethBurkart123 b69d5f47fc feat: update changelog 2025-06-25 09:17:23 +10:00
SethBurkart123 404d3c02f3 feat: new update video 2025-06-25 09:13:19 +10:00
SethBurkart123 964a026e7a feat: improved sensitive content hider (dev) 2025-06-23 12:33:37 +10:00
SethBurkart123 c7d9e1d955 feat: remove rounded corners on custom shortcut icons 2025-06-23 11:58:18 +10:00
SethBurkart123 e305b70035 chore: update changelog 2025-06-20 10:01:56 +10:00
SethBurkart123 c4140a2a9d feat: homepage styling improvements 2025-06-18 14:14:48 +10:00
SethBurkart123 566f326dce feat: modern and animated notices on homepage 2025-06-18 12:49:35 +10:00
SethBurkart123 c9fe4e0e1c feat: add loading animations to lessons on home page 2025-06-18 10:08:01 +10:00
SethBurkart123 fbd8d9e9e8 fix: Home page todays lessons not properly changing days #310 2025-06-18 09:41:21 +10:00
SethBurkart123 3f0d3f87fe fix: search bar searches being delayed 2025-06-13 10:59:15 +10:00
SethBurkart123 2292585e60 feat: add confetti and render overview with svelte 2025-06-13 09:06:56 +10:00
SethBurkart123 bb7c27dfea feat: hide empty overview columns + transparency effects support 2025-06-12 17:12:26 +10:00
SethBurkart123 8c1df8f829 fix (perf): background flickering on page load #305 2025-06-12 17:02:26 +10:00
SethBurkart123 7462e6ab5d fix: builds failing 2025-06-12 16:59:21 +10:00
SethBurkart123 66ff6e3468 fix: standalone settingsState not loading 2025-06-12 16:42:01 +10:00
SethBurkart123 8bd9b1dae7 perf: reduce timetable resource consuption and update notification collector 2025-06-12 16:24:28 +10:00
SethBurkart123 d377329bf9 chore: remove unused code 2025-06-12 15:15:34 +10:00
SethBurkart123 ec38502747 perf: only load vectorWorker when required 2025-06-12 15:15:07 +10:00
SethBurkart123 57b4daa9b7 feat: global search bug fixes and performance improvements 2025-06-12 14:45:36 +10:00
Seth Burkart fa37fe9d21 Merge pull request #307 from BlastedMeteor44/main
Added easter egg to AddBetterSEQTAElements.ts
2025-06-08 10:24:01 +10:00
SethBurkart123 ccb4354b26 feat: assessments overview mark as complete 2025-06-07 23:49:28 +10:00
SethBurkart123 841426d7ec fix: delayed flicker in 12 hour times on timetable 2025-06-07 18:24:52 +10:00
SethBurkart123 b9f0675c4f fix: oversized empty lessons image 2025-06-07 18:21:14 +10:00
BlastedMeteor44 8ff5fd8d2f Added easter egg to AddBetterSEQTAElements.ts
never gonna give you up shows if you toggle light/dark mode 10 times
2025-06-06 17:16:42 +08:00
SethBurkart123 ff01b44ead fix: flashing news sidebar button #303 2025-06-06 12:40:24 +10:00
SethBurkart123 72f7eeb935 fix: 12 hour time in timetable not applying correctly #303 2025-06-06 12:30:36 +10:00
SethBurkart123 a009f40ac2 feat: submitted column + bug fixes 2025-06-06 12:21:46 +10:00
SethBurkart123 c202af9688 feat: horizontal scrolling on timetable in homepage 2025-06-06 12:12:51 +10:00
SethBurkart123 7a91550de5 feat: cleanup assessmentsOverview 2025-06-06 11:52:24 +10:00
SethBurkart123 c4dc4b58b8 feat: assessment overview get letter grades from Assessment Averages plugin 2025-06-06 10:36:19 +10:00
SethBurkart123 d59802d4c3 fix: settings popup not appearing on disabled pages 2025-06-06 10:27:00 +10:00
SethBurkart123 074c2ff4bb feat: performance and visual improvements 2025-06-06 10:16:42 +10:00
Seth Burkart e94008efba Merge pull request #304 from Jones8683/main
Fix profile pic and add light mode styling
2025-06-05 23:18:03 +10:00
SethBurkart123 972783eb13 feat: assessments kanban overview 2025-06-05 23:14:34 +10:00
Jones 9af3ca4516 Merge branch 'BetterSEQTA:main' into main 2025-06-05 14:08:03 +09:30
Jones8683 60b4438552 remove e 2025-06-05 14:07:29 +09:30
Jones8683 d3dadad982 Fix profile pic and add light mode styling 2025-06-05 14:07:04 +09:30
SethBurkart123 bf01c0ca7b feat: custom shortcut icons #258 2025-06-04 21:42:32 +10:00
SethBurkart123 3821034a5c feat: profile picture add border 2025-06-04 17:13:12 +10:00
SethBurkart123 c218f184c0 feat: performance improvements to profile picture plugin 2025-06-04 16:14:54 +10:00
Seth Burkart 4b67736da2 Merge pull request #291 from AdenMGB/main
Add comments to code
2025-06-04 16:12:12 +10:00
Seth Burkart 8e107800f1 Merge branch 'main' into main 2025-06-04 16:12:05 +10:00
Seth Burkart f44d28c2b8 Merge pull request #301 from Jones8683/patch-1
Update injected.scss - Add rounded corners for music lessons/tutorial…
2025-06-04 16:10:36 +10:00
Seth Burkart 058cbb0bfa Merge pull request #300 from Jones8683/main
Remove "here" button and replace with contrib.rocks graph
2025-06-04 16:10:08 +10:00
SethBurkart123 9b13e7571a feat: profile picture plugin #256 2025-06-04 16:08:01 +10:00
SethBurkart123 6c12f5cf00 feat: apply 12 hour time to timetable #280 2025-06-04 15:44:51 +10:00
Jones df385775d9 Update injected.scss - Add rounded corners for music lessons/tutorials and custom events 2025-06-04 14:26:45 +09:30
Jones dce112d129 Merge branch 'BetterSEQTA:main' into main 2025-06-04 14:12:50 +09:30
SethBurkart123 f62d712549 fix: sidebar icons revert back to old style after reload #282 2025-06-04 12:02:00 +10:00
Jones8683 148559556a realign 'buy me a cofffeee! 2025-06-04 08:30:43 +09:30
Jones8683 0c2bdf36cf Align links for parity with openaboutpage.ts 2025-06-04 08:27:53 +09:30
Jones ee002991af Update injected.scss
remove CSS for old "here" button
2025-06-04 07:43:45 +09:30
Jones8683 c53de6ed8d Adjust alignment 2025-06-03 19:11:31 +09:30
Jones8683 98560af0a3 align links properly 2025-06-03 19:10:35 +09:30
Jones8683 f9fa334e40 fix 2025-06-03 18:58:02 +09:30
Jones8683 a7e250a86d commit 2025-06-03 18:57:00 +09:30
Jones8683 b3db85c565 Neaten pfps 2025-06-03 18:42:08 +09:30
Jones8683 c9d9611e3e Re-align 'All Contributers' to left 2025-06-03 18:32:51 +09:30
Jones Jankovic a1f480855e add contributer graph to openaboutpage.ts 2025-06-03 14:51:10 +09:30
Seth Burkart 9c3c63e497 Merge pull request #293 from Jones8683/main
Restyle "here" button in openaboutpage.ts
2025-06-03 13:35:37 +10:00
Jones 418c3c010e Delete Push Procedure.txt idk why this was here it was an acciadent 2025-06-02 19:13:00 +09:30
Jones Jankovic 4c55cf4331 Change name of class selector 2025-06-02 19:10:52 +09:30
Alphons Joseph 0b93223b84 capitalise here 2025-06-02 17:00:01 +08:00
Jones 84ab19eee7 Update injected.scss - fix indents 2025-06-01 10:13:24 +09:30
Jones 94b479519a Merge branch 'BetterSEQTA:main' into main 2025-06-01 10:11:34 +09:30
Seth Burkart af68eb5534 Merge pull request #298 from Jones8683/patch-1
Update README.md - Fix indents
2025-06-01 05:22:16 +10:00
Jones 94e527c73d Merge branch 'BetterSEQTA:main' into main 2025-05-31 19:45:22 +09:30
Jones 3b9f8124cf Update README.md - Fix indents 2025-05-31 19:39:03 +09:30
Seth Burkart 4c69ba7794 Merge pull request #295 from Jones8683/patch-1
Fix numbering in instructions
2025-05-31 19:50:25 +10:00
Jones 6e43be2a18 Update README.md - Fix numbering for instructions 2025-05-30 19:49:17 +09:30
Jones 37a13cba07 Update README.md - Fix numbering for instructions 2025-05-30 19:47:31 +09:30
Jones a855fbe9ec Update README.md - Fix spelling
Somehow "preferred" was misspelled three whole times
2025-05-30 18:45:26 +09:30
Jones8683 1206fb655d Update injected.scss - add styling for openaboutpage.ts 2025-05-30 12:33:40 +09:30
Jones8683 1ae9bd0652 Add about-here-link styles to injected.scss 2025-05-30 12:28:47 +09:30
Jones 280163111e Update OpenAboutPage.ts - Move css to injected.scss 2025-05-30 12:16:53 +09:30
Jones 59e195d2aa Update OpenAboutPage.ts - Fix coloring 2025-05-30 12:10:36 +09:30
Jones 37d62cf2a8 Neaten "here" button in openaboutpage.ts 2025-05-30 12:08:11 +09:30
Jones8683 6e5c3b4733 Neaten 'here' button 2025-05-30 12:05:23 +09:30
Seth Burkart ac9761286c Merge pull request #292 from Jones8683/main
Increase chunk size limit warning so that it doesn't appear
2025-05-30 11:59:51 +10:00
Jones d7fc7582d1 Remove chunk size warning 2025-05-30 09:57:22 +09:30
Aden Lindsay 425860c7cc Merge pull request #3 from AdenMGB/add-jsdoc-comments
Add jsdoc comments
2025-05-29 22:27:08 +09:30
google-labs-jules[bot] 69ac159bad I've added JSDoc comments to various files in the lib directory.
This change introduces JSDoc-style comments to several TypeScript and JavaScript files within the `lib` directory. These files primarily consist of Vite plugins, build scripts, and type definitions.

Comments were added or improved in:
- `lib/base64loader.ts`: I documented the Vite plugin for loading files as base64 data URLs.
- `lib/createManifest.ts`: I enhanced existing comments for functions that create extension manifest objects.
- `lib/inlineWorker.ts`: I documented the Vite plugin for bundling and inlining web worker scripts.
- `lib/utils.ts`: I added comments to utility types and the `createEnum` function, including a note on its type signature vs. runtime behavior.
- `lib/closePlugin.ts`: I documented the Vite plugin for handling build completion and exiting the process.
- `lib/publish.js`: I added comments to functions within the command-line script used for publishing the extension.
- `lib/touchGlobalCSS.ts`: I documented the Vite plugin for improving HMR reliability for global CSS files.
- `lib/types.ts`: I added comments to various type definitions, interfaces, and enum-like objects related to manifests, build configurations, and supported technologies.
2025-05-29 12:55:09 +00:00
google-labs-jules[bot] 4c93bcd0d7 I've added JSDoc comments to interface hooks and utils.
This change introduces JSDoc-style comments to several TypeScript files within your `src/interface` directory to improve code understanding and maintainability, focusing on hooks and utility functions.

- `src/interface/hooks/BackgroundDataLoader.ts`: I added comments to all exported functions and the `BackgroundDB` interface, detailing IndexedDB interactions for background image storage.
- `src/interface/hooks/SettingsPopup.ts`: I documented the public methods of the `SettingsPopup` singleton, which handles event notifications for settings popup closures.
- `src/interface/utils/themeImageHandlers.ts`: I added comments to all exported functions, explaining their roles in managing images within custom themes (uploading, removing, etc.).
- `src/interface/hooks/BackgroundUpdates.ts`: I documented the `BackgroundUpdates` singleton class and its methods, used for broadcasting generic background update events.
- `src/interface/hooks/ThemeUpdates.ts`: I documented the `ThemeUpdates` singleton class and its methods, responsible for broadcasting theme-related update events.
2025-05-29 12:41:31 +00:00
Aden Lindsay c596e65449 Merge pull request #2 from AdenMGB/add-jsdoc-comments
added JSDoc comments to background, plugin core, and settings fi…
2025-05-29 21:58:45 +09:30
google-labs-jules[bot] afdbfe3190 I've added JSDoc comments to background, plugin core, and settings files.
This change introduces JSDoc-style comments to several key areas of the extension to improve your code's understanding and maintainability:

- `src/background/news.ts`: I added comments to `fetchNews`, `fetchAustraliaNews`, and `rssFeedsByCountry` to explain news fetching logic.
- `src/plugins/core/manager.ts`: I added comprehensive JSDoc comments to the `PluginManager` class and its methods, detailing its role in the plugin lifecycle.
- `src/plugins/core/createAPI.ts`: I documented `createPluginAPI` (which creates the main API for plugins) and `createSettingsAPI` (responsible for plugin settings management, initially misidentified as `createPluginSettings`).
- `src/plugins/core/settingsHelpers.ts`: I added comments to functions that define the structure of plugin settings (e.g., `numberSetting`, `stringSetting`, `defineSettings`, `Setting` decorator), clarifying their definition-time role.
2025-05-29 12:27:47 +00:00
Aden Lindsay f1137763a6 Merge pull request #1 from AdenMGB/add-jsdoc-comments
Add JSDoc comments to various utility functions and core files.
2025-05-29 21:52:12 +09:30
google-labs-jules[bot] 074e73b0fd Add JSDoc comments to various utility functions and core files.
This change adds JSDoc-style comments to several functions and classes across the codebase to improve readability and maintainability.

Comments were added to:
- `src/SEQTA.ts`: Explained the `init()` function.
- `src/seqta/utils/waitForElm.ts`: Detailed the `waitForElm()` function, its parameters, and behavior.
- `src/seqta/utils/stringToHTML.ts`: Clarified the `stringToHTML()` function, including its sanitization and styling features.
- `src/seqta/utils/delay.ts`: Added a brief explanation for the `delay()` utility.
- `src/seqta/utils/mutex.ts`: Documented the `Mutex` class and its `acquire` method (renamed from `lock`), explaining its asynchronous locking mechanism and the role of the returned unlock function.
2025-05-29 12:19:57 +00:00
Seth Burkart fc4b121d30 Merge pull request #262 from ar-cyber/patch-27
add a type to bug and fr reports
2025-05-29 13:48:17 +10:00
Seth Burkart 55c48cbe5c Merge pull request #286 from BlastedMeteor44/main
OpenAboutPages.ts update to include all contributors
2025-05-29 13:47:56 +10:00
Seth Burkart 72b18dfb7d Merge branch 'main' into main 2025-05-29 13:47:32 +10:00
Seth Burkart d0cb352e74 Merge pull request #285 from Jones8683/main
Remove un-needed scrollbar in openaboutpage.ts
2025-05-29 13:46:34 +10:00
BlastedMeteor44 8fee6ddb76 OpenAboutPages.ts update to include all contributors 2025-05-28 10:42:33 +08:00
Jones 599f20e6d0 Add oxford comma 2025-05-28 10:00:22 +09:30
Jones 07af33eb78 Update OpenAboutPage.ts - Remove scrollbar to nowhere 2025-05-28 08:33:26 +09:30
Jones f03d25f918 Update OpenAboutPage.ts - Remove scrollbar to nowhere 2025-05-28 08:30:10 +09:30
SethBurkart123 1adb18ca42 feat: cleanup and changelog for update 2025-05-28 07:41:36 +10:00
SethBurkart123 134dfcb5a2 feat: remove background migration 2025-05-26 22:46:13 +10:00
SethBurkart123 c2d701266a feat: auto lesson navigation command 2025-05-26 22:43:38 +10:00
SethBurkart123 34024d70c2 feat: performance and visual improvements 2025-05-26 21:45:17 +10:00
SethBurkart123 70a1ebf881 feat: improved calculator 2025-05-26 20:40:55 +10:00
SethBurkart123 731ce42e74 feat: improved commands and interface for globalsearch 2025-05-26 17:19:06 +10:00
SethBurkart123 2749e07a1b feat: compose message command 2025-05-26 13:17:57 +10:00
SethBurkart123 0bed8b875b feat: visual improvements to search 2025-05-26 12:27:42 +10:00
SethBurkart123 35c005f347 fix: search button in incorrect placement 2025-05-26 12:05:34 +10:00
SethBurkart123 a251827c4b fix: themes always locking after reload 2025-05-26 11:52:59 +10:00
SethBurkart123 854c6ea826 fix: indexer saving infinite items, other improvements 2025-05-25 22:28:40 +10:00
SethBurkart123 cefeac95ea feat: major indexing performance improvements + visual fixes 2025-05-25 20:11:45 +10:00
SethBurkart123 e09eeccfee style: visual tweaks to settings page 2025-05-25 18:30:37 +10:00
SethBurkart123 991f80d316 feat: improved hotkey support and controls 2025-05-25 18:15:06 +10:00
SethBurkart123 f66340cb63 feat: add beta tag to global search plugin 2025-05-25 10:21:24 +10:00
SethBurkart123 fc288bdf01 fix: incorrect imports 2025-05-25 10:15:24 +10:00
SethBurkart123 244e667d90 fix: themes always forcing current mode 2025-05-23 12:34:17 +10:00
SethBurkart123 8adba647d8 feat: use web transitions api for themes 2025-05-23 12:30:43 +10:00
SethBurkart123 da3e11e208 fix: requiring reload on install to function 2025-05-22 23:15:19 +10:00
SethBurkart123 843a0a4c7a style: visual tweaks to courses page 2025-05-22 21:52:41 +10:00
SethBurkart123 b339745697 feat: beautiful modern visual tweaks 2025-05-22 19:06:39 +10:00
SethBurkart123 efdd03ce8e feat: make message items in search open the message 2025-05-22 14:49:13 +10:00
SethBurkart123 6846d945f2 fix: adjust boosting scores to work better 2025-05-21 10:18:16 +10:00
SethBurkart123 bff48f0397 feat: boosted active subjects + visual indication of inactive subjects 2025-05-21 00:01:36 +10:00
SethBurkart123 d8512e44cf feat: button item + search storage reset 2025-05-20 21:03:55 +10:00
SethBurkart123 25623339f8 feat: safer text highlighting 2025-05-20 20:43:16 +10:00
Seth Burkart 281842ea48 Merge pull request #274 from NIDNHU/patch-8
Update README.md - remove random dot point and update credits
2025-05-14 09:37:07 +10:00
SethBurkart123 eaf8ec51cd fix: incorrect usage and cleanup 2025-05-14 09:32:24 +10:00
Seth Burkart 65921845ec Merge pull request #264 from AdenMGB/main
Subject Searching in Global Search
2025-05-14 09:15:31 +10:00
StroepWafel 68d7861afa Update README.md - remove random dot point and update credits 2025-05-13 13:42:58 +09:30
Seth Burkart 2dcb6db3b5 Merge pull request #268 from NIDNHU/patch-7
Update OpenAboutPage.ts - fix grammar + adjust credits
2025-05-12 12:10:46 +10:00
StroepWafel 50b9218224 Update OpenAboutPage.ts 2025-05-11 19:01:32 +09:30
StroepWafel a4d2743f4c Update OpenAboutPage.ts - fix grammar + adjust credits 2025-05-11 14:24:27 +09:30
Seth Burkart 53074d5534 Merge pull request #267 from NIDNHU/patch-5
Update OpenAboutPage.ts - add code contribution welcome
2025-05-11 14:33:40 +10:00
StroepWafel 64ac9019a3 Update OpenAboutPage.ts - add code contribution welcome
stated that we are always looking for more contributors
2025-05-11 14:00:06 +09:30
AdenMGB 27f357cc82 fix(sorting): re-work sorting system to use api response more effectivly, and some styling improvements 2025-05-11 13:27:58 +09:30
codefactor-io ed767131ad [CodeFactor] Apply fixes 2025-05-09 10:53:51 +00:00
AdenMGB 7499880d9d fix(packages): remove old unused package 2025-05-09 20:18:18 +09:30
AdenMGB 908bf8c759 feat(globalSearch): subject course & assesment indexing and searchabilty 2025-05-09 20:12:22 +09:30
SethBurkart123 297c30dc98 fix: shortcuts relying on displayname not being removed 2025-05-09 10:11:27 +10:00
SethBurkart123 f4711ae3d4 style: fixed viewbox for shortcut links 2025-05-09 10:04:46 +10:00
Seth Burkart 97d3098fa3 Merge pull request #260 from NIDNHU/main
Update links.json - add more sites
2025-05-09 09:51:42 +10:00
StroepWafel 6ffacc83a7 Update AddShortcuts.ts - use DisplayName
made use DisplayName for name with fallback if value is null, empty or invalid
2025-05-08 21:52:09 +09:30
StroepWafel fa41542ec6 Update shortcuts.svelte - use "DisplayName" for displayname
made code use "DisplayName" in object for display name instead of object name, includes fallback in case the object is missing displayname
2025-05-08 21:32:59 +09:30
StroepWafel 7a19074c4f Update links.json - fix referencer viewbox 2025-05-08 21:25:00 +09:30
Alphons Joseph ad93a2eb54 fix Aesthetic bug in sidebar of BetterSEQTA+ #251 2025-05-08 19:41:21 +08:00
StroepWafel b8bc54f967 Update links.json - remove social medias 2025-05-08 08:56:13 +09:30
StroepWafel 11c30226f0 Update links.json - fix names and add displayname field 2025-05-08 08:34:50 +09:30
StroepWafel e0e4ba65c7 Update links.json - fix viewBoxes and names 2025-05-07 21:36:38 +09:30
StroepWafel 2c9d24355e Merge pull request #1 from BetterSEQTA/main
Merge changes for testing PR
2025-05-07 20:40:54 +09:30
SethBurkart123 c206e38ee2 fix: change shortcuts to rely on links list 2025-05-07 21:03:46 +10:00
SethBurkart123 87bf526dc6 feat: update job title 2025-05-07 20:28:39 +10:00
Andrew R 8f7a9b655a Update feature_request.yml 2025-05-07 19:55:48 +09:30
Andrew R 899ba46995 Update feature_request.yml 2025-05-07 19:55:02 +09:30
Andrew R ccb465cc2d add a type to bug and fr reports 2025-05-07 19:54:18 +09:30
StroepWafel 49b9428fbb Update links.json 2025-05-07 13:49:25 +09:30
StroepWafel 6d904ff6f9 Update links.json 2025-05-07 13:37:28 +09:30
StroepWafel 14424b167e Update links.json 2025-05-07 12:15:29 +09:30
StroepWafel da68d9628d Update links.json - add more sites
Added: 
- Deezer
- Google Classroom
- Reddit
- Instagram
- Snapchat
- Harvard referencing Generator
2025-05-07 12:00:57 +09:30
SethBurkart123 79ed998edf style: fix light mode gradients 2025-05-05 23:00:56 +10:00
SethBurkart123 364a5c2f22 style: major interface improvements 2025-05-05 22:58:15 +10:00
SethBurkart123 eeb63b5d1a feat: improved search results 2025-05-05 21:56:50 +10:00
SethBurkart123 9aef4c7204 style: remove "Custom Shortcut" overlay text 2025-05-05 20:31:23 +10:00
SethBurkart123 91035172d2 feat: visual improvements 2025-05-05 20:03:57 +10:00
SethBurkart123 d3d9b45caa feat: forums + improvements 2025-05-05 19:49:19 +10:00
SethBurkart123 0f9f618164 format: run prettify 2025-05-05 18:04:10 +10:00
SethBurkart123 771169348f feat: supporting improved assessments and improved parsing 2025-05-05 17:58:40 +10:00
SethBurkart123 ec42f1bb27 feat: improved job indexing 2025-05-05 17:09:44 +10:00
Seth Burkart cd247cfde4 Merge pull request #259 from NIDNHU/patch-4
fixed a couple of wording issues in FEATURE_REQUEST.yml
2025-05-05 11:08:57 +10:00
StroepWafel 44bf8efd71 fixed a couple of wording issues in FEATURE_REQUEST.yml
some wording made no sense so I fixed it
2025-05-05 09:47:12 +09:30
SethBurkart123 955213d577 fix: indexer not saving vectorized items properly 2025-05-04 12:01:03 +10:00
SethBurkart123 40924b5b33 fix: initial load not loading betterseqta 2025-05-04 07:22:24 +10:00
SethBurkart123 69b6b116a0 feat: update crxjs and remove extra included files 2025-05-04 07:20:45 +10:00
SethBurkart123 63a4bd4211 feat: refresh vector cache on complete 2025-05-03 20:40:39 +10:00
SethBurkart123 6ac54eae4b feat: add default enabled toggle 2025-05-03 20:17:53 +10:00
SethBurkart123 c791998b30 feat: migrate to embeddia (from local dir) 2025-05-03 19:27:18 +10:00
Seth Burkart 8a5100c06f Merge branch 'global-search' into main 2025-05-03 18:58:33 +10:00
Alphons Joseph cf2778951f feat: old scrolling sidebar system 2025-05-03 18:57:31 +10:00
StroepWafel 6fa1af2f68 Update feature_request.yml 2025-05-03 18:57:31 +10:00
StroepWafel b8286b6f22 fix validations indentation 2025-05-03 18:57:31 +10:00
StroepWafel 8466ef7691 fix feature_request.yml 2025-05-03 18:57:31 +10:00
StroepWafel d75959eeb1 fix feature_request.yml 2025-05-03 18:57:31 +10:00
StroepWafel 94a2f4ac34 Update and retype feature_request.md to feature_request.yml 2025-05-03 18:57:31 +10:00
StroepWafel 1647870186 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel b332de52ff Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel daf7ea8e83 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 341087b6a0 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel a0d8e05fd0 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 399f68c547 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 3ddf1d0c4f Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 10a6c458b1 Update bug_report.yml 2025-05-03 18:57:31 +10:00
StroepWafel 33825843b7 Update and retyped bug_report.md to bug_report.yml 2025-05-03 18:57:31 +10:00
SethBurkart123 56dabc8fd5 fix: news not loading 2025-05-03 18:57:31 +10:00
SethBurkart123 0c1a71f398 fix: sidebar layout not being applied on pageload #249 2025-05-03 18:56:50 +10:00
SethBurkart123 bb6ee72159 bump(version): 3.4.6.1 + changelog 2025-05-03 18:56:50 +10:00
SethBurkart123 d52a59ae48 fix: storage always resetting to default 2025-05-03 18:56:50 +10:00
SethBurkart123 1c9e361f78 fix: builds running incorrectly 2025-05-03 18:56:50 +10:00
SethBurkart123 ec3396c52e fix: dynamic seqta classes failing to load #248 2025-05-03 18:56:50 +10:00
SethBurkart123 5b5dba69dc fix: handle failed sortmessagepageitems 2025-05-03 18:56:50 +10:00
Andrew R 449b54ae32 Update README.md 2025-05-03 18:56:19 +10:00
codefactor-io c29dc45697 [CodeFactor] Apply fixes to commit d412762 2025-05-01 11:34:34 +00:00
SethBurkart123 d4127626b1 feat: cleaned code + improved performance 2025-05-01 21:34:17 +10:00
Alphons Joseph 907f970018 feat: old scrolling sidebar system 2025-04-28 21:32:08 +08:00
codefactor-io d9b1482255 [CodeFactor] Apply fixes to commit 454ab28 2025-04-13 00:58:22 +00:00
SethBurkart123 454ab283ab feat: improve results and performance (syncronous) 2025-04-13 10:58:05 +10:00
Seth Burkart 0ef43eb9b5 Merge pull request #254 from NIDNHU/main
complete revamp of bug report system
2025-04-13 09:06:55 +10:00
StroepWafel ecbdffbbde Update feature_request.yml 2025-04-13 00:05:07 +09:30
StroepWafel 92344400e1 fix validations indentation 2025-04-13 00:04:34 +09:30
StroepWafel ca20ba4e07 fix feature_request.yml 2025-04-13 00:02:15 +09:30
StroepWafel 694d4ea0a1 fix feature_request.yml 2025-04-13 00:01:35 +09:30
StroepWafel 72a529ee1d Update and retype feature_request.md to feature_request.yml 2025-04-13 00:00:31 +09:30
StroepWafel 0a3ee5c666 Update bug_report.yml 2025-04-12 23:50:50 +09:30
StroepWafel ef6176b6a4 Update bug_report.yml 2025-04-12 23:50:32 +09:30
StroepWafel b3c395cca1 Update bug_report.yml 2025-04-12 23:47:13 +09:30
StroepWafel 8c2539f130 Update bug_report.yml 2025-04-12 23:46:44 +09:30
StroepWafel 442ea04a2f Update bug_report.yml 2025-04-12 23:45:57 +09:30
StroepWafel bd812ffdae Update bug_report.yml 2025-04-12 23:45:28 +09:30
StroepWafel 6377a0c909 Update bug_report.yml 2025-04-12 23:44:57 +09:30
StroepWafel d8829d5716 Update bug_report.yml 2025-04-12 23:43:21 +09:30
StroepWafel 7fd85a5529 Update and retyped bug_report.md to bug_report.yml 2025-04-12 23:42:25 +09:30
SethBurkart123 9562368157 feat: vector search worker improvements 2025-04-11 07:10:55 +10:00
codefactor-io ab867af57d [CodeFactor] Apply fixes to commit 886d0a9 2025-04-10 14:07:58 +00:00
SethBurkart123 886d0a95f1 feat: add working workers with builds 2025-04-11 00:07:29 +10:00
SethBurkart123 dd47deb954 fix: news not loading 2025-04-09 14:46:57 +10:00
SethBurkart123 fbf066cea8 fix: sidebar layout not being applied on pageload #249 2025-04-06 14:03:51 +10:00
SethBurkart123 eb2c665843 bump(version): 3.4.6.1 + changelog 2025-04-03 22:28:50 +11:00
SethBurkart123 45a16de405 fix: storage always resetting to default 2025-04-03 22:24:25 +11:00
codefactor-io 814647e835 [CodeFactor] Apply fixes 2025-04-01 12:16:13 +00:00
SethBurkart123 07aa9524aa feat: early vector search testing 2025-04-01 23:14:45 +11:00
SethBurkart123 13f830ee16 feat: add custom items 2025-04-01 22:01:18 +11:00
SethBurkart123 1b4708261d feat: improve scrolling with calculator 2025-04-01 19:25:27 +11:00
SethBurkart123 6a556b6940 feat: further interface tweaks 2025-04-01 18:20:17 +11:00
codefactor-io d0edad8134 [CodeFactor] Apply fixes 2025-04-01 06:46:45 +00:00
SethBurkart123 5e93ae6e4b feat: add bottom bar 2025-04-01 17:45:30 +11:00
SethBurkart123 0788b78e73 feat: improve units 2025-04-01 17:24:18 +11:00
SethBurkart123 e884b0526b feat: improve searchbar 2025-04-01 16:02:54 +11:00
SethBurkart123 ea77224c75 feat: add calculator 2025-04-01 15:27:46 +11:00
SethBurkart123 18441712c9 feat: complete fuzzy search rebuild 2025-04-01 13:51:45 +11:00
SethBurkart123 8df138a374 feat: upgrade to flexsearch for better keyword search performance 2025-03-31 23:11:41 +11:00
SethBurkart123 068e4ab778 style: further UI tweaks 2025-03-31 23:06:56 +11:00
SethBurkart123 adbba730c4 style: search styling improvements 2025-03-31 23:03:45 +11:00
SethBurkart123 1f3354c47b feat: add global search UI 2025-03-31 22:54:03 +11:00
202 changed files with 39862 additions and 21117 deletions
+94 -111
View File
@@ -2,87 +2,83 @@
module.exports = { module.exports = {
forbidden: [ forbidden: [
{ {
name: 'no-circular', name: "no-circular",
severity: 'warn', severity: "warn",
comment: comment:
'This dependency is part of a circular relationship. You might want to revise ' + "This dependency is part of a circular relationship. You might want to revise " +
'your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ', "your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ",
from: {}, from: {},
to: { to: {
circular: true circular: true,
} },
}, },
{ {
name: 'no-orphans', name: "no-orphans",
comment: comment:
"This is an orphan module - it's likely not used (anymore?). Either use it or " + "This is an orphan module - it's likely not used (anymore?). Either use it or " +
"remove it. If it's logical this module is an orphan (i.e. it's a config file), " + "remove it. If it's logical this module is an orphan (i.e. it's a config file), " +
"add an exception for it in your dependency-cruiser configuration. By default " + "add an exception for it in your dependency-cruiser configuration. By default " +
"this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " + "this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " +
"files (.d.ts), tsconfig.json and some of the babel and webpack configs.", "files (.d.ts), tsconfig.json and some of the babel and webpack configs.",
severity: 'warn', severity: "warn",
from: { from: {
orphan: true, orphan: true,
pathNot: [ pathNot: [
'(^|/)[.][^/]+[.](?:js|cjs|mjs|ts|cts|mts|json)$', // dot files "(^|/)[.][^/]+[.](?:js|cjs|mjs|ts|cts|mts|json)$", // dot files
'[.]d[.]ts$', // TypeScript declaration files "[.]d[.]ts$", // TypeScript declaration files
'(^|/)tsconfig[.]json$', // TypeScript config "(^|/)tsconfig[.]json$", // TypeScript config
'(^|/)(?:babel|webpack)[.]config[.](?:js|cjs|mjs|ts|cts|mts|json)$' // other configs "(^|/)(?:babel|webpack)[.]config[.](?:js|cjs|mjs|ts|cts|mts|json)$", // other configs
] ],
}, },
to: {}, to: {},
}, },
{ {
name: 'no-deprecated-core', name: "no-deprecated-core",
comment: comment:
'A module depends on a node core module that has been deprecated. Find an alternative - these are ' + "A module depends on a node core module that has been deprecated. Find an alternative - these are " +
"bound to exist - node doesn't deprecate lightly.", "bound to exist - node doesn't deprecate lightly.",
severity: 'warn', severity: "warn",
from: {}, from: {},
to: { to: {
dependencyTypes: [ dependencyTypes: ["core"],
'core'
],
path: [ path: [
'^v8/tools/codemap$', "^v8/tools/codemap$",
'^v8/tools/consarray$', "^v8/tools/consarray$",
'^v8/tools/csvparser$', "^v8/tools/csvparser$",
'^v8/tools/logreader$', "^v8/tools/logreader$",
'^v8/tools/profile_view$', "^v8/tools/profile_view$",
'^v8/tools/profile$', "^v8/tools/profile$",
'^v8/tools/SourceMap$', "^v8/tools/SourceMap$",
'^v8/tools/splaytree$', "^v8/tools/splaytree$",
'^v8/tools/tickprocessor-driver$', "^v8/tools/tickprocessor-driver$",
'^v8/tools/tickprocessor$', "^v8/tools/tickprocessor$",
'^node-inspect/lib/_inspect$', "^node-inspect/lib/_inspect$",
'^node-inspect/lib/internal/inspect_client$', "^node-inspect/lib/internal/inspect_client$",
'^node-inspect/lib/internal/inspect_repl$', "^node-inspect/lib/internal/inspect_repl$",
'^async_hooks$', "^async_hooks$",
'^punycode$', "^punycode$",
'^domain$', "^domain$",
'^constants$', "^constants$",
'^sys$', "^sys$",
'^_linklist$', "^_linklist$",
'^_stream_wrap$' "^_stream_wrap$",
], ],
} },
}, },
{ {
name: 'not-to-deprecated', name: "not-to-deprecated",
comment: comment:
'This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later ' + "This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later " +
'version of that module, or find an alternative. Deprecated modules are a security risk.', "version of that module, or find an alternative. Deprecated modules are a security risk.",
severity: 'warn', severity: "warn",
from: {}, from: {},
to: { to: {
dependencyTypes: [ dependencyTypes: ["deprecated"],
'deprecated' },
]
}
}, },
{ {
name: 'no-non-package-json', name: "no-non-package-json",
severity: 'error', severity: "error",
comment: comment:
"This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " + "This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " +
"That's problematic as the package either (1) won't be available on live (2 - worse) will be " + "That's problematic as the package either (1) won't be available on live (2 - worse) will be " +
@@ -90,84 +86,75 @@ module.exports = {
"in your package.json.", "in your package.json.",
from: {}, from: {},
to: { to: {
dependencyTypes: [ dependencyTypes: ["npm-no-pkg", "npm-unknown"],
'npm-no-pkg', },
'npm-unknown'
]
}
}, },
{ {
name: 'not-to-unresolvable', name: "not-to-unresolvable",
comment: comment:
"This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " + "This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " +
'module: add it to your package.json. In all other cases you likely already know what to do.', "module: add it to your package.json. In all other cases you likely already know what to do.",
severity: 'error', severity: "error",
from: {}, from: {},
to: { to: {
couldNotResolve: true couldNotResolve: true,
} },
}, },
{ {
name: 'no-duplicate-dep-types', name: "no-duplicate-dep-types",
comment: comment:
"Likely this module depends on an external ('npm') package that occurs more than once " + "Likely this module depends on an external ('npm') package that occurs more than once " +
"in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " + "in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " +
"maintenance problems later on.", "maintenance problems later on.",
severity: 'warn', severity: "warn",
from: {}, from: {},
to: { to: {
moreThanOneDependencyType: true, moreThanOneDependencyType: true,
// as it's pretty common to have a type import be a type only import // as it's pretty common to have a type import be a type only import
// _and_ (e.g.) a devDependency - don't consider type-only dependency // _and_ (e.g.) a devDependency - don't consider type-only dependency
// types for this rule // types for this rule
dependencyTypesNot: ["type-only"] dependencyTypesNot: ["type-only"],
} },
}, },
/* rules you might want to tweak for your specific situation: */ /* rules you might want to tweak for your specific situation: */
{ {
name: 'not-to-spec', name: "not-to-spec",
comment: comment:
'This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. ' + "This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. " +
"If there's something in a spec that's of use to other modules, it doesn't have that single " + "If there's something in a spec that's of use to other modules, it doesn't have that single " +
'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.', "responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.",
severity: 'error', severity: "error",
from: {}, from: {},
to: { to: {
path: '[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$' path: "[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$",
} },
}, },
{ {
name: 'not-to-dev-dep', name: "not-to-dev-dep",
severity: 'error', severity: "error",
comment: comment:
"This module depends on an npm package from the 'devDependencies' section of your " + "This module depends on an npm package from the 'devDependencies' section of your " +
'package.json. It looks like something that ships to production, though. To prevent problems ' + "package.json. It looks like something that ships to production, though. To prevent problems " +
"with npm packages that aren't there on production declare it (only!) in the 'dependencies'" + "with npm packages that aren't there on production declare it (only!) in the 'dependencies'" +
'section of your package.json. If this module is development only - add it to the ' + "section of your package.json. If this module is development only - add it to the " +
'from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration', "from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration",
from: { from: {
path: '^(src)', path: "^(src)",
pathNot: '[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$' pathNot: "[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$",
}, },
to: { to: {
dependencyTypes: [ dependencyTypes: ["npm-dev"],
'npm-dev',
],
// type only dependencies are not a problem as they don't end up in the // type only dependencies are not a problem as they don't end up in the
// production code or are ignored by the runtime. // production code or are ignored by the runtime.
dependencyTypesNot: [ dependencyTypesNot: ["type-only"],
'type-only' pathNot: ["node_modules/@types/"],
], },
pathNot: [
'node_modules/@types/'
]
}
}, },
{ {
name: 'optional-deps-used', name: "optional-deps-used",
severity: 'info', severity: "info",
comment: comment:
"This module depends on an npm package that is declared as an optional dependency " + "This module depends on an npm package that is declared as an optional dependency " +
"in your package.json. As this makes sense in limited situations only, it's flagged here. " + "in your package.json. As this makes sense in limited situations only, it's flagged here. " +
@@ -175,33 +162,28 @@ module.exports = {
"dependency-cruiser configuration.", "dependency-cruiser configuration.",
from: {}, from: {},
to: { to: {
dependencyTypes: [ dependencyTypes: ["npm-optional"],
'npm-optional' },
]
}
}, },
{ {
name: 'peer-deps-used', name: "peer-deps-used",
comment: comment:
"This module depends on an npm package that is declared as a peer dependency " + "This module depends on an npm package that is declared as a peer dependency " +
"in your package.json. This makes sense if your package is e.g. a plugin, but in " + "in your package.json. This makes sense if your package is e.g. a plugin, but in " +
"other cases - maybe not so much. If the use of a peer dependency is intentional " + "other cases - maybe not so much. If the use of a peer dependency is intentional " +
"add an exception to your dependency-cruiser configuration.", "add an exception to your dependency-cruiser configuration.",
severity: 'warn', severity: "warn",
from: {}, from: {},
to: { to: {
dependencyTypes: [ dependencyTypes: ["npm-peer"],
'npm-peer' },
] },
}
}
], ],
options: { options: {
/* Which modules not to follow further when encountered */ /* Which modules not to follow further when encountered */
doNotFollow: { doNotFollow: {
/* path: an array of regular expressions in strings to match against */ /* path: an array of regular expressions in strings to match against */
path: ['node_modules'] path: ["node_modules"],
}, },
/* Which modules to exclude */ /* Which modules to exclude */
@@ -274,7 +256,7 @@ module.exports = {
defaults to './tsconfig.json'. defaults to './tsconfig.json'.
*/ */
tsConfig: { tsConfig: {
fileName: 'tsconfig.json' fileName: "tsconfig.json",
}, },
/* Webpack configuration to use to get resolve options from. /* Webpack configuration to use to get resolve options from.
@@ -364,8 +346,8 @@ module.exports = {
"bun:wrap", "bun:wrap",
"detect-libc", "detect-libc",
"undici", "undici",
"ws" "ws",
] ],
}, },
reporterOptions: { reporterOptions: {
@@ -375,7 +357,7 @@ module.exports = {
collapses everything in node_modules to one folder deep so you see collapses everything in node_modules to one folder deep so you see
the external modules, but their innards. the external modules, but their innards.
*/ */
collapsePattern: 'node_modules/(?:@[^/]+/[^/]+|[^/]+)', collapsePattern: "node_modules/(?:@[^/]+/[^/]+|[^/]+)",
/* Options to tweak the appearance of your graph.See /* Options to tweak the appearance of your graph.See
https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions
@@ -397,7 +379,8 @@ module.exports = {
dependency graph reporter (`archi`) you probably want to tweak dependency graph reporter (`archi`) you probably want to tweak
this collapsePattern to your situation. this collapsePattern to your situation.
*/ */
collapsePattern: '^(?:packages|src|lib(s?)|app(s?)|bin|test(s?)|spec(s?))/[^/]+|node_modules/(?:@[^/]+/[^/]+|[^/]+)', collapsePattern:
"^(?:packages|src|lib(s?)|app(s?)|bin|test(s?)|spec(s?))/[^/]+|node_modules/(?:@[^/]+/[^/]+|[^/]+)",
/* Options to tweak the appearance of your graph. If you don't specify a /* Options to tweak the appearance of your graph. If you don't specify a
theme for 'archi' dependency-cruiser will use the one specified in the theme for 'archi' dependency-cruiser will use the one specified in the
@@ -405,10 +388,10 @@ module.exports = {
*/ */
// theme: { }, // theme: { },
}, },
"text": { text: {
"highlightFocused": true highlightFocused: true,
},
},
}, },
}
}
}; };
// generated: dependency-cruiser@16.10.0 on 2025-02-16T22:32:01.621Z // generated: dependency-cruiser@16.10.0 on 2025-02-16T22:32:01.621Z
+5 -2
View File
@@ -12,12 +12,15 @@
}, },
"rules": { "rules": {
// allow importing ts extensions // allow importing ts extensions
"sort-imports": ["error", { "sort-imports": [
"error",
{
"ignoreCase": true, "ignoreCase": true,
"ignoreDeclarationSort": true, "ignoreDeclarationSort": true,
"ignoreMemberSort": false, "ignoreMemberSort": false,
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"] "memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
}], }
],
"import/extensions": [ "import/extensions": [
"error", "error",
"ignorePackages", "ignorePackages",
-23
View File
@@ -1,23 +0,0 @@
---
name: Report A bug.
about: Create a report of a present bug.
title: "[BUG]"
labels: bug
assignees: ''
---
**Bug Description**
Please provide a clear and concise description of the bug.
**Steps to Reproduce**
Please list the actions that caused the issue.
**Expected Behavior**
Please describe how you think the program should have behaved, making sure to be as clear and concise as possible.
**Screenshots**
If applicable, please provide screenshots. This will help us to reproduce the issue and better understand what we are looking for.
**Additional Context**
Feel free to provide any additional, applicable context or information that is relevant to the problem.
+57
View File
@@ -0,0 +1,57 @@
name: Bug report
description: Report an issue with the modpack in its unmodified state. For other issues, use Discord.
labels: bug
title: "[BUG]"
type: "Bug"
body:
- type: markdown
attributes:
value: |
Before reporting an issue, [please search](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) to make sure it has not already been reported (make sure to search closed issues as well!).
- type: textarea
attributes:
label: Describe the bug
description: Describe your issue. For general issues and questions you'll get a faster answer [from our Discord.](https://discord.gg/YzmbnCDkat)
validations:
required: true
- type: input
attributes:
label: Extension version
description: What version of the extension are you using?
placeholder: Find it by opening the config menu and clicking the about icon in the top right.
validations:
required: true
- type: dropdown
attributes:
label: Browser
description: Which Browser are you using?
options:
- Chrome
- Firefox
- Brave
- Safari
- DuckDuckGO
- Microsoft Edge
- Other Chromium-Based Browser
- Other Non-Chromium-Based Browser
validations:
required: true
- type: checkboxes
attributes:
label: Confirm
options:
- label: This bug report is about an issue with the extension itself. I have not modified the extension nor added any unsupported plugins. If this is not the case, I know that I should post the issue to the extension's Discord support channel instead.
required: true
- type: textarea
attributes:
label: Additional context
description: Screenshots, video or any other information. Include photos of the console if possible
placeholder: |
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
-14
View File
@@ -1,14 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FR] "
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? if so, Please describe the issue or link to a pre-existing bug report**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen. Provide reference art/pictures if poccible
@@ -0,0 +1,52 @@
name: Feature request
description: Suggest a new Feature to be added or replaced in BetterSeqtaPLUS
labels: enhancement
title: "[FR]"
body:
- type: checkboxes
attributes:
label: Confirm
options:
- label: "Is this feature request related to a Bug report?"
required: false
- type: input
attributes:
label: Bug report link
description: "If this feature request is related to a bug report, please insert the link to the bug report here"
placeholder: "https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/..."
validations:
required: false
- type: markdown
attributes:
value: |
## Feature details
Before you request a feature, [please search](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) if it has already been requested. (Make sure to check closed issues as well!)
- type: dropdown
attributes:
label: Feature type
multiple: false
options:
- Graphical
- Functional
- Not Sure
validations:
required: true
- type: input
attributes:
label: Feature Details
description: Please write, with as much detail as possible, what you would like to see from this feature.
placeholder: it would be cool if
validations:
required: false
- type: textarea
attributes:
label: Additional details
description: Anything else that would help describe your vision (reference images, descriptions, etc)
validations:
required: false
+2 -2
View File
@@ -2,9 +2,9 @@ name: NodeJS Build
on: on:
push: push:
branches: [ "main" ] branches: ["main"]
pull_request: pull_request:
branches: [ "main" ] branches: ["main"]
jobs: jobs:
build: build:
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"tabWidth": 2, "tabWidth": 2,
"useTabs": false, "useTabs": false,
"semi": false "semi": true
} }
+10 -10
View File
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our Examples of behavior that contributes to a positive environment for our
community include: community include:
* Demonstrating empathy and kindness toward other people - Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences - Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback - Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, - Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience and learning from the experience
* Focusing on what is best not just for us as individuals, but for the - Focusing on what is best not just for us as individuals, but for the
overall community overall community
Examples of unacceptable behavior include: Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or - The use of sexualized language or imagery, and sexual attention or
advances of any kind advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks - Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment - Public or private harassment
* Publishing others' private information, such as a physical or email - Publishing others' private information, such as a physical or email
address, without their explicit permission address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a - Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
## Enforcement Responsibilities ## Enforcement Responsibilities
+15 -16
View File
@@ -1,4 +1,3 @@
# #
<a href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel"> <a href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel">
@@ -59,15 +58,13 @@ Don't worry- if you get stuck feel free to ask around in the [discord](https://d
## Getting started ## Getting started
1. Clone the repository &nbsp;&nbsp;&nbsp; **1. Clone the repository**
``` ```
git clone https://github.com/BetterSEQTA/BetterSEQTA-Plus git clone https://github.com/BetterSEQTA/BetterSEQTA-Plus
``` ```
&nbsp;&nbsp;&nbsp; **2. Install dependencies**
1. Install dependencies
You may install the dependencies like below: You may install the dependencies like below:
@@ -80,29 +77,30 @@ But it is recommended to do it like this:
``` ```
npm install --legacy-peer-deps # Only NPM supported npm install --legacy-peer-deps # Only NPM supported
``` ```
### Running Development ### Running Development
2. Run the dev script (it updates as you save files)
&nbsp;&nbsp;&nbsp; **3. Run the dev script (it updates as you save files)**
``` ```
npm run dev # or use your perferred package manager npm run dev # or use your preferred package manager
``` ```
### Building for production ### Building for production
2. Run the build script &nbsp;&nbsp;&nbsp; **4. Run the build script**
``` ```
npm run build # or use your perferred package manager npm run build # or use your preferred package manager
``` ```
2.1. Package it up (optional) &nbsp;&nbsp;&nbsp; **4.1. Package it up (optional)**
``` ```
npm run zip # This REQUIRES 7-Zip to be installed in order to work. You can also use your perferred package manager npm run zip # This REQUIRES 7-Zip to be installed in order to work. You can also use your preferred package manager
``` ```
3. Load the extension into chrome
&nbsp;&nbsp;&nbsp; **5. Load the extension into chrome**
- Go to `chrome://extensions` - Go to `chrome://extensions`
- Enable developer mode - Enable developer mode
@@ -116,7 +114,7 @@ Just remember, in order to update changes to the extension if you are running in
The folder structure is as follows: The folder structure is as follows:
- The `src` folder contains source files that are compiled to the build directory. - The `src` folder contains source files that are compiled to the build directory.
-
- The `src/plugins` folder contains vital loaders required for BetterSEQTA+ functionality. - The `src/plugins` folder contains vital loaders required for BetterSEQTA+ functionality.
- The `src/interface` folder contains source React & Svelte files that are required for the Settings page. - The `src/interface` folder contains source React & Svelte files that are required for the Settings page.
@@ -130,9 +128,10 @@ The folder structure is as follows:
</a> </a>
Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plus/blob/main/CONTRIBUTING.md) Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plus/blob/main/CONTRIBUTING.md)
## Credits ## Credits
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development by [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers
## Star History ## Star History
+2 -1
View File
@@ -5,11 +5,12 @@
Below here is the supported versions of BetterSEQTA+. Anything older than this is not supported and contains bugs. Below here is the supported versions of BetterSEQTA+. Anything older than this is not supported and contains bugs.
| Version | Supported | | Version | Supported |
| ------- | ------------------ | | ------- | --------- |
| 3.4.3 | ✅ | | 3.4.3 | ✅ |
| < 3.4.3 | :x: | | < 3.4.3 | :x: |
`*` May not work on other devices. `*` May not work on other devices.
## Reporting a Vulnerability ## Reporting a Vulnerability
If you find vulnerabilities, REPORT IT IMMEDIATELY. open the [advisories tab](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories) on the left and click the green "report a vulnerability" button or use [this quick-link](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories/new) to create a new report If you find vulnerabilities, REPORT IT IMMEDIATELY. open the [advisories tab](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories) on the left and click the green "report a vulnerability" button or use [this quick-link](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories/new) to create a new report
+2
View File
@@ -7,11 +7,13 @@ Welcome to the BetterSEQTA+ documentation! This documentation will help you unde
## Table of Contents ## Table of Contents
### Getting Started ### Getting Started
- [Project Overview](./README.md) - This file - [Project Overview](./README.md) - This file
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+ - [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+
- [Contributing Guide](../CONTRIBUTING.md) - How to contribute to BetterSEQTA+ - [Contributing Guide](../CONTRIBUTING.md) - How to contribute to BetterSEQTA+
### Plugin System ### Plugin System
- [Creating Your First Plugin](./plugins/README.md) - A comprehensive, beginner-friendly guide to creating plugins - [Creating Your First Plugin](./plugins/README.md) - A comprehensive, beginner-friendly guide to creating plugins
- [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs - [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs
+6
View File
@@ -22,6 +22,7 @@ Thank you for your interest in contributing to BetterSEQTA+! This document provi
BetterSEQTA+ is committed to providing a welcoming and inclusive environment for all contributors. We expect all participants to adhere to our Code of Conduct, which promotes respectful and harassment-free interaction. BetterSEQTA+ is committed to providing a welcoming and inclusive environment for all contributors. We expect all participants to adhere to our Code of Conduct, which promotes respectful and harassment-free interaction.
Key points: Key points:
- Be respectful and inclusive - Be respectful and inclusive
- Focus on what is best for the community - Focus on what is best for the community
- Show empathy towards other community members - Show empathy towards other community members
@@ -105,6 +106,7 @@ git checkout -b feature/my-new-feature
2. **Write Clear Commit Messages** 2. **Write Clear Commit Messages**
Follow the conventional commits format: Follow the conventional commits format:
``` ```
feat: add new feature feat: add new feature
fix: resolve bug with timetable fix: resolve bug with timetable
@@ -118,6 +120,7 @@ git checkout -b feature/my-new-feature
4. **Run Tests** 4. **Run Tests**
Make sure all tests pass before submitting your PR: Make sure all tests pass before submitting your PR:
```bash ```bash
npm test npm test
``` ```
@@ -157,6 +160,7 @@ We follow TypeScript best practices and have a consistent code style:
5. **Use Linters** 5. **Use Linters**
We use ESLint and Prettier. Run them before submitting your PR: We use ESLint and Prettier. Run them before submitting your PR:
```bash ```bash
npm run lint npm run lint
npm run format npm run format
@@ -173,6 +177,7 @@ If you find a bug, please report it by creating an issue on GitHub:
2. **Use the Bug Report Template** 2. **Use the Bug Report Template**
Fill in all sections of the bug report template: Fill in all sections of the bug report template:
- Description - Description
- Steps to reproduce - Steps to reproduce
- Expected behavior - Expected behavior
@@ -195,6 +200,7 @@ We welcome feature suggestions! To suggest a new feature:
2. **Use the Feature Request Template** 2. **Use the Feature Request Template**
Fill in all sections of the feature request template: Fill in all sections of the feature request template:
- Description - Description
- Use case - Use case
- Potential implementation - Potential implementation
+2
View File
@@ -132,6 +132,7 @@ bun install
#### Extension not appearing in SEQTA #### Extension not appearing in SEQTA
Make sure: Make sure:
- You're visiting a SEQTA Learn page - You're visiting a SEQTA Learn page
- The extension is enabled - The extension is enabled
- You've refreshed the page after installing the extension - You've refreshed the page after installing the extension
@@ -139,6 +140,7 @@ Make sure:
#### Development build not updating #### Development build not updating
Try: Try:
1. Stopping the development server 1. Stopping the development server
2. Clearing your browser cache 2. Clearing your browser cache
3. Removing the extension from your browser 3. Removing the extension from your browser
+75 -35
View File
@@ -5,6 +5,7 @@ Hey there! 👋 So you want to create a plugin for BetterSEQTA+? That's awesome!
## What is a Plugin? ## What is a Plugin?
In BetterSEQTA+, a plugin is like a mini-app that adds new features to SEQTA. Think of it as a piece of LEGO that you can snap onto SEQTA to make it do new things. For example, you could create a plugin that: In BetterSEQTA+, a plugin is like a mini-app that adds new features to SEQTA. Think of it as a piece of LEGO that you can snap onto SEQTA to make it do new things. For example, you could create a plugin that:
- Changes how SEQTA looks - Changes how SEQTA looks
- Adds new buttons or features - Adds new buttons or features
- Shows extra information on your timetable - Shows extra information on your timetable
@@ -16,29 +17,32 @@ In BetterSEQTA+, a plugin is like a mini-app that adds new features to SEQTA. Th
Let's create a super simple plugin together. We'll make one that adds a friendly message to the SEQTA homepage. Here's what we'll need: Let's create a super simple plugin together. We'll make one that adds a friendly message to the SEQTA homepage. Here's what we'll need:
```typescript ```typescript
import type { Plugin } from '@/plugins/core/types'; import type { Plugin } from "@/plugins/core/types";
const myFirstPlugin: Plugin = { const myFirstPlugin: Plugin = {
// Every plugin needs these basic details // Every plugin needs these basic details
id: 'my-first-plugin', id: "my-first-plugin",
name: 'My First Plugin', name: "My First Plugin",
description: 'Adds a friendly message to SEQTA', description: "Adds a friendly message to SEQTA",
version: '1.0.0', version: "1.0.0",
// This tells BetterSEQTA+ that users can turn our plugin on/off // This tells BetterSEQTA+ that users can turn our plugin on/off
disableToggle: true, disableToggle: true,
// Optional: Mark your plugin as beta to show a "Beta" tag in settings
beta: true,
// This is where the magic happens! // This is where the magic happens!
run: async (api) => { run: async (api) => {
// Wait for the homepage to load // Wait for the homepage to load
api.seqta.onMount('.home-page', (homePage) => { api.seqta.onMount(".home-page", (homePage) => {
// Create our message // Create our message
const message = document.createElement('div'); const message = document.createElement("div");
message.textContent = 'Hello from my first plugin! 🎉'; message.textContent = "Hello from my first plugin! 🎉";
message.style.padding = '20px'; message.style.padding = "20px";
message.style.backgroundColor = '#e9f5ff'; message.style.backgroundColor = "#e9f5ff";
message.style.borderRadius = '8px'; message.style.borderRadius = "8px";
message.style.margin = '20px'; message.style.margin = "20px";
// Add it to the page // Add it to the page
homePage.prepend(message); homePage.prepend(message);
@@ -46,10 +50,10 @@ const myFirstPlugin: Plugin = {
// Return a cleanup function that removes our message when the plugin is disabled // Return a cleanup function that removes our message when the plugin is disabled
return () => { return () => {
const message = document.querySelector('.home-page > div'); const message = document.querySelector(".home-page > div");
message?.remove(); message?.remove();
}; };
} },
}; };
export default myFirstPlugin; export default myFirstPlugin;
@@ -64,10 +68,11 @@ Let's break down what's happening here:
- `description`: Explain what your plugin does - `description`: Explain what your plugin does
- `version`: Your plugin's version number - `version`: Your plugin's version number
3. We set `disableToggle: true` so users can turn our plugin on/off in settings 3. We set `disableToggle: true` so users can turn our plugin on/off in settings
4. The `run` function is where we put our plugin's code 4. We set `beta: true` to mark the plugin as beta
5. We use `api.seqta.onMount` to wait for the homepage to load 5. The `run` function is where we put our plugin's code
6. We create and style a message element 6. We use `api.seqta.onMount` to wait for the homepage to load
7. We return a cleanup function that removes our changes when the plugin is disabled 7. We create and style a message element
8. We return a cleanup function that removes our changes when the plugin is disabled
## The Plugin API ## The Plugin API
@@ -79,13 +84,13 @@ This helps you interact with SEQTA's pages:
```typescript ```typescript
// Wait for an element to appear on the page // Wait for an element to appear on the page
api.seqta.onMount('.some-class', (element) => { api.seqta.onMount(".some-class", (element) => {
// Do something with the element // Do something with the element
}); });
// Know when the user changes pages // Know when the user changes pages
api.seqta.onPageChange((page) => { api.seqta.onPageChange((page) => {
console.log('User went to:', page); console.log("User went to:", page);
}); });
// Get the current page // Get the current page
@@ -97,8 +102,12 @@ const currentPage = api.seqta.getCurrentPage();
Want to let users customize your plugin? Use settings! Want to let users customize your plugin? Use settings!
```typescript ```typescript
import { BasePlugin } from '@/plugins/core/settings'; import { BasePlugin } from "@/plugins/core/settings";
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers'; import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
// Define your settings // Define your settings
const settings = defineSettings({ const settings = defineSettings({
@@ -106,7 +115,7 @@ const settings = defineSettings({
default: true, default: true,
title: "Show Welcome Message", title: "Show Welcome Message",
description: "Show a friendly message on the homepage", description: "Show a friendly message on the homepage",
}) }),
}); });
// Create a class for your plugin // Create a class for your plugin
@@ -129,14 +138,14 @@ const myPlugin: Plugin<typeof settings> = {
} }
// Listen for setting changes // Listen for setting changes
api.settings.onChange('showMessage', (newValue) => { api.settings.onChange("showMessage", (newValue) => {
if (newValue) { if (newValue) {
// Show the message // Show the message
} else { } else {
// Hide the message // Hide the message
} }
}); });
} },
}; };
``` ```
@@ -146,14 +155,14 @@ Need to save some data? The storage API has got you covered:
```typescript ```typescript
// Save some data // Save some data
await api.storage.set('lastVisit', new Date().toISOString()); await api.storage.set("lastVisit", new Date().toISOString());
// Get it back later // Get it back later
const lastVisit = await api.storage.get('lastVisit'); const lastVisit = await api.storage.get("lastVisit");
// Listen for changes // Listen for changes
api.storage.onChange('lastVisit', (newValue) => { api.storage.onChange("lastVisit", (newValue) => {
console.log('Last visit updated:', newValue); console.log("Last visit updated:", newValue);
}); });
``` ```
@@ -163,12 +172,12 @@ Want your plugin to be able to interface with other plugins? Then use events!
```typescript ```typescript
// Listen for an event // Listen for an event
api.events.on('myCustomEvent', (data) => { api.events.on("myCustomEvent", (data) => {
console.log('Got event:', data); console.log("Got event:", data);
}); });
// Send an event // Send an event
api.events.emit('myCustomEvent', { some: 'data' }); api.events.emit("myCustomEvent", { some: "data" });
``` ```
## Adding Styles ## Adding Styles
@@ -199,7 +208,7 @@ const myPlugin: Plugin = {
run: async (api) => { run: async (api) => {
// Your plugin code here // Your plugin code here
} },
}; };
``` ```
@@ -208,28 +217,31 @@ const myPlugin: Plugin = {
Here are some tips to make your plugin awesome: Here are some tips to make your plugin awesome:
1. **Always Clean Up**: When your plugin is disabled, clean up any changes you made: 1. **Always Clean Up**: When your plugin is disabled, clean up any changes you made:
```typescript ```typescript
run: async (api) => { run: async (api) => {
// Add stuff to the page // Add stuff to the page
const element = document.createElement('div'); const element = document.createElement("div");
document.body.appendChild(element); document.body.appendChild(element);
// Return a cleanup function // Return a cleanup function
return () => { return () => {
element.remove(); element.remove();
}; };
} };
``` ```
2. **Use TypeScript**: It helps catch errors before they happen and makes your code easier to understand. 2. **Use TypeScript**: It helps catch errors before they happen and makes your code easier to understand.
3. **Test Your Plugin**: Make sure it works in different situations: 3. **Test Your Plugin**: Make sure it works in different situations:
- When SEQTA is loading - When SEQTA is loading
- When the user switches pages - When the user switches pages
- When the plugin is enabled/disabled - When the plugin is enabled/disabled
- When settings are changed - When settings are changed
4. **Keep It Fast**: Don't slow down SEQTA: 4. **Keep It Fast**: Don't slow down SEQTA:
- Use `onMount` instead of intervals or timeouts - Use `onMount` instead of intervals or timeouts
- Clean up event listeners when they're not needed - Clean up event listeners when they're not needed
- Don't do heavy calculations on the main thread - Don't do heavy calculations on the main thread
@@ -238,10 +250,37 @@ Here are some tips to make your plugin awesome:
- Add clear settings with good descriptions - Add clear settings with good descriptions
- Use `disableToggle: true` so users can turn it off if needed - Use `disableToggle: true` so users can turn it off if needed
- Add helpful error messages if something goes wrong - Add helpful error messages if something goes wrong
- Use `beta: true` for experimental features to let users know they're trying something new
## Plugin Metadata Options
Your plugin object supports several optional flags to customize how it appears and behaves:
```typescript
const myPlugin: Plugin = {
id: "my-plugin",
name: "My Plugin",
description: "What my plugin does",
version: "1.0.0",
// Optional flags:
disableToggle: true, // Show enable/disable toggle in settings
defaultEnabled: false, // Start disabled by default (requires disableToggle: true)
beta: true, // Show "Beta" tag in settings UI
// Your plugin code...
run: async (api) => { /* ... */ },
};
```
- **`disableToggle`**: When `true`, users can enable/disable your plugin in settings
- **`defaultEnabled`**: When `false`, your plugin starts disabled (only works with `disableToggle: true`)
- **`beta`**: When `true`, shows an orange "Beta" tag next to your plugin name in settings
## Examples ## Examples
Want to see more examples? Check out our built-in plugins: Want to see more examples? Check out our built-in plugins:
- [themes](../../src/plugins/built-in/themes/index.ts): Shows how to change SEQTA's appearance - [themes](../../src/plugins/built-in/themes/index.ts): Shows how to change SEQTA's appearance
- [notificationCollector](../../src/plugins/built-in/notificationCollector/index.ts): Shows how to work with SEQTA's notifications - [notificationCollector](../../src/plugins/built-in/notificationCollector/index.ts): Shows how to work with SEQTA's notifications
- [timetable](../../src/plugins/built-in/timetable/index.ts): Shows how to modify SEQTA's timetable view - [timetable](../../src/plugins/built-in/timetable/index.ts): Shows how to modify SEQTA's timetable view
@@ -250,6 +289,7 @@ Want to see more examples? Check out our built-in plugins:
## Need Help? ## Need Help?
Got stuck? No worries! Here's where you can get help: Got stuck? No worries! Here's where you can get help:
- Join our [Discord server](https://discord.gg/YzmbnCDkat) - Join our [Discord server](https://discord.gg/YzmbnCDkat)
- Check out the built-in plugins in the `src/plugins/built-in` folder - Check out the built-in plugins in the `src/plugins/built-in` folder
- Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues) - Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues)
+126 -74
View File
@@ -7,9 +7,13 @@ This document provides detailed technical information about BetterSEQTA+'s plugi
Here's how a plugin is structured: Here's how a plugin is structured:
```typescript ```typescript
import type { Plugin } from '@/plugins/core/types'; import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from '@/plugins/core/settings'; import { BasePlugin } from "@/plugins/core/settings";
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers'; import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
// First, define your settings // First, define your settings
const settings = defineSettings({ const settings = defineSettings({
@@ -17,7 +21,7 @@ const settings = defineSettings({
default: true, default: true,
title: "Enable Feature", title: "Enable Feature",
description: "Turn this feature on or off", description: "Turn this feature on or off",
}) }),
}); });
// Create a class to handle your settings // Create a class to handle your settings
@@ -31,59 +35,92 @@ const settingsInstance = new MyPluginClass();
// Create your plugin // Create your plugin
const myPlugin: Plugin<typeof settings> = { const myPlugin: Plugin<typeof settings> = {
id: 'my-plugin', id: "my-plugin",
name: 'My Plugin', name: "My Plugin",
description: 'A cool plugin that does things', description: "A cool plugin that does things",
version: '1.0.0', version: "1.0.0",
settings: settingsInstance.settings, settings: settingsInstance.settings,
disableToggle: true, disableToggle: true,
beta: true,
run: async (api) => { run: async (api) => {
console.log('Plugin is running!'); console.log("Plugin is running!");
// Do stuff when settings change // Do stuff when settings change
api.settings.onChange('enabled', (enabled) => { api.settings.onChange("enabled", (enabled) => {
if (enabled) { if (enabled) {
console.log('Feature enabled!'); console.log("Feature enabled!");
} }
}); });
// Return a cleanup function // Return a cleanup function
return () => { return () => {
console.log('Plugin cleanup'); console.log("Plugin cleanup");
}; };
} },
}; };
export default myPlugin; export default myPlugin;
``` ```
## Plugin Metadata
The plugin object supports several metadata fields and options:
```typescript
interface Plugin {
// Required fields
id: string; // Unique identifier (lowercase, dashes)
name: string; // Display name shown to users
description: string; // Brief description of what the plugin does
version: string; // Semantic version (e.g., "1.0.0")
settings: PluginSettings; // Plugin settings object
run: (api: PluginAPI) => void; // Main plugin function
// Optional fields
styles?: string; // CSS styles to inject
disableToggle?: boolean; // Show enable/disable toggle in settings
defaultEnabled?: boolean; // Start enabled/disabled (requires disableToggle)
beta?: boolean; // Show "Beta" tag in settings UI
}
```
### Metadata Options
- **`disableToggle`**: When `true`, users can enable/disable your plugin in the settings page
- **`defaultEnabled`**: When `false`, your plugin starts disabled by default (only works with `disableToggle: true`)
- **`beta`**: When `true`, displays an orange "Beta" tag next to your plugin name in the settings UI
- **`styles`**: CSS string that gets injected into the page when your plugin runs
## SEQTA API ## SEQTA API
The SEQTA API helps you interact with SEQTA's pages: The SEQTA API helps you interact with SEQTA's pages:
```typescript ```typescript
import type { Plugin } from '@/plugins/core/types'; import type { Plugin } from "@/plugins/core/types";
const seqtaPlugin: Plugin<typeof settings> = { const seqtaPlugin: Plugin<typeof settings> = {
id: 'seqta-example', id: "seqta-example",
name: 'SEQTA Example', name: "SEQTA Example",
description: 'Shows how to use the SEQTA API', description: "Shows how to use the SEQTA API",
version: '1.0.0', version: "1.0.0",
settings: {}, settings: {},
disableToggle: true, disableToggle: true,
run: async (api) => { run: async (api) => {
// Wait for elements to appear // Wait for elements to appear
const { unregister: timetableUnregister } = api.seqta.onMount('.timetable', (timetable) => { const { unregister: timetableUnregister } = api.seqta.onMount(
const button = document.createElement('button'); ".timetable",
button.textContent = 'Export'; (timetable) => {
const button = document.createElement("button");
button.textContent = "Export";
timetable.appendChild(button); timetable.appendChild(button);
}); },
);
// Track page changes // Track page changes
const { unregister: pageUnregister } = api.seqta.onPageChange((page) => { const { unregister: pageUnregister } = api.seqta.onPageChange((page) => {
console.log('User went to:', page); console.log("User went to:", page);
}); });
// Clean up when disabled // Clean up when disabled
@@ -91,7 +128,7 @@ const seqtaPlugin: Plugin<typeof settings> = {
timetableUnregister(); timetableUnregister();
pageUnregister(); pageUnregister();
}; };
} },
}; };
export default seqtaPlugin; export default seqtaPlugin;
@@ -102,22 +139,29 @@ export default seqtaPlugin;
Here's how to add settings to your plugin: Here's how to add settings to your plugin:
```typescript ```typescript
import type { Plugin } from '@/plugins/core/types'; import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from '@/plugins/core/settings'; import { BasePlugin } from "@/plugins/core/settings";
import { booleanSetting, stringSetting, numberSetting, selectSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers'; import {
booleanSetting,
stringSetting,
numberSetting,
selectSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
// Define your settings // Define your settings
const settings = defineSettings({ const settings = defineSettings({
darkMode: booleanSetting({ darkMode: booleanSetting({
default: false, default: false,
title: "Dark Mode", title: "Dark Mode",
description: "Enable dark mode" description: "Enable dark mode",
}), }),
userName: stringSetting({ userName: stringSetting({
default: "", default: "",
title: "User Name", title: "User Name",
description: "Your display name", description: "Your display name",
placeholder: "Enter your name..." placeholder: "Enter your name...",
}), }),
theme: selectSetting({ theme: selectSetting({
default: "light", default: "light",
@@ -125,9 +169,9 @@ const settings = defineSettings({
description: "Choose your theme", description: "Choose your theme",
options: [ options: [
{ value: "light", label: "Light" }, { value: "light", label: "Light" },
{ value: "dark", label: "Dark" } { value: "dark", label: "Dark" },
] ],
}) }),
}); });
// Create your settings class // Create your settings class
@@ -144,29 +188,29 @@ class ThemePluginClass extends BasePlugin<typeof settings> {
// Create the plugin // Create the plugin
const themePlugin: Plugin<typeof settings> = { const themePlugin: Plugin<typeof settings> = {
id: 'theme-example', id: "theme-example",
name: 'Theme Example', name: "Theme Example",
description: 'Shows how to use settings', description: "Shows how to use settings",
version: '1.0.0', version: "1.0.0",
settings: new ThemePluginClass().settings, settings: new ThemePluginClass().settings,
disableToggle: true, disableToggle: true,
run: async (api) => { run: async (api) => {
// Apply initial settings // Apply initial settings
if (api.settings.darkMode) { if (api.settings.darkMode) {
document.body.classList.add('dark'); document.body.classList.add("dark");
} }
// Listen for changes // Listen for changes
const { unregister } = api.settings.onChange('darkMode', (enabled) => { const { unregister } = api.settings.onChange("darkMode", (enabled) => {
document.body.classList.toggle('dark', enabled); document.body.classList.toggle("dark", enabled);
}); });
return () => { return () => {
unregister(); unregister();
document.body.classList.remove('dark'); document.body.classList.remove("dark");
}; };
} },
}; };
export default themePlugin; export default themePlugin;
@@ -177,13 +221,13 @@ export default themePlugin;
Here's how to use storage in your plugin: Here's how to use storage in your plugin:
```typescript ```typescript
import type { Plugin } from '@/plugins/core/types'; import type { Plugin } from "@/plugins/core/types";
const storagePlugin: Plugin<typeof settings> = { const storagePlugin: Plugin<typeof settings> = {
id: 'storage-example', id: "storage-example",
name: 'Storage Example', name: "Storage Example",
description: 'Shows how to use storage', description: "Shows how to use storage",
version: '1.0.0', version: "1.0.0",
settings: {}, settings: {},
disableToggle: true, disableToggle: true,
@@ -192,21 +236,21 @@ const storagePlugin: Plugin<typeof settings> = {
await api.storage.loaded; await api.storage.loaded;
// Save some data // Save some data
await api.storage.set('lastVisit', new Date().toISOString()); await api.storage.set("lastVisit", new Date().toISOString());
// Get saved data // Get saved data
const lastVisit = await api.storage.get('lastVisit'); const lastVisit = await api.storage.get("lastVisit");
console.log('Last visit:', lastVisit); console.log("Last visit:", lastVisit);
// Listen for changes // Listen for changes
const { unregister } = api.storage.onChange('lastVisit', (newValue) => { const { unregister } = api.storage.onChange("lastVisit", (newValue) => {
console.log('Last visit updated:', newValue); console.log("Last visit updated:", newValue);
}); });
return () => { return () => {
unregister(); unregister();
}; };
} },
}; };
export default storagePlugin; export default storagePlugin;
@@ -217,33 +261,39 @@ export default storagePlugin;
Here's how to use events in your plugin: Here's how to use events in your plugin:
```typescript ```typescript
import type { Plugin } from '@/plugins/core/types'; import type { Plugin } from "@/plugins/core/types";
const eventsPlugin: Plugin<typeof settings> = { const eventsPlugin: Plugin<typeof settings> = {
id: 'events-example', id: "events-example",
name: 'Events Example', name: "Events Example",
description: 'Shows how to use events', description: "Shows how to use events",
version: '1.0.0', version: "1.0.0",
settings: {}, settings: {},
disableToggle: true, disableToggle: true,
run: async (api) => { run: async (api) => {
// Listen for theme changes // Listen for theme changes
const { unregister: themeListener } = api.events.on('theme.changed', (theme) => { const { unregister: themeListener } = api.events.on(
console.log('Theme changed to:', theme); "theme.changed",
}); (theme) => {
console.log("Theme changed to:", theme);
},
);
// Listen for notifications // Listen for notifications
const { unregister: notifyListener } = api.events.on('notification.new', (notification) => { const { unregister: notifyListener } = api.events.on(
console.log('New notification:', notification); "notification.new",
}); (notification) => {
console.log("New notification:", notification);
},
);
// Clean up listeners // Clean up listeners
return () => { return () => {
themeListener(); themeListener();
notifyListener(); notifyListener();
}; };
} },
}; };
export default eventsPlugin; export default eventsPlugin;
@@ -254,20 +304,20 @@ export default eventsPlugin;
Here's how to write efficient plugins: Here's how to write efficient plugins:
```typescript ```typescript
import type { Plugin } from '@/plugins/core/types'; import type { Plugin } from "@/plugins/core/types";
const efficientPlugin: Plugin<typeof settings> = { const efficientPlugin: Plugin<typeof settings> = {
id: 'efficient-example', id: "efficient-example",
name: 'Efficient Example', name: "Efficient Example",
description: 'Shows performance best practices', description: "Shows performance best practices",
version: '1.0.0', version: "1.0.0",
settings: {}, settings: {},
disableToggle: true, disableToggle: true,
run: async (api) => { run: async (api) => {
// ✅ Good: Use onMount // ✅ Good: Use onMount
const { unregister } = api.seqta.onMount('.timetable', (el) => { const { unregister } = api.seqta.onMount(".timetable", (el) => {
el.classList.add('enhanced'); el.classList.add("enhanced");
}); });
// ❌ Bad: Don't use intervals // ❌ Bad: Don't use intervals
@@ -277,7 +327,7 @@ const efficientPlugin: Plugin<typeof settings> = {
// }, 100); // }, 100);
// ✅ Good: Cache DOM elements // ✅ Good: Cache DOM elements
const header = document.querySelector('.header'); const header = document.querySelector(".header");
if (header) { if (header) {
// Reuse header instead of querying again // Reuse header instead of querying again
} }
@@ -285,7 +335,7 @@ const efficientPlugin: Plugin<typeof settings> = {
// ✅ Good: Batch DOM updates // ✅ Good: Batch DOM updates
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const div = document.createElement('div'); const div = document.createElement("div");
fragment.appendChild(div); fragment.appendChild(div);
} }
document.body.appendChild(fragment); document.body.appendChild(fragment);
@@ -294,13 +344,14 @@ const efficientPlugin: Plugin<typeof settings> = {
unregister(); unregister();
// clearInterval(interval); // If you used the bad approach // clearInterval(interval); // If you used the bad approach
}; };
} },
}; };
export default efficientPlugin; export default efficientPlugin;
``` ```
Each plugin should be in its own file and exported as the default export. The plugin should: Each plugin should be in its own file and exported as the default export. The plugin should:
1. Import necessary types and helpers 1. Import necessary types and helpers
2. Define settings if needed 2. Define settings if needed
3. Create a settings class if using settings 3. Create a settings class if using settings
@@ -308,6 +359,7 @@ Each plugin should be in its own file and exported as the default export. The pl
5. Export the plugin as default 5. Export the plugin as default
Remember to always: Remember to always:
- Use proper TypeScript types - Use proper TypeScript types
- Clean up when your plugin is disabled - Clean up when your plugin is disabled
- Handle errors gracefully - Handle errors gracefully
+17
View File
@@ -0,0 +1,17 @@
export default {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: [
'**/__tests__/**/*.ts',
'**/?(*.)+(spec|test).ts'
],
transform: {
'^.+\\.ts$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'js', 'json'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
],
};
+34 -1
View File
@@ -1,13 +1,46 @@
import fs from "fs"; import fs from "fs";
import mime from "mime-types"; import mime from "mime-types";
/**
* A Vite plugin designed to load files as base64 encoded data URLs.
* This plugin intercepts module imports that have a `?base64` query parameter
* appended to the file path. It then reads the targeted file, converts its content
* to a base64 string, and constructs a data URL which is then exported as the
* default export of a new JavaScript module.
*
* @example
* // To use this loader, import a file with ?base64 query:
* // import myImageBase64 from './path/to/myimage.png?base64';
* // myImageBase64 will then be a string like "data:image/png;base64,..."
*/
export const base64Loader = { export const base64Loader = {
/**
* The name of the Vite plugin.
* @type {string}
*/
name: "base64-loader", name: "base64-loader",
/**
* The core transformation function of the Vite plugin.
* It is called by Vite for modules that might need transformation. This function
* checks if the module ID includes the `?base64` query. If so, it reads the
* specified file, converts it to a base64 data URL, and returns a new
* JavaScript module that default exports this data URL.
*
* @param {any} _ The original code of the file. This parameter is unused by this loader.
* @param {string} id The ID of the module being transformed. This string typically
* contains the absolute file path and any query parameters
* (e.g., "/path/to/file.png?base64").
* @returns {string | null} If the module ID does not contain `?base64` query,
* it returns `null` to indicate no transformation.
* Otherwise, it returns a string of JavaScript code
* that default exports the base64 data URL of the file.
* For example: `export default 'data:image/png;base64,xxxx';`
*/
transform(_: any, id: string) { transform(_: any, id: string) {
const [filePath, query] = id.split("?"); const [filePath, query] = id.split("?");
if (query !== "base64") return null; if (query !== "base64") return null;
const data = fs.readFileSync(filePath, { encoding: 'base64' }); const data = fs.readFileSync(filePath, { encoding: "base64" });
const mimeType = mime.lookup(filePath); const mimeType = mime.lookup(filePath);
const dataURL = `data:${mimeType};base64,${data}`; const dataURL = `data:${mimeType};base64,${data}`;
+45 -12
View File
@@ -1,25 +1,58 @@
// ref: https://stackoverflow.com/a/76920975 // ref: https://stackoverflow.com/a/76920975
import type { Plugin } from 'vite'; import type { Plugin } from "vite";
/**
* Creates a Vite plugin designed to gracefully handle the conclusion of the build process.
* This plugin utilizes the `buildEnd` and `closeBundle` hooks provided by Vite.
* It checks for errors at the end of the build:
* - If an error occurred during the build (`buildEnd` hook receives an error), it logs the error
* and explicitly exits the Node.js process with a status code of 1 (indicating failure).
* - If the build completes without errors and the bundle is successfully generated
* (`closeBundle` hook is called), it logs a success message and exits the process
* with a status code of 0 (indicating success).
* This explicit process exiting can be useful in CI/CD environments or scripts that
* rely on the process status code to determine the build outcome.
* The core logic for using these hooks to exit the process is inspired by
* a solution found on StackOverflow (https://stackoverflow.com/a/76920975).
*
* @returns {Plugin} A Vite plugin object configured with `name`, `buildEnd`, and `closeBundle` hooks.
*/
export default function ClosePlugin(): Plugin { export default function ClosePlugin(): Plugin {
return { return {
name: 'ClosePlugin', // required, will show up in warnings and errors /**
* The unique name of this Vite plugin. This name is used by Vite for identification
* purposes and will appear in warnings, errors, and logs related to this plugin.
* @type {string}
*/
name: "ClosePlugin", // required, will show up in warnings and errors
// use this to catch errors when building /**
* A Vite hook that is called when the build process has finished, regardless of
* whether it was successful or encountered an error.
*
* @param {Error} [error] An optional error object. If the build failed, this parameter
* will contain the error that occurred. If the build was successful,
* this parameter will be undefined or null.
*/
buildEnd(error) { buildEnd(error) {
if(error) { if (error) {
console.error('Error bundling') console.error("Error bundling");
console.error(error) console.error(error);
process.exit(1) process.exit(1); // Exit with status 1 indicating an error
} else { } else {
console.log('Build ended') console.log("Build ended"); // Log successful completion of the build phase
} }
}, },
// use this to catch the end of a build without errors /**
* A Vite hook that is called after the `buildEnd` hook, but only if the build
* was successful (i.e., no errors were passed to `buildEnd`) and all output
* files have been generated and written to disk. This signifies the successful
* completion of the entire bundling process.
*/
closeBundle() { closeBundle() {
console.log('Bundle closed') console.log("Bundle closed"); // Log successful closure of the bundle
process.exit(0) process.exit(0); // Exit with status 0 indicating a successful build
}, },
} };
} }
+27 -14
View File
@@ -1,12 +1,22 @@
import type { Browser, BuildTarget, Manifest } from './types' import type { Browser, BuildTarget, Manifest } from "./types";
import type { AnyCase } from './utils' import type { AnyCase } from "./utils";
/** /**
* * Packages a given manifest object with a specific target browser identifier.
* This function is typically used in multi-browser extension build processes
* to create a configuration object that pairs the manifest data with the browser
* it's intended for. The `AnyCase<Browser>` type for the browser parameter
* implies that browser names like 'chrome', 'firefox', etc., can be provided
* in various casings.
* *
* @export * @export
* @param {Manifest} manifest * @param {Manifest} manifest The core manifest data for the extension,
* @param {AnyCase<Browser>} browser * compatible with `chrome.runtime.ManifestV3` as defined by the {@link Manifest} type.
* @return {*} {@link BuildTarget} * @param {AnyCase<Browser>} browser The target browser identifier (e.g., 'chrome', 'firefox', 'CHROME').
* Refers to the {@link Browser} type, allowing for flexible casing.
* @returns {BuildTarget} An object that pairs the `manifest` with its target `browser`.
* The structure is `{ manifest: Manifest; browser: AnyCase<Browser>; }`
* as defined by the {@link BuildTarget} type.
*/ */
export function createManifest( export function createManifest(
manifest: Manifest, manifest: Manifest,
@@ -15,19 +25,22 @@ export function createManifest(
return { return {
manifest, manifest,
browser, browser,
} };
} }
/** /**
* create a base Manifest to inherit from * Defines a base manifest object.
* type Manifest = chrome.runtime.ManifestV3 * This function is typically used to establish a common, shared foundation for an extension's manifest
* * (compatible with `chrome.runtime.ManifestV3` as per the {@link Manifest} type).
* use as shared base to extend inBrowser manifests * This base can then be extended or modified for different browsers or specific build configurations.
* For example, you might define core permissions and properties here, and then add
* browser-specific keys in subsequent steps.
* *
* @export * @export
* @param {Manifest} manifest * @param {Manifest} manifest The core manifest data to be used as a base.
* @return {*} {@link Manifest} * This should conform to the {@link Manifest} type structure.
* @returns {Manifest} The provided manifest object, intended to serve as a reusable base.
*/ */
export function createManifestBase(manifest: Manifest): Manifest { export function createManifestBase(manifest: Manifest): Manifest {
return manifest return manifest;
} }
+70
View File
@@ -0,0 +1,70 @@
// vite-plugin-inline-worker-dev.ts
// vite-plugin-inline-worker-dev.ts
import { Plugin } from "vite";
import fs from "fs/promises";
import { build } from "esbuild";
/**
* Creates a Vite plugin designed for bundling and inlining web worker scripts during development.
* This plugin specifically targets module imports that include a `?inlineWorker` query parameter.
* When such an import is encountered, the plugin bundles the worker script using `esbuild`
* and then generates JavaScript code that inlines this bundled worker as a Blob,
* creating the worker instance via `URL.createObjectURL()`.
* The name "vite:inline-worker-dev" suggests it's primarily intended for development builds.
*
* @returns {Plugin} A Vite plugin object with `name` and `load` properties.
*/
export default function InlineWorkerDevPlugin(): Plugin {
return {
/**
* The unique name of this Vite plugin.
* @type {string}
*/
name: "vite:inline-worker-dev",
/**
* The Vite hook responsible for loading and transforming modules.
* This function intercepts modules imported with `?inlineWorker`.
* For such modules, it bundles the worker script and returns JavaScript code
* that, when executed, will create an instance of this worker from an inlined Blob.
*
* @async
* @param {string} id The path or ID of the module Vite is attempting to load,
* potentially including query parameters (e.g., "/path/to/worker.ts?inlineWorker").
* @returns {Promise<string | null>} A promise that resolves to:
* - `null` if the module ID does not include `?inlineWorker`.
* - A string of JavaScript code if the module is an inline worker.
* This code will define a default export function (e.g., `InlineWorker`)
* that, when called, creates and returns a new `Worker` instance
* from the bundled and inlined worker script.
*/
async load(id) {
if (id.includes("?inlineWorker")) {
const [cleanPath] = id.split("?");
// Note: Original code had `await fs.readFile(cleanPath, "utf-8");` but `code` wasn't used.
// `esbuild` directly takes `cleanPath` as an entry point.
const result = await build({
entryPoints: [cleanPath], // esbuild uses the file path directly
bundle: true,
write: false, // We want the output in memory, not written to disk
platform: "browser", // Target environment for the worker code
format: "iife", // Immediately Invoked Function Expression, suitable for workers
target: "esnext", // Transpile to modern JavaScript
});
const workerCode = result.outputFiles[0].text;
// Construct JavaScript code that will create the worker from a Blob.
// This code is what gets returned to Vite and replaces the original import.
const workerBlobCode = `
const code = ${JSON.stringify(workerCode)};
export default function InlineWorker() {
const blob = new Blob([code], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob), { type: 'module' });
}
`;
return workerBlobCode;
}
return null; // Let Vite handle other modules normally
},
};
}
-79
View File
@@ -1,79 +0,0 @@
/*
TEMPORARY FIX FOR CHROME 130+ builds
*/
import path from 'node:path';
import fs from 'fs';
import { PluginOption } from 'vite';
import { ManifestV3Export } from '@crxjs/vite-plugin';
const manifestPath = path.resolve('dist/chrome/manifest.json');
export function updateManifestPlugin(): PluginOption {
return {
name: 'update-manifest-plugin',
enforce: 'post',
closeBundle() {
forceDisableUseDynamicUrl();
},
configureServer(server) {
server.httpServer?.once('listening', () => {
const updated = forceDisableUseDynamicUrl();
if (updated) {
server.ws.send({ type: 'full-reload' });
console.log('** updated **');
}
// Implement retry mechanism for file watching
const watchWithRetry = () => {
if (!fs.existsSync(manifestPath)) {
console.log('Manifest not found, retrying in 1 second...');
setTimeout(watchWithRetry, 1000);
return;
}
fs.watchFile(manifestPath, () => {
try {
const manifestContents = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (manifestContents.web_accessible_resources?.some((resource: any) => resource.use_dynamic_url)) {
const updated = forceDisableUseDynamicUrl();
if (updated) {
server.ws.send({ type: 'full-reload' });
console.log('** updated **');
}
}
} catch (error) {
console.log('Error reading manifest, will retry on next change:', error.message);
}
});
};
watchWithRetry();
});
},
writeBundle() {
console.log('### writeBundle ##');
forceDisableUseDynamicUrl();
},
};
}
function forceDisableUseDynamicUrl() {
if (!fs.existsSync(manifestPath)) {
return false;
}
const manifestContents = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Awaited<ManifestV3Export>;
if (typeof manifestContents === 'function' || !manifestContents.web_accessible_resources) return false;
if (manifestContents.web_accessible_resources.every((resource) => !resource.use_dynamic_url)) return false;
manifestContents.web_accessible_resources.forEach((resource) => {
if (resource.use_dynamic_url) resource.use_dynamic_url = false;
});
fs.writeFileSync(manifestPath, JSON.stringify(manifestContents, null, 2));
return true;
}
+154 -48
View File
@@ -1,94 +1,199 @@
const glob = require('glob'); /**
const semver = require('semver'); * @fileoverview
const { execSync } = require('child_process'); * This script is a command-line utility for publishing the BetterSEQTA+ extension.
const path = require('path'); * It automates the process of finding the latest built extension ZIP files for specified
* browsers, zipping the project source code (for Firefox), and then invoking the
* `publish-extension` tool with the appropriate arguments.
*
* To use this script, invoke it with Node.js followed by browser arguments:
* e.g., `node lib/publish.js --b chrome firefox`
* or `node lib/publish.js --b chrome`
* or `node lib/publish.js --b firefox`
*/
const glob = require("glob");
const semver = require("semver");
const { execSync } = require("child_process");
const path = require("path");
/**
* Determines the latest version string from a list of filenames that include version numbers.
* Filenames are expected to follow a pattern like `betterseqtaplus@3.4.5.1-chrome.zip`.
* This function handles potential 4-part versions (e.g., `3.4.5.1`) by trimming them
* to 3 parts (e.g., `3.4.5`) for comparison using the `semver` library. After identifying
* the latest semver-compatible version, it returns the original full version string
* (e.g., "3.4.5.1") that corresponds to this latest version.
*
* @param {string[]} files An array of filenames.
* @returns {string | null} The latest version string (e.g., "3.4.5.1") found among the files,
* or `null` if no valid version numbers are found or no files are provided.
*/
function getLatestVersion(files) { function getLatestVersion(files) {
console.log('Files passed to getLatestVersion:', files); console.log("Files passed to getLatestVersion:", files);
const versions = files.map(file => { const versions = files
.map((file) => {
const match = file.match(/@([\d\.]+)-/); const match = file.match(/@([\d\.]+)-/);
console.log('Matching file:', file, 'Version found:', match ? match[1] : 'None'); console.log(
"Matching file:",
file,
"Version found:",
match ? match[1] : "None",
);
if (!match) return null; if (!match) return null;
const fullVersion = match[1]; // Original version (e.g., 3.4.5.1) const fullVersion = match[1]; // Original version (e.g., 3.4.5.1)
const semverVersion = fullVersion.split('.').slice(0, 3).join('.'); // Trim to 3.4.5 // Trim to 3 parts for semver comparison, as semver typically handles X.Y.Z
const semverVersion = fullVersion.split(".").slice(0, 3).join(".");
return { fullVersion, semverVersion }; return { fullVersion, semverVersion };
}).filter(Boolean); })
.filter(Boolean); // Remove null entries if any file didn't match
console.log('Extracted versions:', versions.map(v => v.semverVersion)); console.log(
"Extracted versions:",
versions.map((v) => v.semverVersion),
);
if (versions.length === 0) {
console.log("No versions extracted.");
return null;
}
// Find latest version using the trimmed semver format // Find latest version using the trimmed semver format
const latestSemver = semver.maxSatisfying(versions.map(v => v.semverVersion), '*'); const latestSemver = semver.maxSatisfying(
console.log('Latest SemVer-compatible version:', latestSemver); versions.map((v) => v.semverVersion),
"*", // Satisfy any version, effectively finding the max
);
console.log("Latest SemVer-compatible version:", latestSemver);
// Get the full version that matches the latest SemVer version if (!latestSemver) {
const latestVersion = versions.find(v => v.semverVersion === latestSemver)?.fullVersion || null; console.log("Could not determine latest semver version.");
return null;
}
console.log('Final selected latest version:', latestVersion); // Get the original full version string that matches the identified latest SemVer version
return latestVersion; const latestVersionData = versions.find(
(v) => v.semverVersion === latestSemver,
);
const latestFullVersion = latestVersionData ? latestVersionData.fullVersion : null;
console.log("Final selected latest version:", latestFullVersion);
return latestFullVersion;
} }
/**
* Finds the path to the latest built ZIP file for a specific browser.
* It constructs a glob pattern based on the browser name (e.g., `dist/betterseqtaplus@*-*chrome.zip`),
* finds all matching files, and then uses `getLatestVersion` to identify the version string
* of the most recent file. Finally, it returns the full path to that specific file.
*
* @param {string} browser A string indicating the target browser (e.g., "chrome", "firefox").
* @returns {string | undefined} The filepath string to the latest ZIP file for the specified browser,
* or `undefined` if no matching file is found or if the latest version
* cannot be determined.
*/
function getLatestFiles(browser) { function getLatestFiles(browser) {
const pattern = `dist/betterseqtaplus@*-*${browser}.zip`; const pattern = `dist/betterseqtaplus@*-*${browser}.zip`;
console.log('Glob pattern:', pattern); console.log("Glob pattern:", pattern);
const files = glob.sync(pattern); const files = glob.sync(pattern);
console.log('Files found for browser', browser, ':', files); console.log("Files found for browser", browser, ":", files);
if (files.length === 0) {
console.log("No files found for browser", browser);
return undefined;
}
const latestVersion = getLatestVersion(files); const latestVersion = getLatestVersion(files);
if (!latestVersion) {
console.log("Could not determine latest version for browser", browser);
return undefined;
}
// Find the exact file by matching the original full version // Find the exact file by matching the original full version string
const latestFile = files.find(file => file.includes(`@${latestVersion}-`)); const latestFile = files.find((file) => file.includes(`@${latestVersion}-`));
console.log('Latest file for browser', browser, ':', latestFile); console.log("Latest file for browser", browser, ":", latestFile);
return latestFile; return latestFile;
} }
/**
* Creates a ZIP file of the project's source code, excluding specified development-related
* files and directories such as `node_modules`, `dist`, `.git`, etc.
* It uses the `7z` command-line tool to perform the archiving.
* The output filename is fixed as `dist/betterseqtaplus@latest-sources.zip`.
*
* @returns {string} The filename of the created ZIP file (e.g., `dist/betterseqtaplus@latest-sources.zip`).
*/
function zipSources() { function zipSources() {
const zipFileName = `dist/betterseqtaplus@latest-sources.zip`; const zipFileName = `dist/betterseqtaplus@latest-sources.zip`;
const excludePatterns = [ const excludePatterns = [
'node_modules', "node_modules",
'dist', "dist",
'.env*', ".env*",
'.git', ".git",
'.github', ".github",
'.vscode', ".vscode",
'LICENSE', "LICENSE",
'package.json' "package.json",
].map(pattern => `-x!${pattern}`).join(' '); ]
.map((pattern) => `-x!${pattern}`) // Format for 7z exclude syntax
.join(" ");
// Command to zip the current directory's contents into zipFileName, applying exclude patterns
const zipCommand = `7z a ${zipFileName} . ${excludePatterns}`; const zipCommand = `7z a ${zipFileName} . ${excludePatterns}`;
console.log('Zipping project sources with command:', zipCommand); console.log("Zipping project sources with command:", zipCommand);
execSync(zipCommand, { stdio: 'inherit' }); execSync(zipCommand, { stdio: "inherit" }); // Execute synchronously and show output
return zipFileName; return zipFileName;
} }
/**
* Orchestrates the extension publishing process for the specified browsers.
* This function performs the following steps:
* 1. Calls `getLatestFiles` to find the latest built ZIP for Chrome if "chrome" is in `browsers`.
* 2. Calls `getLatestFiles` to find the latest built ZIP for Firefox if "firefox" is in `browsers`.
* 3. Calls `zipSources` to create a source code ZIP if "firefox" is in `browsers` (required for Mozilla Add-ons).
* 4. Validates that all required files were found and that at least one browser was specified. Exits if not.
* 5. Constructs the `publish-extension` command-line string with the appropriate arguments
* based on the found ZIP files for the specified browsers.
* 6. Executes the constructed `publish-extension` command.
*
* @param {string[]} browsers An array of browser strings (e.g., ["chrome", "firefox"]) for which to publish the extension.
*/
function runPublishCommand(browsers) { function runPublishCommand(browsers) {
const chromeZip = browsers.includes('chrome') ? getLatestFiles('chrome') : null; const chromeZip = browsers.includes("chrome")
const firefoxZip = browsers.includes('firefox') ? getLatestFiles('firefox') : null; ? getLatestFiles("chrome")
const firefoxSourcesZip = browsers.includes('firefox') ? zipSources() : null; : null;
const firefoxZip = browsers.includes("firefox")
? getLatestFiles("firefox")
: null;
// Sources are typically only needed for Firefox submissions
const firefoxSourcesZip = browsers.includes("firefox") ? zipSources() : null;
console.log('Chrome zip:', chromeZip); console.log("Chrome zip:", chromeZip);
console.log('Firefox zip:', firefoxZip); console.log("Firefox zip:", firefoxZip);
console.log('Firefox sources zip:', firefoxSourcesZip); console.log("Firefox sources zip:", firefoxSourcesZip);
if (browsers.length === 0) { if (browsers.length === 0) {
console.log('No browsers specified. Exiting.'); console.log("No browsers specified. Exiting.");
process.exit(0); process.exit(0); // Exit gracefully if no action is needed
} }
if ((browsers.includes('chrome') && !chromeZip) || (browsers.includes('firefox') && (!firefoxZip || !firefoxSourcesZip))) { // Check if required files are missing for the specified browsers
console.error('Could not find required zip files for specified browsers.'); if (
process.exit(1); (browsers.includes("chrome") && !chromeZip) ||
(browsers.includes("firefox") && (!firefoxZip || !firefoxSourcesZip))
) {
console.error("Could not find required zip files for specified browsers.");
process.exit(1); // Exit with error status
} }
let command = 'publish-extension'; let command = "publish-extension";
if (chromeZip) { if (chromeZip) {
command += ` --chrome-zip ${chromeZip}`; command += ` --chrome-zip ${chromeZip}`;
} }
@@ -96,13 +201,14 @@ function runPublishCommand(browsers) {
command += ` --firefox-zip ${firefoxZip} --firefox-sources-zip ${firefoxSourcesZip}`; command += ` --firefox-zip ${firefoxZip} --firefox-sources-zip ${firefoxSourcesZip}`;
} }
console.log('Running command:', command); console.log("Running command:", command);
execSync(command, { stdio: 'inherit' }); execSync(command, { stdio: "inherit" }); // Execute and show output
} }
// Parse command-line arguments // Parse command-line arguments to determine which browsers to publish for
const args = process.argv.slice(2); const args = process.argv.slice(2);
const browserIndex = args.indexOf('--b'); const browserIndex = args.indexOf("--b"); // Find the --b flag
// If --b is found, take all subsequent arguments as browser names
const browsers = browserIndex !== -1 ? args.slice(browserIndex + 1) : []; const browsers = browserIndex !== -1 ? args.slice(browserIndex + 1) : [];
runPublishCommand(browsers); runPublishCommand(browsers);
+46 -8
View File
@@ -1,17 +1,55 @@
import fs from 'fs'; import fs from "fs";
/**
* Creates a Vite plugin designed to improve the reliability of Hot Module Replacement (HMR)
* for global CSS files.
*
* When a JavaScript/TypeScript module that imports a CSS file is updated, Vite's HMR
* might not always reliably update the styles injected by that global CSS. This plugin
* attempts to mitigate this by listening for hot updates. If an updated module
* has direct importers that are CSS files (e.g., a JS file imports a global CSS file),
* this plugin will "touch" those CSS files by updating their access and modification
* timestamps using `fs.utimesSync`. This action can help signal to Vite or the browser
* that the CSS file has changed, potentially triggering a more reliable style reload.
*
* @returns {import('vite').Plugin} A Vite plugin object configured with `name` and `handleHotUpdate` hooks.
*/
export default function touchGlobalCSSPlugin() { export default function touchGlobalCSSPlugin() {
return { return {
name: 'touch-global-css', /**
* The unique name of this Vite plugin.
* This name is used by Vite for identification purposes and will appear in logs.
* @type {string}
*/
name: "touch-global-css",
/**
* A Vite hook that is called when a module is hot-updated.
* This function inspects the importers of the updated module. If any of these
* importers are CSS files, their filesystem timestamps are updated ("touched").
*
* @param {object} context The context object provided by Vite's `handleHotUpdate` hook.
* @param {Array<import('vite').ModuleNode>} context.modules An array of `ModuleNode` instances that have been updated.
* This plugin specifically accesses `modules[0]._clientModule.importers`
* to find CSS files that import the updated module.
*/
handleHotUpdate({ modules }) { handleHotUpdate({ modules }) {
// log all of the staticImportedUrls // It's assumed `modules[0]` is the primary updated module of interest.
const importers = modules[0]._clientModule.importers // `_clientModule` and `importers` might be internal or less stable Vite APIs.
const importers = modules[0]?._clientModule?.importers;
if (importers) {
importers.forEach((importer) => { importers.forEach((importer) => {
if (importer.file.includes('.css')) { // Check if the importer is a CSS file
console.log("touching", importer.file) if (importer.file && importer.file.includes(".css")) {
fs.utimesSync(importer.file, new Date(), new Date()) console.log("[touch-global-css] touching", importer.file);
try {
// Update the access and modification times of the CSS file to the current time
fs.utimesSync(importer.file, new Date(), new Date());
} catch (err) {
console.error(`[touch-global-css] Error touching file ${importer.file}:`, err);
} }
})
} }
});
}
},
}; };
} }
+205 -69
View File
@@ -1,104 +1,240 @@
import type { ManifestV3Export } from '@crxjs/vite-plugin' import type { ManifestV3Export } from "@crxjs/vite-plugin";
import { type AnyCase, createEnum } from './utils' import { type AnyCase, createEnum, ObjectValues } from "./utils";
/**
* Enumerates supported JavaScript frameworks for project generation or configuration.
*/
export const FrameworkEnum = { export const FrameworkEnum = {
React: 'React', React: "React",
Vanilla: 'Vanilla', Vanilla: "Vanilla",
Preact: 'Preact', Preact: "Preact",
Lit: 'Lit', Lit: "Lit",
Svelte: 'Svelte', Svelte: "Svelte",
Vue: 'Vue', Vue: "Vue",
} as const } as const;
/**
* Enumerates supported web browsers, typically for targeting builds or configurations.
*/
export const BrowserEnum = { export const BrowserEnum = {
Chrome: 'Chrome', Chrome: "Chrome",
Brave: 'Brave', Brave: "Brave",
Opera: 'Opera', Opera: "Opera",
Edge: 'Edge', Edge: "Edge",
Firefox: 'Firefox', Firefox: "Firefox",
Safari: 'Safari', Safari: "Safari",
} as const } as const;
/**
* @private
* Enumerates supported programming languages for project setup.
* This enum is not exported, suggesting it's for internal use within this module or related modules.
*/
const LanguageEnum = { const LanguageEnum = {
TypeScript: 'TypeScript', TypeScript: "TypeScript",
JavaScript: 'JavaScript', JavaScript: "JavaScript",
} as const } as const;
/**
* Enumerates supported styling options or libraries.
*/
export const StyleEnum = { export const StyleEnum = {
Tailwind: 'Tailwind', Tailwind: "Tailwind",
} as const } as const;
/**
* Enumerates supported package managers.
*/
export const PackageManagerEnum = { export const PackageManagerEnum = {
Bun: 'Bun', Bun: "Bun",
PnPm: 'PnPm', PnPm: "PnPm",
Npm: 'Npm', Npm: "Npm",
Yarn: 'Yarn', Yarn: "Yarn",
} as const } as const;
// see: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/firefox-webext-browser/index.d.ts /**
* Defines the structure for browser-specific settings within a web extension manifest.
* This is particularly used for Firefox (gecko) extensions to specify properties like
* an extension ID, and minimum/maximum supported browser versions.
* The structure is based on common manifest extensions for Firefox.
* See: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings
* The link in the original code (// see: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/firefox-webext-browser/index.d.ts)
* also points to type definitions that include this structure.
*
* @property {object} [browser_specific_settings] - Container for browser-specific settings.
* @property {object} [browser_specific_settings.gecko] - Settings specific to Gecko-based browsers (e.g., Firefox).
* @property {string} [browser_specific_settings.gecko.id] - The unique identifier for the extension in Firefox.
* @property {string} [browser_specific_settings.gecko.strict_min_version] - The minimum version of Firefox the extension is compatible with.
* @property {string} [browser_specific_settings.gecko.strict_max_version] - The maximum version of Firefox the extension is compatible with.
*/
export type BrowserSpecificSettings = { export type BrowserSpecificSettings = {
browser_specific_settings?: { browser_specific_settings?: {
gecko?: { gecko?: {
id: string id: string;
strict_min_version?: string strict_min_version?: string;
strict_max_version?: string strict_max_version?: string;
} };
} };
} };
export type Manifest = ManifestV3Export /**
export type ManifestIcons = chrome.runtime.ManifestIcons * Represents the structure of a Chrome Manifest V3 file.
export type ManifestBackground = chrome.runtime.ManifestV3['background'] * This type is an alias for `ManifestV3Export` from the `@crxjs/vite-plugin`,
* which provides a comprehensive definition for Chrome extension manifests.
*/
export type Manifest = ManifestV3Export;
/** Alias for the `icons` property within a Chrome Manifest V3. */
export type ManifestIcons = chrome.runtime.ManifestIcons;
/** Alias for the `background` property within a Chrome Manifest V3. */
export type ManifestBackground = chrome.runtime.ManifestV3["background"];
/** Alias for the `content_scripts` property within a Chrome Manifest V3. */
export type ManifestContentScripts = export type ManifestContentScripts =
chrome.runtime.ManifestV3['content_scripts'] chrome.runtime.ManifestV3["content_scripts"];
/** Alias for the `web_accessible_resources` property within a Chrome Manifest V3. */
export type ManifestWebAccessibleResources = export type ManifestWebAccessibleResources =
chrome.runtime.ManifestV3['web_accessible_resources'] chrome.runtime.ManifestV3["web_accessible_resources"];
export type ManifestCommands = chrome.runtime.ManifestV3['commands'] /** Alias for the `commands` property within a Chrome Manifest V3. */
export type ManifestAction = chrome.runtime.ManifestV3['action'] export type ManifestCommands = chrome.runtime.ManifestV3["commands"];
export type ManifestPermissions = chrome.runtime.ManifestV3['permissions'] /** Alias for the `action` property (or `browser_action`/`page_action`) within a Chrome Manifest V3. */
export type ManifestOptionsUI = chrome.runtime.ManifestV3['options_ui'] export type ManifestAction = chrome.runtime.ManifestV3["action"];
/** Alias for the `permissions` property within a Chrome Manifest V3. */
export type ManifestPermissions = chrome.runtime.ManifestV3["permissions"];
/** Alias for the `options_ui` property within a Chrome Manifest V3. */
export type ManifestOptionsUI = chrome.runtime.ManifestV3["options_ui"];
/** Alias for the `chrome_url_overrides` property within a Chrome Manifest V3. */
export type ManifestURLOverrides = export type ManifestURLOverrides =
chrome.runtime.ManifestV3['chrome_url_overrides'] chrome.runtime.ManifestV3["chrome_url_overrides"];
export type BrowserName<T extends string> = Capitalize<T> | Lowercase<T> /**
* Creates a type that accepts a string literal `T` in either its capitalized or lowercase form.
* Useful for defining types that should be case-insensitive for specific known strings.
* @template T - A string literal type.
*/
export type BrowserName<T extends string> = Capitalize<T> | Lowercase<T>;
/**
* Creates a record type where both keys and values are derived from a string literal `T`,
* specifically using `BrowserName<T>` which allows for capitalized or lowercase forms.
* This could be used to define an object where, for example, keys are 'Chrome' or 'chrome'
* and values are also 'Chrome' or 'chrome'.
* @template T - A string literal type, typically representing a browser name.
*/
export type BrowserEnumType<T extends string> = { export type BrowserEnumType<T extends string> = {
[browser in BrowserName<T>]: BrowserName<T> [browser in BrowserName<T>]: BrowserName<T>;
} };
export type BuildMode = AnyCase<Browser> /**
* Represents the target browser for a build, allowing for various casings of browser names
* (e.g., "chrome", "Chrome", "CHROME") through the `AnyCase<Browser>` utility type.
* `Browser` itself is a union of specific browser name strings (e.g., "Chrome" | "Firefox").
*/
export type BuildMode = AnyCase<Browser>;
/**
* Defines an object structure that pairs a web extension `Manifest`
* with its target `browser` (represented as `AnyCase<Browser>`).
* This is commonly used in build processes to manage configurations for different browsers.
*/
export type BuildTarget = { export type BuildTarget = {
manifest: Manifest manifest: Manifest;
browser: AnyCase<Browser> browser: AnyCase<Browser>;
} };
/**
* Defines the configuration options for a build process.
* @property {"build" | "serve"} [command] - The type of build command (e.g., 'build' for production, 'serve' for development).
* @property {AnyCase<Browser> | string | undefined} [mode] - The target build mode, typically a browser name (allowing various casings)
* or potentially other custom mode strings.
*/
export type BuildConfig = { export type BuildConfig = {
command?: 'build' | 'serve' command?: "build" | "serve";
mode?: AnyCase<Browser> | string | undefined mode?: AnyCase<Browser> | string | undefined;
} };
/**
* Defines the structure for repository information, commonly found in `package.json`.
* @property {string} type - The type of the repository (e.g., "git").
* @property {string} [url] - The URL of the repository.
* @property {Bugs} [bugs] - An object containing information about where to report bugs.
*/
export interface Repository { export interface Repository {
type: string type: string;
url?: string url?: string;
bugs?: Bugs bugs?: Bugs;
} }
/**
* Defines the structure for bug reporting information, often part of the `Repository` interface.
* @property {string} [url] - The URL of the issue tracker.
* @property {string} [email] - The email address for reporting bugs.
*/
export interface Bugs { export interface Bugs {
url?: string url?: string;
email?: string email?: string;
} }
export type Browser = (typeof BrowserEnum)[keyof typeof BrowserEnum] /**
export const Browser: AnyCase<Browser> = createEnum(BrowserEnum) * A string literal union type representing supported browser names, derived from the values of `BrowserEnum`.
* e.g., "Chrome" | "Firefox" | ...
*/
export type Browser = ObjectValues<typeof BrowserEnum>;
export type PackageManager = /**
(typeof PackageManagerEnum)[keyof typeof PackageManagerEnum] * A constant intended to provide access to browser names, potentially in various casings.
* Its type `AnyCase<Browser>` suggests it can be used where case-insensitivity for browser names is needed.
* The `createEnum(BrowserEnum)` call aims to produce a representation of browser names from `BrowserEnum`.
* Note: `createEnum` from `lib/utils.ts` has a declared return type of `ObjectValues<T>` (a union of values),
* while its implementation uses `Object.values()` which returns an array. This constant will hold the
* runtime array value, but its JSDoc type refers to the more restrictive `AnyCase<Browser>` union type.
*/
export const Browser: AnyCase<Browser> = createEnum(BrowserEnum);
/**
* A string literal union type representing supported package managers, derived from the values of `PackageManagerEnum`.
* e.g., "Bun" | "PnPm" | "Npm" | "Yarn"
*/
export type PackageManager = ObjectValues<typeof PackageManagerEnum>;
/**
* A constant intended to provide access to package manager names, potentially in various casings.
* Its type `AnyCase<PackageManager>` suggests it can be used where case-insensitivity for package manager names is needed.
* Utilizes `createEnum(PackageManagerEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const PackageManager: AnyCase<PackageManager> = export const PackageManager: AnyCase<PackageManager> =
createEnum(PackageManagerEnum) createEnum(PackageManagerEnum);
export type Framework = (typeof FrameworkEnum)[keyof typeof FrameworkEnum] /**
export const Framework: AnyCase<Framework> = createEnum(FrameworkEnum) * A string literal union type representing supported JavaScript frameworks, derived from the values of `FrameworkEnum`.
* e.g., "React" | "Vanilla" | ...
*/
export type Framework = ObjectValues<typeof FrameworkEnum>;
/**
* A constant intended to provide access to framework names, potentially in various casings.
* Its type `AnyCase<Framework>` suggests it can be used where case-insensitivity for framework names is needed.
* Utilizes `createEnum(FrameworkEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const Framework: AnyCase<Framework> = createEnum(FrameworkEnum);
export type Style = (typeof StyleEnum)[keyof typeof StyleEnum] /**
export const Style: AnyCase<Style> = createEnum(StyleEnum) * A string literal union type representing supported styling options, derived from the values of `StyleEnum`.
* e.g., "Tailwind"
*/
export type Style = ObjectValues<typeof StyleEnum>;
/**
* A constant intended to provide access to style option names, potentially in various casings.
* Its type `AnyCase<Style>` suggests it can be used where case-insensitivity for style names is needed.
* Utilizes `createEnum(StyleEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const Style: AnyCase<Style> = createEnum(StyleEnum);
export type Language = (typeof LanguageEnum)[keyof typeof LanguageEnum] /**
export const Language: AnyCase<Language> = createEnum(LanguageEnum) * A string literal union type representing supported programming languages, derived from the values of `LanguageEnum`.
* e.g., "TypeScript" | "JavaScript"
*/
export type Language = ObjectValues<typeof LanguageEnum>;
/**
* A constant intended to provide access to programming language names, potentially in various casings.
* Its type `AnyCase<Language>` suggests it can be used where case-insensitivity for language names is needed.
* Utilizes `createEnum(LanguageEnum)`. Refer to notes on `Browser` constant regarding `createEnum` behavior.
*/
export const Language: AnyCase<Language> = createEnum(LanguageEnum);
+69 -6
View File
@@ -1,21 +1,84 @@
export type ObjectValues<T> = T[keyof T] /**
* Extracts a union type of all values from the properties of an object type `T`.
*
* @template T - An object type (typically a Record or an enum-like object).
* @example
* type MyObject = { a: "foo", b: "bar", c: 123 };
* type MyObjectValues = ObjectValues<MyObject>; // "foo" | "bar" | 123
*/
export type ObjectValues<T> = T[keyof T];
/**
* Creates a union of an object's string values, often used to represent the set of possible values for an enum-like object.
* Note: The implementation `Object.values(enumObj) as unknown as ObjectValues<T>` returns an array at runtime,
* but the declared return type `ObjectValues<T>` is a union of the object's property values.
* This type signature suggests it's intended to represent the set of possible string values from `enumObj`.
*
* @template T - An object type where keys are strings and values are strings (e.g., `const MyEnum = { VAL_A: "A", VAL_B: "B" }`).
* @param {T} enumObj - The object from which to extract values.
* @returns {ObjectValues<T>} A union type representing all possible string values of the `enumObj`.
* For example, if `enumObj` is `{ A: "valA", B: "valB" }`, the return type is `"valA" | "valB"`.
* (Runtime behavior of `Object.values()` is to return an array like `["valA", "valB"]`).
*/
export function createEnum<T extends Record<string, string>>(enumObj: T) { export function createEnum<T extends Record<string, string>>(enumObj: T) {
return Object.values(enumObj) as unknown as ObjectValues<T> return Object.values(enumObj) as unknown as ObjectValues<T>;
} }
/**
* Creates a union type that includes various case formats (uppercase, lowercase, capitalized, uncapitalized)
* of a given string literal type `T`.
*
* @template T - A string literal type.
* @example
* type MyString = "example";
* type MyStringAnyCase = AnyCase<MyString>; // "EXAMPLE" | "example" | "Example" | "example" (Uncapitalize<"Example"> is "example")
*/
export type AnyCase<T extends string> = export type AnyCase<T extends string> =
| Uppercase<T> | Uppercase<T>
| Lowercase<T> | Lowercase<T>
| Capitalize<T> | Capitalize<T>
| Uncapitalize<T> | Uncapitalize<T>;
/**
* Creates a union type that includes various case formats (uppercase, lowercase, capitalized, uncapitalized)
* of the union of two given string literal types `T` and `K`.
* This is useful for representing a combined set of related string constants where case variations are permitted for each.
*
* @template T - A string literal type.
* @template K - Another string literal type.
* @example
* type Lang1 = "english";
* type Lang2 = "french";
* type CombinedLangsAnyCase = AnyCaseLanguage<Lang1, Lang2>;
* // Result includes: "ENGLISH" | "english" | "English" | "FRENCH" | "french" | "French" etc.
* // for all case variations of "english" and "french".
*/
export type AnyCaseLanguage<T extends string, K extends string> = export type AnyCaseLanguage<T extends string, K extends string> =
| Uppercase<T | K> | Uppercase<T | K>
| Lowercase<T | K> | Lowercase<T | K>
| Capitalize<T | K> | Capitalize<T | K>
| Uncapitalize<T | K> | Uncapitalize<T | K>;
/**
* Extracts a new object type containing only the keys of `T` whose properties are optional
* (i.e., their type includes `undefined`). The values associated with these keys retain their original types.
*
* @template T - An object type.
* @example
* type MyObject = {
* requiredProp: string;
* optionalProp?: number;
* anotherOptional?: boolean | undefined;
* nullProp: string | null;
* };
* type MyOptionalProps = OptionalKeys<MyObject>;
* // MyOptionalProps would be conceptually equivalent to:
* // {
* // optionalProp?: number;
* // anotherOptional?: boolean | undefined;
* // }
* // The actual resulting type is an object type with only these optional keys.
*/
export type OptionalKeys<T> = { export type OptionalKeys<T> = {
[K in keyof T as undefined extends T[K] ? K : never]: T[K] [K in keyof T as undefined extends T[K] ? K : never]: T[K];
} };
+8 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"version": "3.4.6", "version": "3.4.8",
"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",
@@ -36,7 +36,7 @@
"@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.91",
"@crxjs/vite-plugin": "2.0.0-beta.25", "@crxjs/vite-plugin": "2.0.0-beta.32",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@@ -75,15 +75,21 @@
"@uiw/codemirror-extensions-color": "^4.23.10", "@uiw/codemirror-extensions-color": "^4.23.10",
"@uiw/codemirror-theme-github": "^4.23.10", "@uiw/codemirror-theme-github": "^4.23.10",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"canvas-confetti": "^1.9.3",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"color": "^5.0.0", "color": "^5.0.0",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"embeddia": "^1.2.1",
"embla-carousel-autoplay": "^8.5.2", "embla-carousel-autoplay": "^8.5.2",
"embla-carousel-svelte": "^8.5.2", "embla-carousel-svelte": "^8.5.2",
"esbuild": "^0.25.3",
"events": "^3.3.0",
"flexsearch": "^0.8.147",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"idb": "^8.0.2", "idb": "^8.0.2",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mathjs": "^14.4.0",
"million": "^3.1.11", "million": "^3.1.11",
"motion": "^12.4.12", "motion": "^12.4.12",
"postcss": "^8.5.3", "postcss": "^8.5.3",
+50 -29
View File
@@ -1,62 +1,83 @@
import { import {
initializeSettingsState,
settingsState, settingsState,
} from "@/seqta/utils/listeners/SettingsState" } from "@/seqta/utils/listeners/SettingsState";
import documentLoadCSS from "@/css/documentload.scss?inline" import documentLoadCSS from "@/css/documentload.scss?inline";
import icon48 from "@/resources/icons/icon-48.png?base64" import icon48 from "@/resources/icons/icon-48.png?base64";
import browser from "webextension-polyfill" import browser from "webextension-polyfill";
import * as plugins from "@/plugins" import * as plugins from "@/plugins";
import { main } from "@/seqta/main" import { main } from "@/seqta/main";
import { delay } from "./seqta/utils/delay";
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
export let MenuOptionsOpen = false;
export let MenuOptionsOpen = false var IsSEQTAPage = false;
let hasSEQTAText = false;
var IsSEQTAPage = false
let hasSEQTAText = false
// This check is placed outside of the document load event due to issues with EP (https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/84) // This check is placed outside of the document load event due to issues with EP (https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/84)
if (document.childNodes[1]) { if (document.childNodes[1]) {
hasSEQTAText = hasSEQTAText =
document.childNodes[1].textContent?.includes( document.childNodes[1].textContent?.includes(
"Copyright (c) SEQTA Software", "Copyright (c) SEQTA Software",
) ?? false ) ?? false;
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") const hasSEQTATitle = document.title.includes("SEQTA Learn");
if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) { // Verify we are on a SEQTA page if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) {
IsSEQTAPage = true // Verify we are on a SEQTA page
console.info("[BetterSEQTA+] Verified SEQTA Page") IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page");
const documentLoadStyle = document.createElement("style") const documentLoadStyle = document.createElement("style");
documentLoadStyle.textContent = documentLoadCSS documentLoadStyle.textContent = documentLoadCSS;
document.head.appendChild(documentLoadStyle) document.head.appendChild(documentLoadStyle);
const icon = document.querySelector('link[rel*="icon"]')! as HTMLLinkElement const icon = document.querySelector(
icon.href = icon48 // Change the icon 'link[rel*="icon"]',
)! as HTMLLinkElement;
icon.href = icon48; // Change the icon
try { try {
await initializeSettingsState();
if (typeof settingsState.onoff === "undefined") { if (typeof settingsState.onoff === "undefined") {
browser.runtime.sendMessage({ type: "setDefaultStorage" }) await browser.runtime.sendMessage({ type: "setDefaultStorage" });
await delay(5);
} }
await main() await main();
plugins.Monofile();
if (settingsState.onoff) { if (settingsState.onoff) {
// Initialize legacy plugins
plugins.Monofile()
// Initialize new plugin system
await plugins.initializePlugins(); await plugins.initializePlugins();
} }
initializeHideSensitiveToggle();
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: any) {
console.error(error) console.error(error);
} }
} }
} }
+69 -84
View File
@@ -1,63 +1,68 @@
import browser from 'webextension-polyfill' import browser from "webextension-polyfill";
import type { SettingsState } from "@/types/storage"; import type { SettingsState } from "@/types/storage";
import { fetchNews } from './background/news'; import { fetchNews } from "./background/news";
function reloadSeqtaPages() { function reloadSeqtaPages() {
const result = browser.tabs.query({}) const result = browser.tabs.query({});
function open (tabs: any) { function open(tabs: any) {
for (let tab of tabs) { for (let tab of tabs) {
if (tab.title.includes('SEQTA Learn')) { if (tab.title.includes("SEQTA Learn")) {
browser.tabs.reload(tab.id); browser.tabs.reload(tab.id);
} }
} }
} }
result.then(open, console.error) result.then(open, console.error);
} }
// @ts-ignore // @ts-ignore
browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (response?: any) => void) => { browser.runtime.onMessage.addListener(
(request: any, _: any, sendResponse: (response?: any) => void) => {
switch (request.type) { switch (request.type) {
case 'reloadTabs': case "reloadTabs":
reloadSeqtaPages(); reloadSeqtaPages();
break; break;
case 'extensionPages': case "extensionPages":
browser.tabs.query({}).then(function (tabs) { browser.tabs.query({}).then(function (tabs) {
for (let tab of tabs) { for (let tab of tabs) {
if (tab.url?.includes('chrome-extension://')) { if (tab.url?.includes("chrome-extension://")) {
browser.tabs.sendMessage(tab.id!, request); browser.tabs.sendMessage(tab.id!, request);
} }
} }
}); });
break; break;
case 'currentTab': case "currentTab":
browser.tabs.query({ active: true, currentWindow: true }).then(function (tabs) { browser.tabs
browser.tabs.sendMessage(tabs[0].id!, request).then(function (response) { .query({ active: true, currentWindow: true })
.then(function (tabs) {
browser.tabs
.sendMessage(tabs[0].id!, request)
.then(function (response) {
sendResponse(response); sendResponse(response);
}); });
}); });
return true; return true;
case 'githubTab': case "githubTab":
browser.tabs.create({ url: 'github.com/BetterSEQTA/BetterSEQTA-Plus' }); browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
break; break;
case 'setDefaultStorage': case "setDefaultStorage":
SetStorageValue(DefaultValues); SetStorageValue(DefaultValues);
break; break;
case 'sendNews': case "sendNews":
fetchNews(request.source ?? 'australia', sendResponse); fetchNews(request.source ?? "australia", sendResponse);
return true; return true;
default: default:
console.log('Unknown request type'); console.log("Unknown request type");
} }
return false; return false;
}); },
);
const DefaultValues: SettingsState = { const DefaultValues: SettingsState = {
onoff: true, onoff: true,
@@ -86,66 +91,31 @@ const DefaultValues: SettingsState = {
}, },
menuorder: [], menuorder: [],
subjectfilters: {}, subjectfilters: {},
selectedTheme: '', selectedTheme: "",
selectedColor: 'linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)', selectedColor:
originalSelectedColor: '', "linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)",
originalSelectedColor: "",
DarkMode: true, DarkMode: true,
animations: true, animations: true,
assessmentsAverage: true, assessmentsAverage: true,
defaultPage: 'home', defaultPage: "home",
shortcuts: [ shortcuts: [
{ {
name: 'YouTube', name: "Outlook",
enabled: false,
},
{
name: 'Outlook',
enabled: true, enabled: true,
}, },
{ {
name: 'Office', name: "Office",
enabled: true, enabled: true,
}, },
{ {
name: 'Spotify', name: "Google",
enabled: false,
},
{
name: 'Google',
enabled: true, enabled: true,
}, },
{
name: 'DuckDuckGo',
enabled: false,
},
{
name: 'Cool Math Games',
enabled: false,
},
{
name: 'SACE',
enabled: false,
},
{
name: 'Google Scholar',
enabled: false,
},
{
name: 'Gmail',
enabled: false,
},
{
name: 'Netflix',
enabled: false,
},
{
name: 'Education Perfect',
enabled: false,
},
], ],
customshortcuts: [], customshortcuts: [],
lettergrade: false, lettergrade: false,
newsSource: 'australia', newsSource: "australia",
}; };
function SetStorageValue(object: any) { function SetStorageValue(object: any) {
@@ -158,7 +128,8 @@ function convertBksliderToSpeed(bksliderinput: number): number {
const minBase = 50; const minBase = 50;
const maxBase = 150; const maxBase = 150;
const scaledValue = 2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4; const scaledValue =
2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4;
const baseSpeed = 3; const baseSpeed = 3;
const speed = baseSpeed / scaledValue; const speed = baseSpeed / scaledValue;
@@ -166,50 +137,64 @@ function convertBksliderToSpeed(bksliderinput: number): number {
} }
async function migrateLegacySettings() { async function migrateLegacySettings() {
const storage = await browser.storage.local.get(null) as unknown as SettingsState; const storage = (await browser.storage.local.get(
null,
)) as unknown as SettingsState;
// Animated Background Migration // Animated Background Migration
if ('animatedbk' in storage || 'bksliderinput' in storage) { if ("animatedbk" in storage || "bksliderinput" in storage) {
const animatedSettings = { const animatedSettings = {
enabled: storage.animatedbk ?? true, enabled: storage.animatedbk ?? true,
speed: storage.bksliderinput ? convertBksliderToSpeed(parseFloat(storage.bksliderinput)) : 1 speed: storage.bksliderinput
? convertBksliderToSpeed(parseFloat(storage.bksliderinput))
: 1,
}; };
await browser.storage.local.set({ 'plugin.animated-background.settings': animatedSettings }); await browser.storage.local.set({
"plugin.animated-background.settings": animatedSettings,
});
} }
// Assessments Average Migration // Assessments Average Migration
if ('assessmentsAverage' in storage || 'lettergrade' in storage) { if ("assessmentsAverage" in storage || "lettergrade" in storage) {
const assessmentsSettings = { const assessmentsSettings = {
enabled: storage.assessmentsAverage ?? true, enabled: storage.assessmentsAverage ?? true,
lettergrade: storage.lettergrade ?? false lettergrade: storage.lettergrade ?? false,
}; };
await browser.storage.local.set({ 'plugin.assessments-average.settings': assessmentsSettings }); await browser.storage.local.set({
"plugin.assessments-average.settings": assessmentsSettings,
});
} }
if ('selectedTheme' in storage) { if ("selectedTheme" in storage) {
const themesSettings = { enabled: true }; const themesSettings = { enabled: true };
await browser.storage.local.set({ 'plugin.themes.settings': themesSettings }); await browser.storage.local.set({
"plugin.themes.settings": themesSettings,
});
} }
if (storage.notificationCollector !== false) { if (storage.notificationCollector !== false) {
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: true } }); await browser.storage.local.set({
"plugin.notificationCollector.settings": { enabled: true },
});
} else { } else {
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: false } }); await browser.storage.local.set({
"plugin.notificationCollector.settings": { enabled: false },
});
} }
const keysToRemove = [ const keysToRemove = [
'animatedbk', "animatedbk",
'bksliderinput', "bksliderinput",
'assessmentsAverage', "assessmentsAverage",
'lettergrade' "lettergrade",
]; ];
await browser.storage.local.remove(keysToRemove); await browser.storage.local.remove(keysToRemove);
} }
browser.runtime.onInstalled.addListener(function (event) { browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.remove(['justupdated']); browser.storage.local.remove(["justupdated"]);
browser.storage.local.remove(['data']); browser.storage.local.remove(["data"]);
if ( event.reason == 'install' || event.reason == 'update' ) { if (event.reason == "install" || event.reason == "update") {
browser.storage.local.set({ justupdated: true }); browser.storage.local.set({ justupdated: true });
migrateLegacySettings(); migrateLegacySettings();
} }
+62 -20
View File
@@ -1,17 +1,36 @@
import Parser from 'rss-parser'; import Parser from "rss-parser";
/**
* Fetches news articles specifically for Australia from the NewsAPI.
*
* This function handles a specific case for fetching Australian news. It includes a
* mechanism to retry the fetch operation by appending "%00" to the URL if a
* rate limit error (`response.code == "rateLimited"`) is encountered. This is
* likely a workaround for cache-busting or bypassing certain rate-limiting measures.
*
* @param {string} url The NewsAPI URL to fetch Australian news from.
* @param {any} sendResponse A callback function (likely from a browser extension message listener)
* to send the fetched news data back to the caller.
* It's called with an object like `{ news: responseData }`.
*/
const fetchAustraliaNews = async (url: string, sendResponse: any) => { const fetchAustraliaNews = async (url: string, sendResponse: any) => {
fetch(url) fetch(url)
.then((result) => result.json()) .then((result) => result.json())
.then((response) => { .then((response) => {
if (response.code == 'rateLimited') { if (response.code == "rateLimited") {
fetchAustraliaNews(url += '%00', sendResponse); fetchAustraliaNews((url += "%00"), sendResponse);
} else { } else {
sendResponse({ news: response }); sendResponse({ news: response });
} }
}); });
}; };
/**
* A record mapping lowercase country codes (e.g., "usa", "canada") to an array
* of RSS feed URLs for news sources in that country.
*
* @type {Record<string, string[]>}
*/
const rssFeedsByCountry: Record<string, string[]> = { const rssFeedsByCountry: Record<string, string[]> = {
usa: [ usa: [
"https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml", "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml",
@@ -19,20 +38,25 @@ const rssFeedsByCountry: Record<string, string[]> = {
"https://www.npr.org/rss/rss.php", "https://www.npr.org/rss/rss.php",
], ],
taiwan: [ taiwan: [
"https://focustaiwan.tw/rss", "https://news.ltn.com.tw/rss/all.xml",
"https://www.taipeitimes.com/rss/all.xml", "https://www.taipeitimes.com/xml/index.rss",
"https://international.thenewslens.com/rss", "https://international.thenewslens.com/rss",
], ],
hong_kong: [ hong_kong: [
"https://news.rthk.hk/rthk/en/rss.htm", "https://rthk9.rthk.hk/rthk/news/rss/e_expressnews_elocal.xml",
"https://www.scmp.com/rss/91/feed", "https://www.scmp.com/rss/91/feed",
], ],
panama: [ panama: [
"http://www.panama-guide.com/backend.php", "https://critica.com.pa/rss.xml",
"https://www.panamaamerica.com.pa/rss.xml",
"https://noticiassin.com/feed/",
"https://elcapitalfinanciero.com/feed/",
], ],
canada: [ canada: [
"https://www.cbc.ca/cmlink/rss-topstories", "https://www.cbc.ca/cmlink/rss-topstories",
"https://www.theglobeandmail.com/?service=rss", "https://calgaryherald.com/feed",
"https://ottawacitizen.com/feed",
"https://www.montrealgazette.com/feed",
], ],
singapore: [ singapore: [
"https://www.straitstimes.com/news/singapore/rss.xml", "https://www.straitstimes.com/news/singapore/rss.xml",
@@ -43,28 +67,40 @@ const rssFeedsByCountry: Record<string, string[]> = {
"https://www.theguardian.com/uk/rss", "https://www.theguardian.com/uk/rss",
], ],
japan: [ japan: [
"https://www.japantimes.co.jp/feed/topstories.xml",
"https://www3.nhk.or.jp/nhkworld/en/news/feeds/", "https://www3.nhk.or.jp/nhkworld/en/news/feeds/",
"https://news.livedoor.com/topics/rss/int.xml",
], ],
netherlands: [ netherlands: ["https://www.dutchnews.nl/feed/", "https://www.nrc.nl/rss/"],
"https://www.dutchnews.nl/feed/",
"http://feeds.nos.nl/nosnieuwsalgemeen",
],
}; };
/**
* Fetches news articles based on a specified source.
*
* The source can be:
* 1. The string "australia": Fetches news from Australian sources via NewsAPI,
* handled by the `fetchAustraliaNews` function.
* 2. A lowercase country code (e.g., "usa", "canada"): Fetches news from a predefined
* list of RSS feeds for that country, as specified in `rssFeedsByCountry`.
* 3. A direct RSS feed URL (starting with "http"): Fetches news directly from this URL.
*
* The fetched articles are then sent back to the caller using the `sendResponse` callback.
*
* @param {string} source The news source identifier. This can be "australia", a
* lowercase country code, or a direct RSS feed URL.
* @param {any} sendResponse A callback function (typically from a browser extension
* message listener, like `chrome.runtime.onMessage`)
* used to send the fetched news data back to the caller.
* It's called with an object like `{ news: { articles: [...] } }`.
*/
export async function fetchNews(source: string, sendResponse: any) { export async function fetchNews(source: string, sendResponse: any) {
const parser = new Parser();
let feeds: string[];
console.log('fetchNews', source)
if (source === "australia") { if (source === "australia") {
const date = new Date(); const date = new Date();
const from = const from =
date.getFullYear() + date.getFullYear() +
'-' + "-" +
(date.getMonth() + 1) + (date.getMonth() + 1) +
'-' + "-" +
(date.getDate() - 5); (date.getDate() - 5);
const url = `https://newsapi.org/v2/everything?domains=abc.net.au&from=${from}&apiKey=17c0da766ba347c89d094449504e3080`; const url = `https://newsapi.org/v2/everything?domains=abc.net.au&from=${from}&apiKey=17c0da766ba347c89d094449504e3080`;
@@ -73,6 +109,10 @@ export async function fetchNews(source: string, sendResponse: any) {
return; return;
} }
const parser = new Parser();
let feeds: string[];
console.log("fetchNews", source);
if (rssFeedsByCountry[source.toLowerCase()]) { if (rssFeedsByCountry[source.toLowerCase()]) {
// If the source is a country, fetch from predefined feeds // If the source is a country, fetch from predefined feeds
feeds = rssFeedsByCountry[source.toLowerCase()]; feeds = rssFeedsByCountry[source.toLowerCase()];
@@ -80,7 +120,9 @@ export async function fetchNews(source: string, sendResponse: any) {
// If the source is a URL, use it directly // If the source is a URL, use it directly
feeds = [source]; feeds = [source];
} else { } else {
throw new Error("Invalid source. Provide a country code or a valid RSS feed URL."); throw new Error(
"Invalid source. Provide a country code or a valid RSS feed URL.",
);
} }
const articlesPromises = feeds.map(async (feedUrl) => { const articlesPromises = feeds.map(async (feedUrl) => {
+58 -2
View File
@@ -15,7 +15,7 @@
* along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>. * along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>.
*/ */
@use 'injected/popup.scss'; @use "injected/popup.scss";
html { html {
background: #161616 !important; background: #161616 !important;
@@ -77,7 +77,9 @@ html {
transform-origin: top; transform-origin: top;
transition: transform 0.2s; transition: transform 0.2s;
} }
body:has(.outside-container:not(.hide)) #AddedSettings.tooltip:hover > .tooltiptext { body:has(.outside-container:not(.hide))
#AddedSettings.tooltip:hover
> .tooltiptext {
transform: scale(0); transform: scale(0);
} }
.assessmenttooltip svg { .assessmenttooltip svg {
@@ -92,3 +94,57 @@ body:has(.outside-container:not(.hide)) #AddedSettings.tooltip:hover > .tooltipt
background: var(--text-primary) !important; background: var(--text-primary) !important;
color: var(--theme-primary) !important; color: var(--theme-primary) !important;
} }
.fixed-tooltip {
display: inline-block;
z-index: 5 !important;
width: 28px;
background: none;
box-shadow: none;
padding: 2px;
position: absolute;
}
.fixed-tooltip svg {
fill: var(--theme-primary);
}
.tooltiptext-fixed {
width: 120px;
transform: scale(0);
transition: transform 0.2s;
transform-origin: top;
background: var(--background-primary);
color: var(--text-primary);
text-align: center;
border-radius: 6px;
padding: 2px;
position: fixed;
z-index: 1000;
top: 0;
left: 0;
margin-left: -62px;
}
.tooltiptext-fixed::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent transparent var(--text-primary) transparent;
}
.tooltiptext-fixed.show {
transform: scale(1);
transform-origin: top;
transition: transform 0.2s;
}
.tooltiptext-fixed p:hover {
cursor: pointer;
background: rgba(0, 0, 0, 0.3) !important;
transition: 200ms;
}
.tooltiptext-fixed p {
border-radius: 8px !important;
padding-top: 2px;
padding-bottom: 2px;
margin: 2px;
}
+1 -1
View File
@@ -1 +1 @@
import './documentload.scss'; import "./documentload.scss";
+11 -2
View File
@@ -15,7 +15,7 @@
* along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>. * along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>.
*/ */
body { body {
background: transparent; background: transparent;
} }
@@ -25,7 +25,9 @@
span, span,
body { body {
color: white !important; color: white !important;
text-shadow: 1px 1px 2px #161616, 0 0 1em #161616; text-shadow:
1px 1px 2px #161616,
0 0 1em #161616;
} }
body { body {
@@ -112,3 +114,10 @@
transition: text-shadow 0.5s; transition: text-shadow 0.5s;
} }
} }
.cke_panel_listItem > a {
&:hover {
background: #3d3d3e !important;
}
}
+676 -76
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -36,5 +36,7 @@
transform-origin: 70% 0; transform-origin: 70% 0;
will-change: opacity, transform; will-change: opacity, transform;
transform: translateZ(0); // promotes GPU rendering transform: translateZ(0); // promotes GPU rendering
transition: opacity 0.05s, transform 0.05s; transition:
opacity 0.05s,
transform 0.05s;
} }
+1 -1
View File
@@ -25,7 +25,7 @@
padding-top: 2px; padding-top: 2px;
} }
.sub:has(ul>li.hasChildren.active) > .nav > .back { .sub:has(ul > li.hasChildren.active) > .nav > .back {
display: none !important; display: none !important;
} }
+8 -12
View File
@@ -8,7 +8,6 @@ html.transparencyEffects:not(.dark) {
--background-secondary: rgba(229, 231, 235, 0.6); --background-secondary: rgba(229, 231, 235, 0.6);
} }
html.transparencyEffects { html.transparencyEffects {
/* Background Fixes */ /* Background Fixes */
[class*="notifications__item___"], [class*="notifications__item___"],
@@ -22,6 +21,9 @@ html.transparencyEffects {
} }
/* Blurs */ /* Blurs */
.search,
.document,
.border,
.draggable, .draggable,
.notice, .notice,
[class*="BasicPanel__BasicPanel___"], [class*="BasicPanel__BasicPanel___"],
@@ -37,34 +39,28 @@ html.transparencyEffects {
[class*="LabelList__selected___"], [class*="LabelList__selected___"],
.buttonChecklist, .buttonChecklist,
.pane, .pane,
.legacy-root button, .legacy-root a, .legacy-root button,
.legacy-root a,
[class*="MessageList__MessageList___"] { [class*="MessageList__MessageList___"] {
backdrop-filter: blur(80px); backdrop-filter: blur(80px);
} }
.filter-select,
.report { .report {
backdrop-filter: blur(10px) !important; backdrop-filter: blur(10px) !important;
} }
#menu,
.kanban-column,
.whatsnewContainer, .whatsnewContainer,
[class*="Message__Message___"] { [class*="Message__Message___"] {
backdrop-filter: blur(50px); backdrop-filter: blur(50px);
} }
#menu {
backdrop-filter: blur(20px);
}
.title > a { .title > a {
backdrop-filter: blur(0px) !important; backdrop-filter: blur(0px) !important;
} }
.search,
.document,
.border {
backdrop-filter: blur(80px);
}
#main > .dashboard { #main > .dashboard {
section, section,
.dashlet { .dashlet {
+11 -6
View File
@@ -1,9 +1,14 @@
declare module '*.mp4'; declare module "*.mp4";
declare module '*.woff'; declare module "*.woff";
declare module '*.scss'; declare module "*.scss";
declare module '*.png'; declare module "*.png";
declare module '*.html'; declare module "*.html";
declare module '*.svelte'; declare module "*.svelte";
declare module "*?inlineWorker" {
const value: () => Worker;
export default value;
}
declare module "*.png?base64" { declare module "*.png?base64" {
const value: string; const value: string;
+1 -1
View File
@@ -2,6 +2,6 @@
let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>(); let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>();
</script> </script>
<button onclick={onClick} class='px-4 py-1 text-[0.75rem] dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white rounded-md'> <button onclick={onClick} class='px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg'>
{text} {text}
</button> </button>
+3 -3
View File
@@ -81,20 +81,20 @@
</script> </script>
{#if standalone} {#if standalone}
<div class="h-auto rounded-xl overflow-clip"> <div class="h-auto overflow-clip rounded-xl">
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} /> <ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
</div> </div>
{:else} {:else}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
bind:this={background} bind:this={background}
class="absolute top-0 left-0 z-50 flex items-center justify-center w-full h-full cursor-pointer bg-black/20" class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full shadow-2xl cursor-pointer bg-black/20 border border-[#DDDDDD]/30 dark:border-[#38373D]/30"
onclick={handleBackgroundClick} onclick={handleBackgroundClick}
onkeydown={(e) => { e.key === 'Enter' && handleBackgroundClick }} onkeydown={(e) => { e.key === 'Enter' && handleBackgroundClick }}
> >
<div <div
bind:this={content} bind:this={content}
class="h-auto p-4 bg-white border shadow-lg cursor-auto rounded-xl dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700" class="p-4 h-auto bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
> >
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} /> <ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
</div> </div>
+42 -27
View File
@@ -1,6 +1,6 @@
import ColorPicker from "react-best-gradient-color-picker" import ColorPicker from "react-best-gradient-color-picker";
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react";
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts" import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts";
const defaultPresets = [ const defaultPresets = [
"linear-gradient(30deg, rgba(229,209,218,1) 0%, RGBA(235,169,202,1) 46%, rgba(214,155,162,1) 100%)", "linear-gradient(30deg, rgba(229,209,218,1) 0%, RGBA(235,169,202,1) 46%, rgba(214,155,162,1) 100%)",
@@ -22,12 +22,12 @@ const defaultPresets = [
"rgba(30, 64, 175, 0.89)", "rgba(30, 64, 175, 0.89)",
"rgba(134, 25, 143, 1)", "rgba(134, 25, 143, 1)",
"rgba(14, 165, 233, 0.9)", "rgba(14, 165, 233, 0.9)",
] ];
interface PickerProps { interface PickerProps {
customOnChange?: (color: string) => void customOnChange?: (color: string) => void;
customState?: string customState?: string;
savePresets?: boolean savePresets?: boolean;
} }
export default function Picker({ export default function Picker({
@@ -35,32 +35,44 @@ export default function Picker({
customState, customState,
savePresets = true, savePresets = true,
}: PickerProps) { }: PickerProps) {
const [customThemeColor, setCustomThemeColor] = useState<string | null>() const [customThemeColor, setCustomThemeColor] = useState<string | null>();
const [presets, setPresets] = useState<string[]>() const [presets, setPresets] = useState<string[]>();
const latestValuesRef = useRef({ customThemeColor, customOnChange, savePresets, presets }); const latestValuesRef = useRef({
customThemeColor,
customOnChange,
savePresets,
presets,
});
useEffect(() => { useEffect(() => {
if (customState !== undefined && customState !== null) { if (customState !== undefined && customState !== null) {
setCustomThemeColor(customState) setCustomThemeColor(customState);
} else { } else {
setCustomThemeColor(settingsState.selectedColor ?? null) setCustomThemeColor(settingsState.selectedColor ?? null);
} }
if (presets === undefined) { if (presets === undefined) {
const savedPresets = localStorage.getItem("colorPickerPresets") const savedPresets = localStorage.getItem("colorPickerPresets");
setPresets(savedPresets ? JSON.parse(savedPresets) : defaultPresets) setPresets(savedPresets ? JSON.parse(savedPresets) : defaultPresets);
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
latestValuesRef.current = { customThemeColor, customOnChange, savePresets, presets }; latestValuesRef.current = {
customThemeColor,
customOnChange,
savePresets,
presets,
};
}, [customThemeColor, customOnChange, savePresets, presets]); }, [customThemeColor, customOnChange, savePresets, presets]);
useEffect(() => { useEffect(() => {
return () => { return () => {
const { customThemeColor, customOnChange, savePresets, presets } = latestValuesRef.current; const { customThemeColor, customOnChange, savePresets, presets } =
if (!(customThemeColor && !customOnChange && savePresets && presets)) return; latestValuesRef.current;
if (!(customThemeColor && !customOnChange && savePresets && presets))
return;
// Only proceed if presets are different (avoid unnecessary updates) // Only proceed if presets are different (avoid unnecessary updates)
const existingIndex = presets.indexOf(customThemeColor); const existingIndex = presets.indexOf(customThemeColor);
@@ -79,15 +91,18 @@ export default function Picker({
updatedPresets = [customThemeColor, ...presets].slice(0, 18); updatedPresets = [customThemeColor, ...presets].slice(0, 18);
} }
localStorage.setItem("colorPickerPresets", JSON.stringify(updatedPresets)); localStorage.setItem(
} "colorPickerPresets",
}, []) JSON.stringify(updatedPresets),
);
};
}, []);
useEffect(() => { useEffect(() => {
if (customThemeColor && !customOnChange) { if (customThemeColor && !customOnChange) {
settingsState.selectedColor = customThemeColor settingsState.selectedColor = customThemeColor;
} }
}, [customThemeColor, customOnChange]) }, [customThemeColor, customOnChange]);
return ( return (
<ColorPicker <ColorPicker
@@ -97,12 +112,12 @@ export default function Picker({
value={customThemeColor ?? ""} value={customThemeColor ?? ""}
onChange={(color: string) => { onChange={(color: string) => {
if (customOnChange) { if (customOnChange) {
customOnChange(color) customOnChange(color);
setCustomThemeColor(color) setCustomThemeColor(color);
} else { } else {
setCustomThemeColor(color) setCustomThemeColor(color);
} }
}} }}
/> />
) );
} }
+227
View File
@@ -0,0 +1,227 @@
<script lang="ts">
import { isValidHotkey, parseHotkey } from '@/plugins/built-in/globalSearch/src/utils/hotkeyUtils';
let { value, onChange } = $props<{
value: string,
onChange: (newValue: string) => void
}>();
let isRecording = $state(false);
let recordedKeys = $state<Set<string>>(new Set());
let inputElement = $state<HTMLInputElement>();
const formatKeyForHotkey = (key: string): string => {
// Map special keys to their hotkey format
const keyMap: Record<string, string> = {
'Control': 'ctrl',
'Meta': 'cmd',
'Alt': 'alt',
'Shift': 'shift',
' ': 'space',
'ArrowUp': 'up',
'ArrowDown': 'down',
'ArrowLeft': 'left',
'ArrowRight': 'right',
'Escape': 'esc',
'Enter': 'enter',
'Tab': 'tab',
'Backspace': 'backspace',
'Delete': 'delete',
};
return keyMap[key] || key.toLowerCase();
};
const formatKeyForDisplay = (key: string): string => {
// Map keys to their display format
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const keyMap: Record<string, string> = {
'ctrl': isMac ? '⌃' : 'Ctrl',
'cmd': '⌘',
'meta': '⌘',
'alt': isMac ? '⌥' : 'Alt',
'shift': isMac ? '⇧' : 'Shift',
'space': 'Space',
'up': '↑',
'down': '↓',
'left': '←',
'right': '→',
'esc': 'Esc',
'enter': 'Enter',
'tab': 'Tab',
'backspace': 'Backspace',
'delete': 'Delete',
};
return keyMap[key.toLowerCase()] || key.toUpperCase();
};
const getHotkeyParts = (hotkeyString: string): string[] => {
if (!hotkeyString || !isValidHotkey(hotkeyString)) {
return [];
}
const parsed = parseHotkey(hotkeyString);
const parts: string[] = [];
// Add modifiers in a consistent order
if (parsed.ctrl) parts.push('ctrl');
if (parsed.meta) parts.push('cmd');
if (parsed.alt) parts.push('alt');
if (parsed.shift) parts.push('shift');
// Add the main key
if (parsed.key) parts.push(parsed.key);
return parts;
};
const startRecording = () => {
isRecording = true;
recordedKeys.clear();
inputElement?.focus();
};
const stopRecording = () => {
if (recordedKeys.size > 0) {
if (recordedKeys.has('esc')) {
onChange('');
isRecording = false;
recordedKeys.clear();
inputElement?.blur();
return;
}
// Build the hotkey string
const modifiers: string[] = [];
let mainKey = '';
for (const key of recordedKeys) {
if (['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
modifiers.push(key);
} else {
mainKey = key;
}
}
if (mainKey) {
const hotkeyString = [...modifiers, mainKey].join('+');
if (isValidHotkey(hotkeyString)) {
onChange(hotkeyString);
}
}
}
isRecording = false;
recordedKeys.clear();
inputElement?.blur();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isRecording) return;
e.preventDefault();
e.stopPropagation();
const key = formatKeyForHotkey(e.key);
// Add modifiers
if (e.ctrlKey) recordedKeys.add('ctrl');
if (e.metaKey) recordedKeys.add('cmd');
if (e.altKey) recordedKeys.add('alt');
if (e.shiftKey) recordedKeys.add('shift');
// Add the main key (ignore modifier keys themselves)
if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
recordedKeys.add(key);
}
// Auto-stop recording if we have a main key
if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
setTimeout(stopRecording, 100);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!isRecording) return;
e.preventDefault();
e.stopPropagation();
};
const handleBlur = () => {
if (isRecording) {
stopRecording();
}
};
$effect(() => {
if (isRecording && inputElement) {
inputElement.focus();
}
});
// Get the parts to display
const hotkeyParts = $derived(isRecording
? Array.from(recordedKeys).map(formatKeyForDisplay)
: getHotkeyParts(value).map(formatKeyForDisplay));
</script>
<div class="flex gap-2 items-center">
<div class="relative">
{#if isRecording}
<!-- Recording state -->
<div
class="flex items-center justify-center px-3 py-1.5 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white border cursor-pointer text-nowrap"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
Press keys...
</div>
{:else if hotkeyParts.length > 0}
<!-- Display current hotkey -->
<div
class="flex gap-1 items-center text-sm rounded-md border-none cursor-pointer dark:text-white"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
{#each hotkeyParts as part}
<div class="size-8 text-sm flex items-center justify-center rounded-md border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30">
{part}
</div>
{/each}
</div>
{:else}
<!-- Empty state -->
<div
class="flex items-center justify-center px-3 py-2 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD] dark:text-white border-none cursor-pointer text-nowrap"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
<span class="text-gray-500 dark:text-gray-400">Click to set</span>
</div>
{/if}
<!-- Hidden input for focus management -->
<input
bind:this={inputElement}
type="text"
readonly
class="absolute inset-0 opacity-0 pointer-events-none"
onkeydown={handleKeyDown}
onkeyup={handleKeyUp}
onblur={handleBlur}
/>
</div>
</div>
<style>
input:focus {
outline: none;
}
</style>
+2 -1
View File
@@ -5,7 +5,8 @@
</script> </script>
<button <button
aria-label="Color Picker Swatch"
onclick={onClick} onclick={onClick}
style="background: {$settingsState.selectedColor}" style="background: {$settingsState.selectedColor}"
class="w-16 h-8 rounded-md" class="w-16 h-8 rounded-md shadow-2xl ring-[1px] ring-[#DDDDDD]/30 dark:ring-[#38373D]/30"
></button> ></button>
+6 -4
View File
@@ -8,15 +8,17 @@
let select: HTMLSelectElement; let select: HTMLSelectElement;
</script> </script>
<select <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">
<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:bg-[#38373D] bg-[#DDDDDD] dark:text-white rounded-md w-full" 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"
> >
{#each options as option} {#each options as option}
<option value={option.value}> <option value={option.value}>
{option.label} {option.label}
</option> </option>
{/each} {/each}
</select> </select>
</div>
+3 -2
View File
@@ -16,9 +16,9 @@
max={max} max={max}
step={step} step={step}
bind:value={state} bind:value={state}
style={`background: linear-gradient(to right, #30D259 ${percentage}%, #dddddd ${percentage}%)`} style={`background: linear-gradient(to right, #30d259ad 0%, #30D259 ${percentage}%, #dddddd ${percentage}%)`}
onchange={(e) => onChange(Number(e.currentTarget.value))} onchange={(e) => onChange(Number(e.currentTarget.value))}
class="w-full h-1 rounded-full appearance-none cursor-pointer dark:bg-[#38373D] bg-[#DDDDDD] slider" class="w-full h-1 rounded-full appearance-none cursor-pointer slider"
/> />
</div> </div>
@@ -38,6 +38,7 @@
height: 24px; height: 24px;
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.3); box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.3);
background: white; background: white;
color: #30d259ad;
cursor: pointer; cursor: pointer;
border-radius: 50%; border-radius: 50%;
} }
+1 -1
View File
@@ -1,4 +1,4 @@
.dark .switch[data-ison="true"], .dark .switch[data-ison="true"],
.switch[data-ison="true"] { .switch[data-ison="true"] {
background-color: #30D259; background-color: #30d259;
} }
+1 -8
View File
@@ -30,8 +30,7 @@
</script> </script>
<div <div
class="flex w-14 p-1 cursor-pointer transition-all duration-150 rounded-full dark:bg-[#38373D] bg-[#DDDDDD] switch select-none" class="flex w-14 p-1 cursor-pointer transition-all duration-150 rounded-full bg-gradient-to-tr select-none shadow-2xl ring-[1px] ring-[#DDDDDD]/30 dark:ring-[#38373D]/30 {state ? 'to-[#30D259]/80 from-[#30D259] dark:from-[#30D259]/40 dark:to-[#30D259]' : 'dark:from-[#38373D]/50 dark:to-[#38373D] to-[#DDDDDD]/50 from-[#DDDDDD]'}"
data-ison={state}
onclick={() => onChange(!state)} onclick={() => onChange(!state)}
onkeydown={(e) => e.key === "Enter" && onChange(!state)} onkeydown={(e) => e.key === "Enter" && onChange(!state)}
role="switch" role="switch"
@@ -43,9 +42,3 @@
class="w-6 h-6 bg-white dark:bg-[#FEFEFE] rounded-full drop-shadow-md" class="w-6 h-6 bg-white dark:bg-[#FEFEFE] rounded-full drop-shadow-md"
></div> ></div>
</div> </div>
<style>
.switch[data-ison="true"] {
background-color: #30D259;
}
</style>
@@ -43,7 +43,7 @@
<div class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container"> <div class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container">
<div bind:this={containerRef} class="flex relative"> <div bind:this={containerRef} class="flex relative">
<MotionDiv <MotionDiv
class="absolute top-0 left-0 z-0 h-full bg-[#DDDDDD] dark:bg-[#38373D] rounded-full opacity-40 tab-width" class="absolute top-0 left-0 z-0 h-full bg-gradient-to-tr dark:from-[#38373D]/80 dark:to-[#38373D] from-[#DDDDDD]/80 to-[#DDDDDD] rounded-full opacity-40 tab-width"
animate={{ x: calcXPos(activeTab) }} animate={{ x: calcXPos(activeTab) }}
transition={springTransition} transition={springTransition}
/> />
@@ -65,8 +65,9 @@
> >
<div class="flex"> <div class="flex">
{#each tabs as { Content, props }, index} {#each tabs as { Content, props }, index}
<div class="absolute focus:outline-none w-full transition-opacity duration-300 overflow-y-scroll no-scrollbar h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}" <div class="absolute focus:outline-none w-full pt-2 transition-opacity duration-300 overflow-y-scroll no-scrollbar pb-2 h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}"
style="left: {index * 100}%;"> style="left: {index * 100}%;">
<div style="left: {index * 100}%;" class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
<Content {...props} /> <Content {...props} />
</div> </div>
{/each} {/each}
@@ -2,7 +2,7 @@
import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader'; import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader';
import Spinner from '../Spinner.svelte'; import Spinner from '../Spinner.svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState' import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import Fuse from 'fuse.js'; import { Index } from 'flexsearch';
import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates' import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager' import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
@@ -20,19 +20,12 @@
let savedBackgrounds = $state<string[]>([]); let savedBackgrounds = $state<string[]>([]);
let installingBackgrounds = $state<Set<string>>(new Set()); let installingBackgrounds = $state<Set<string>>(new Set());
let debugInfo = $state<string>(''); let debugInfo = $state<string>('');
let searchIndex = $state<Index | null>(null);
// New state variables // New state variables
let activeTab = $state<'all' | 'installed' | 'photos' | 'videos'>('all'); let activeTab = $state<'all' | 'installed' | 'photos' | 'videos'>('all');
let sortBy = $state<'newest' | 'popular' | 'name'>('newest'); let sortBy = $state<'newest' | 'popular' | 'name'>('newest');
// Add Fuse.js options
const fuseOptions = {
keys: ['name', 'description'],
threshold: 0.4,
ignoreLocation: true
};
let fuse: Fuse<Background>;
// Existing functions // Existing functions
const loadStore = async () => { const loadStore = async () => {
try { try {
@@ -43,7 +36,19 @@
} }
const data = await response.json(); const data = await response.json();
backgrounds = data.backgrounds; backgrounds = data.backgrounds;
fuse = new Fuse(backgrounds, fuseOptions);
// Initialize FlexSearch index
const index = new Index({
tokenize: "forward",
preset: "score"
});
// Add backgrounds to the index
backgrounds.forEach((bg, i) => {
index.add(i, bg.name + " " + bg.description);
});
searchIndex = index;
debugInfo = `Loaded ${backgrounds.length} backgrounds`; debugInfo = `Loaded ${backgrounds.length} backgrounds`;
await loadSavedBackgrounds(); await loadSavedBackgrounds();
} catch (e) { } catch (e) {
@@ -74,14 +79,10 @@
let filteredBackgrounds = $derived((() => { let filteredBackgrounds = $derived((() => {
let filtered = backgrounds; let filtered = backgrounds;
// Use Fuse.js search if there's a search term // Use FlexSearch if there's a search term
if (searchTerm.trim()) { if (searchTerm.trim() && searchIndex) {
// @ts-ignore const results = searchIndex.search(searchTerm) as number[];
if (fuse) { filtered = results.map(i => backgrounds[i]);
filtered = fuse.search(searchTerm).map((result: any) => result.item) ?? [];
} else {
filtered = backgrounds.filter(bg => bg.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
} }
// Apply category filtering // Apply category filtering
@@ -14,12 +14,14 @@
let isDragging = $state(false); let isDragging = $state(false);
let tempTheme = $state(null); let tempTheme = $state(null);
const handleThemeClick = async (theme: CustomTheme) => { const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
if (isEditMode) return; if (isEditMode) return;
if (theme.id === themes?.selectedTheme) { if (theme.id === themes?.selectedTheme) {
themeManager.setTransitionPoint(e.clientX, e.clientY);
await themeManager.disableTheme(); await themeManager.disableTheme();
themes.selectedTheme = ''; themes.selectedTheme = '';
} else { } else {
themeManager.setTransitionPoint(e.clientX, e.clientY);
await themeManager.setTheme(theme.id); await themeManager.setTheme(theme.id);
if (!themes) return; if (!themes) return;
themes.selectedTheme = theme.id; themes.selectedTheme = theme.id;
@@ -127,7 +129,7 @@
{#each themes.themes as theme (theme.id)} {#each themes.themes as theme (theme.id)}
<button <button
class="relative group w-full aspect-theme flex justify-center items-center rounded-xl transition ring dark:ring-white ring-zinc-300 {theme.id === themes.selectedTheme ? 'dark:ring-2 ring-4' : 'ring-0'}" class="relative group w-full aspect-theme flex justify-center items-center rounded-xl transition ring dark:ring-white ring-zinc-300 {theme.id === themes.selectedTheme ? 'dark:ring-2 ring-4' : 'ring-0'}"
onclick={() => handleThemeClick(theme)} onclick={(e) => handleThemeClick(theme, e)}
> >
{#if isEditMode} {#if isEditMode}
<div <div
+101 -18
View File
@@ -1,8 +1,20 @@
import { type DBSchema, type IDBPDatabase, openDB } from 'idb'; import { type DBSchema, type IDBPDatabase, openDB } from "idb";
/**
* Defines the schema for the IndexedDB database used for storing background image data.
*
* @interface BackgroundDB
* @extends {DBSchema}
* @property {object} backgrounds - The object store for background images.
* @property {string} backgrounds.key - The type of the key for the object store (in this case, it's `id` as defined in `keyPath`).
* @property {object} backgrounds.value - The structure of the objects stored.
* @property {string} backgrounds.value.id - The unique identifier for the background image record.
* @property {string} backgrounds.value.type - The MIME type of the image (e.g., "image/png", "image/jpeg").
* @property {Blob} backgrounds.value.blob - The binary large object (Blob) containing the image data.
*/
interface BackgroundDB extends DBSchema { interface BackgroundDB extends DBSchema {
backgrounds: { backgrounds: {
key: string; key: string; // Corresponds to the 'id' property due to keyPath: "id"
value: { value: {
id: string; id: string;
type: string; type: string;
@@ -13,43 +25,100 @@ interface BackgroundDB extends DBSchema {
let db: IDBPDatabase<BackgroundDB> | null = null; let db: IDBPDatabase<BackgroundDB> | null = null;
/**
* Initializes and opens an IndexedDB connection or returns an existing one.
* If the database doesn't exist or needs an upgrade, the `upgrade` callback
* creates the 'backgrounds' object store with 'id' as the keyPath.
*
* @async
* @returns {Promise<IDBPDatabase<BackgroundDB>>} A promise that resolves with the database instance.
*/
export async function openDatabase(): Promise<IDBPDatabase<BackgroundDB>> { export async function openDatabase(): Promise<IDBPDatabase<BackgroundDB>> {
if (db) return db; if (db) return db;
db = await openDB<BackgroundDB>('BackgroundDB', 1, { db = await openDB<BackgroundDB>("BackgroundDB", 1, {
upgrade(db: IDBPDatabase<BackgroundDB>) { upgrade(db: IDBPDatabase<BackgroundDB>) {
db.createObjectStore('backgrounds', { keyPath: 'id' }); db.createObjectStore("backgrounds", { keyPath: "id" });
}, },
}); });
return db; return db;
} }
export async function readAllData(): Promise<Array<{ id: string; type: string; blob: Blob }>> { /**
* Retrieves all background image records from the 'backgrounds' object store in IndexedDB.
*
* @async
* @returns {Promise<Array<{id: string, type: string, blob: Blob}>>} A promise that resolves with an array of all background image records.
*/
export async function readAllData(): Promise<
Array<{ id: string; type: string; blob: Blob }>
> {
const db = await openDatabase(); const db = await openDatabase();
return db.getAll('backgrounds'); return db.getAll("backgrounds");
} }
export async function writeData(id: string, type: string, blob: Blob): Promise<void> { /**
* Writes or updates a background image record in the 'backgrounds' object store.
* If a record with the given `id` already exists, it will be updated. Otherwise, a new record is created.
*
* @async
* @param {string} id - The unique identifier for the background image record.
* @param {string} type - The MIME type of the image (e.g., "image/png").
* @param {Blob} blob - The Blob object containing the image data.
* @returns {Promise<void>} A promise that resolves when the data has been successfully written.
*/
export async function writeData(
id: string,
type: string,
blob: Blob,
): Promise<void> {
const db = await openDatabase(); const db = await openDatabase();
await db.put('backgrounds', { id, type, blob }); await db.put("backgrounds", { id, type, blob });
} }
/**
* Deletes a background image record from the 'backgrounds' object store by its ID.
*
* @async
* @param {string} id - The unique identifier of the background image record to delete.
* @returns {Promise<void>} A promise that resolves when the data has been successfully deleted.
*/
export async function deleteData(id: string): Promise<void> { export async function deleteData(id: string): Promise<void> {
const db = await openDatabase(); const db = await openDatabase();
await db.delete('backgrounds', id); await db.delete("backgrounds", id);
} }
/**
* Clears all records from the 'backgrounds' object store in IndexedDB.
*
* @async
* @returns {Promise<void>} A promise that resolves when all data has been successfully cleared.
*/
export async function clearAllData(): Promise<void> { export async function clearAllData(): Promise<void> {
const db = await openDatabase(); const db = await openDatabase();
await db.clear('backgrounds'); await db.clear("backgrounds");
} }
export async function getDataById(id: string): Promise<{ id: string; type: string; blob: Blob } | undefined> { /**
* Retrieves a single background image record from the 'backgrounds' object store by its ID.
*
* @async
* @param {string} id - The unique identifier of the background image record to retrieve.
* @returns {Promise<{id: string, type: string, blob: Blob} | undefined>} A promise that resolves with the
* background image record if found, or undefined otherwise.
*/
export async function getDataById(
id: string,
): Promise<{ id: string; type: string; blob: Blob } | undefined> {
const db = await openDatabase(); const db = await openDatabase();
return db.get('backgrounds', id); return db.get("backgrounds", id);
} }
/**
* Closes the active IndexedDB connection and nullifies the global `db` variable.
* This is important to release resources and allow for proper database management.
*/
export function closeDatabase(): void { export function closeDatabase(): void {
if (db) { if (db) {
db.close(); db.close();
@@ -57,17 +126,31 @@ export function closeDatabase(): void {
} }
} }
// Helper function to check if IndexedDB is supported /**
* Checks if IndexedDB is supported by the current browser environment.
*
* @returns {boolean} True if IndexedDB is supported, false otherwise.
*/
export function isIndexedDBSupported(): boolean { export function isIndexedDBSupported(): boolean {
return 'indexedDB' in window; return "indexedDB" in window;
} }
// Helper function to check if there's enough storage space /**
export async function hasEnoughStorageSpace(requiredSpace: number): Promise<boolean> { * Estimates available storage space and checks if it's sufficient for the specified `requiredSpace`.
if ('storage' in navigator && 'estimate' in navigator.storage) { * Uses the `navigator.storage.estimate()` API if available.
* If the API is not available or cannot determine space, it defaults to assuming enough space is available.
*
* @async
* @param {number} requiredSpace - The amount of storage space required, in bytes.
* @returns {Promise<boolean>} A promise that resolves with true if enough space is estimated to be available, false otherwise.
*/
export async function hasEnoughStorageSpace(
requiredSpace: number,
): Promise<boolean> {
if ("storage" in navigator && "estimate" in navigator.storage) {
const { quota, usage } = await navigator.storage.estimate(); const { quota, usage } = await navigator.storage.estimate();
if (quota !== undefined && usage !== undefined) { if (quota !== undefined && usage !== undefined) {
return (quota - usage) > requiredSpace; return quota - usage > requiredSpace;
} }
} }
// If we can't determine, assume there's enough space // If we can't determine, assume there's enough space
+28 -1
View File
@@ -1,11 +1,21 @@
type BackgroundUpdateCallback = () => void; type BackgroundUpdateCallback = () => void;
/**
* A singleton class used to notify listeners about generic background updates or events.
* These updates typically signify that UI components or other parts of the application
* might need to refresh or re-evaluate background-related data (e.g., after a new background
* image is added, removed, or changed).
*/
class BackgroundUpdates { class BackgroundUpdates {
private static instance: BackgroundUpdates; private static instance: BackgroundUpdates;
private listeners: Set<BackgroundUpdateCallback> = new Set(); private listeners: Set<BackgroundUpdateCallback> = new Set();
private constructor() {} private constructor() {}
/**
* Gets the singleton instance of the BackgroundUpdates class.
* @returns {BackgroundUpdates} The singleton instance.
*/
public static getInstance(): BackgroundUpdates { public static getInstance(): BackgroundUpdates {
if (!BackgroundUpdates.instance) { if (!BackgroundUpdates.instance) {
BackgroundUpdates.instance = new BackgroundUpdates(); BackgroundUpdates.instance = new BackgroundUpdates();
@@ -13,16 +23,33 @@ class BackgroundUpdates {
return BackgroundUpdates.instance; return BackgroundUpdates.instance;
} }
/**
* Registers a callback function to be invoked when a background update is triggered.
*
* @param {BackgroundUpdateCallback} callback The function to call when a background update occurs.
* This callback takes no arguments and returns void.
*/
public addListener(callback: BackgroundUpdateCallback): void { public addListener(callback: BackgroundUpdateCallback): void {
this.listeners.add(callback); this.listeners.add(callback);
} }
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when a background update is triggered.
*
* @param {BackgroundUpdateCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: BackgroundUpdateCallback): void { public removeListener(callback: BackgroundUpdateCallback): void {
this.listeners.delete(callback); this.listeners.delete(callback);
} }
/**
* Invokes all registered listener callbacks, signifying that a background update has occurred.
* This method should be called whenever a change to background data happens that requires
* other parts of the application to be notified.
*/
public triggerUpdate(): void { public triggerUpdate(): void {
this.listeners.forEach(callback => callback()); this.listeners.forEach((callback) => callback());
} }
} }
+18 -2
View File
@@ -7,7 +7,7 @@ type SettingsPopupCallback = () => void;
* settingsPopup.addListener(() => { * settingsPopup.addListener(() => {
* console.log('Settings popup closed'); * console.log('Settings popup closed');
* }); * });
*/ */
class SettingsPopup { class SettingsPopup {
private static instance: SettingsPopup; private static instance: SettingsPopup;
private listeners: Set<SettingsPopupCallback> = new Set(); private listeners: Set<SettingsPopupCallback> = new Set();
@@ -21,16 +21,32 @@ class SettingsPopup {
return SettingsPopup.instance; return SettingsPopup.instance;
} }
/**
* Registers a callback function to be invoked when the settings popup is closed.
*
* @param {SettingsPopupCallback} callback The function to call when the settings popup closes.
* This callback takes no arguments and returns void.
*/
public addListener(callback: SettingsPopupCallback): void { public addListener(callback: SettingsPopupCallback): void {
this.listeners.add(callback); this.listeners.add(callback);
} }
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when the settings popup closes.
*
* @param {SettingsPopupCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: SettingsPopupCallback): void { public removeListener(callback: SettingsPopupCallback): void {
this.listeners.delete(callback); this.listeners.delete(callback);
} }
/**
* Invokes all registered listener callbacks.
* This method should be called when the settings popup is closed to notify all subscribed components or services.
*/
public triggerClose(): void { public triggerClose(): void {
this.listeners.forEach(callback => callback()); this.listeners.forEach((callback) => callback());
} }
} }
+28 -1
View File
@@ -1,11 +1,21 @@
type ThemeUpdateCallback = () => void; type ThemeUpdateCallback = () => void;
/**
* A singleton class used to notify listeners about theme-related updates.
* These updates can include events like theme changes, custom theme modifications,
* or any other event that might require UI components to refresh their appearance
* or re-apply theme styles.
*/
class ThemeUpdates { class ThemeUpdates {
private static instance: ThemeUpdates; private static instance: ThemeUpdates;
private listeners: Set<ThemeUpdateCallback> = new Set(); private listeners: Set<ThemeUpdateCallback> = new Set();
private constructor() {} private constructor() {}
/**
* Gets the singleton instance of the ThemeUpdates class.
* @returns {ThemeUpdates} The singleton instance.
*/
public static getInstance(): ThemeUpdates { public static getInstance(): ThemeUpdates {
if (!ThemeUpdates.instance) { if (!ThemeUpdates.instance) {
ThemeUpdates.instance = new ThemeUpdates(); ThemeUpdates.instance = new ThemeUpdates();
@@ -13,16 +23,33 @@ class ThemeUpdates {
return ThemeUpdates.instance; return ThemeUpdates.instance;
} }
/**
* Registers a callback function to be invoked when a theme update is triggered.
*
* @param {ThemeUpdateCallback} callback The function to call when a theme update occurs.
* This callback takes no arguments and returns void.
*/
public addListener(callback: ThemeUpdateCallback): void { public addListener(callback: ThemeUpdateCallback): void {
this.listeners.add(callback); this.listeners.add(callback);
} }
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when a theme update is triggered.
*
* @param {ThemeUpdateCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: ThemeUpdateCallback): void { public removeListener(callback: ThemeUpdateCallback): void {
this.listeners.delete(callback); this.listeners.delete(callback);
} }
/**
* Invokes all registered listener callbacks, signifying that a theme-related update has occurred.
* This method should be called whenever a change related to themes happens that requires
* other parts of the application to be notified.
*/
public triggerUpdate(): void { public triggerUpdate(): void {
this.listeners.forEach(callback => callback()); this.listeners.forEach((callback) => callback());
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
@import './components/ColourPicker.css'; @import "./components/ColourPicker.css";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
+1 -1
View File
@@ -6,7 +6,7 @@
<title>BetterSEQTA+ Settings</title> <title>BetterSEQTA+ Settings</title>
</head> </head>
<body class="h-[600px]"> <body class="h-[600px]">
<div id="app" style="height: 100%;"></div> <div id="app" style="height: 100%"></div>
<script type="module" src="./index.ts"></script> <script type="module" src="./index.ts"></script>
</body> </body>
</html> </html>
+20 -15
View File
@@ -1,29 +1,34 @@
import "./index.css" import "./index.css";
import Settings from "./pages/settings.svelte" import Settings from "./pages/settings.svelte";
import IconFamily from '@/resources/fonts/IconFamily.woff' import IconFamily from "@/resources/fonts/IconFamily.woff";
import browser from "webextension-polyfill" import browser from "webextension-polyfill";
import renderSvelte from "./main" import renderSvelte from "./main";
import { initializeSettingsState } from "@/seqta/utils/listeners/SettingsState";
function InjectCustomIcons() { function InjectCustomIcons() {
console.info('[BetterSEQTA+] Injecting Icons') console.info("[BetterSEQTA+] Injecting Icons");
const style = document.createElement('style') const style = document.createElement("style");
style.setAttribute('type', 'text/css') style.setAttribute("type", "text/css");
style.innerHTML = ` style.innerHTML = `
@font-face { @font-face {
font-family: 'IconFamily'; font-family: 'IconFamily';
src: url('${browser.runtime.getURL(IconFamily)}') format('woff'); src: url('${browser.runtime.getURL(IconFamily)}') format('woff');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
}` }`;
document.head.appendChild(style) document.head.appendChild(style);
} }
const mountPoint = document.getElementById('app') const mountPoint = document.getElementById("app");
if (!mountPoint) { if (!mountPoint) {
console.error('Mount point #app not found') console.error("Mount point #app not found");
throw new Error('Mount point #app not found') throw new Error("Mount point #app not found");
} }
InjectCustomIcons() InjectCustomIcons();
renderSvelte(Settings, mountPoint, { standalone: true })
(async () => {
await initializeSettingsState();
renderSvelte(Settings, mountPoint, { standalone: true });
})();
+1 -1
View File
@@ -1,4 +1,4 @@
import './index.css'; import "./index.css";
declare module "*.png"; declare module "*.png";
declare module "*.svg"; declare module "*.svg";
+11 -9
View File
@@ -1,9 +1,9 @@
import { mount } from "svelte" import { mount } from "svelte";
import type { ComponentType } from "svelte" import type { SvelteComponent } from "svelte";
import style from './index.css?inline' import style from "./index.css?inline";
export default function renderSvelte( export default function renderSvelte(
Component: ComponentType | any, Component: SvelteComponent | any,
mountPoint: ShadowRoot | HTMLElement, mountPoint: ShadowRoot | HTMLElement,
props: Record<string, any> = {}, props: Record<string, any> = {},
) { ) {
@@ -13,11 +13,13 @@ export default function renderSvelte(
standalone: false, standalone: false,
...props, ...props,
}, },
}) });
const styleElement = document.createElement('style') if (mountPoint instanceof ShadowRoot) {
styleElement.textContent = style const styleElement = document.createElement("style");
mountPoint.appendChild(styleElement) styleElement.textContent = style;
mountPoint.appendChild(styleElement);
}
return app return app;
} }
+3 -5
View File
@@ -7,7 +7,7 @@
import { standalone as StandaloneStore } from '../utils/standalone.svelte'; import { standalone as StandaloneStore } from '../utils/standalone.svelte';
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { initializeSettingsState, settingsState } from '@/seqta/utils/listeners/SettingsState' import { settingsState } from '@/seqta/utils/listeners/SettingsState'
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"
@@ -52,21 +52,19 @@
let { standalone } = $props<{ standalone?: boolean }>(); let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state<boolean>(false); let showColourPicker = $state<boolean>(false);
onMount(() => { onMount(async () => {
settingsPopup.addListener(() => { settingsPopup.addListener(() => {
showColourPicker = false; showColourPicker = false;
}); });
if (!standalone) return; if (!standalone) return;
initializeSettingsState();
console.log('settingsState', $settingsState);
StandaloneStore.setStandalone(true); StandaloneStore.setStandalone(true);
}); });
</script> </script>
<div class="w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode ? 'dark' : ''} { standalone ? 'h-[600px]' : 'h-full rounded-xl' } overflow-clip"> <div class="w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode ? 'dark' : ''} { standalone ? 'h-[600px]' : 'h-full rounded-xl' } overflow-clip">
<div class="flex relative flex-col gap-2 h-full overflow-clip bg-white dark:bg-zinc-800 dark:text-white"> <div class="flex relative flex-col gap-2 h-full overflow-clip bg-white dark:bg-zinc-800 dark:text-white">
<div class="grid place-items-center border-b border-b-zinc-200/40"> <div class="grid place-items-center border-b border-b-zinc-200/40 dark:border-b-zinc-700/40">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<img src={browser.runtime.getURL('resources/icons/betterseqta-dark-full.png')} class="w-4/5 dark:hidden" alt="Light logo" onclick={handleDevModeToggle} /> <img src={browser.runtime.getURL('resources/icons/betterseqta-dark-full.png')} class="w-4/5 dark:hidden" alt="Light logo" onclick={handleDevModeToggle} />
+68 -11
View File
@@ -3,16 +3,16 @@
import Button from "../../components/Button.svelte" import Button from "../../components/Button.svelte"
import Slider from "../../components/Slider.svelte" import Slider from "../../components/Slider.svelte"
import Select from "@/interface/components/Select.svelte" import Select from "@/interface/components/Select.svelte"
import HotkeyInput from "@/interface/components/HotkeyInput.svelte"
import browser from "webextension-polyfill" import browser from "webextension-polyfill"
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 hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent"
import { getAllPluginSettings } from "@/plugins" import { getAllPluginSettings } from "@/plugins"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting } from "@/plugins/core/types" import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
// Union type representing all possible settings // Union type representing all possible settings
type SettingType = type SettingType =
@@ -23,12 +23,26 @@
type: 'select', type: 'select',
id: string, id: string,
options: string[] options: string[]
}) |
(Omit<ButtonSetting, 'type'> & {
type: 'button',
id: string
}) |
(Omit<HotkeySetting, 'type'> & {
type: 'hotkey',
id: string
}) |
(Omit<ComponentSetting, 'type'> & {
type: 'component',
id: string,
component: any
}); });
interface Plugin { interface Plugin {
pluginId: string; pluginId: string;
name: string; name: string;
description: string; description: string;
beta?: boolean;
settings: Record<string, SettingType>; settings: Record<string, SettingType>;
} }
@@ -45,7 +59,11 @@
pluginSettingsValues[plugin.pluginId] = stored[storageKey] || {}; pluginSettingsValues[plugin.pluginId] = stored[storageKey] || {};
for (const [key, setting] of Object.entries(plugin.settings)) { for (const [key, setting] of Object.entries(plugin.settings)) {
if (pluginSettingsValues[plugin.pluginId][key] === undefined) { if (
pluginSettingsValues[plugin.pluginId][key] === undefined &&
setting.type !== 'button' &&
setting.type !== 'component'
) {
pluginSettingsValues[plugin.pluginId][key] = setting.default; pluginSettingsValues[plugin.pluginId][key] = setting.default;
} }
} }
@@ -184,12 +202,20 @@
{/each} {/each}
{#each pluginSettings as plugin} {#each pluginSettings as plugin}
<div> <div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40 {!(plugin as any).disableToggle && Object.keys(plugin.settings).length === 0 ? 'hidden' : ''}">
<!-- Always show enable toggle if disableToggle is true --> <!-- Always show enable toggle if disableToggle is true -->
{#if (plugin as any).disableToggle} {#if (plugin as any).disableToggle}
<div class="flex justify-between items-center px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
<div class="pr-4"> <div class="pr-4">
<h2 class="text-sm font-bold">Enable {plugin.name}</h2> <h2 class="flex gap-2 items-center text-sm font-bold">
Enable {plugin.name}
{#if plugin.beta}
<span class="px-2 py-0.5 text-xs font-medium text-orange-800 bg-orange-100 rounded-full border border-orange-300/30 dark:bg-orange-900/30 dark:text-orange-300 dark:border-orange-900/30">
Beta
</span>
{/if}
</h2>
<p class="text-xs">{plugin.description}</p> <p class="text-xs">{plugin.description}</p>
</div> </div>
<div> <div>
@@ -201,7 +227,6 @@
</div> </div>
{/if} {/if}
<!-- Only show other settings if plugin is enabled or has no disableToggle -->
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)} {#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]} {#each Object.entries(plugin.settings) as [key, setting]}
<!-- Skip the 'enabled' setting if it's part of the settings object --> <!-- Skip the 'enabled' setting if it's part of the settings object -->
@@ -228,7 +253,7 @@
{:else if setting.type === 'string'} {:else if setting.type === 'string'}
<input <input
type="text" type="text"
class="px-2 py-1 text-sm rounded-md dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white" class="px-2 py-1 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD] dark:text-white border-none"
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default} value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
oninput={(e) => updatePluginSetting(plugin.pluginId, key, e.currentTarget.value)} oninput={(e) => updatePluginSetting(plugin.pluginId, key, e.currentTarget.value)}
/> />
@@ -241,6 +266,21 @@
label: opt.charAt(0).toUpperCase() + opt.slice(1) label: opt.charAt(0).toUpperCase() + opt.slice(1)
}))} }))}
/> />
{:else if setting.type === 'button'}
<Button
onClick={() => setting.trigger?.()}
text={setting.title}
/>
{:else if setting.type === 'hotkey'}
<HotkeyInput
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/>
{:else if setting.type === 'component'}
{#if setting.component}
{@const Component = setting.component}
<Component />
{/if}
{/if} {/if}
</div> </div>
</div> </div>
@@ -248,8 +288,11 @@
{/each} {/each}
{/if} {/if}
</div> </div>
</div>
{/each} {/each}
<div class="p-1 border-none"></div>
{@render Setting({ {@render Setting({
title: "BetterSEQTA+", title: "BetterSEQTA+",
description: "Enables BetterSEQTA+ features", description: "Enables BetterSEQTA+ features",
@@ -262,7 +305,8 @@
})} })}
{#if $settingsState.devMode} {#if $settingsState.devMode}
<div class="flex items-center justify-between px-4 py-3 mt-4 pt-[1.75rem]"> <div class="flex-col p-1 my-1 bg-gradient-to-br from-white rounded-xl border shadow-sm to-zinc-100 border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4"> <div class="pr-4">
<h2 class="text-sm font-bold">Developer Mode</h2> <h2 class="text-sm font-bold">Developer Mode</h2>
<p class="text-xs">Enables developer mode, allowing you to test new features and changes.</p> <p class="text-xs">Enables developer mode, allowing you to test new features and changes.</p>
@@ -277,11 +321,24 @@
<p class="text-xs">Replace sensitive content with mock data</p> <p class="text-xs">Replace sensitive content with mock data</p>
</div> </div>
<div> <div>
<Button <Switch
onClick={() => hideSensitiveContent()} state={$settingsState.hideSensitiveContent ?? false}
text="Hide" onChange={(isOn: boolean) => settingsState.hideSensitiveContent = isOn}
/> />
</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">Mock Notices</h2>
<p class="text-xs">Use fake notice data on homepage instead of real data</p>
</div>
<div>
<Switch
state={$settingsState.mockNotices ?? false}
onChange={(isOn: boolean) => settingsState.mockNotices = isOn}
/>
</div>
</div>
</div>
{/if} {/if}
</div> </div>
+87 -21
View File
@@ -3,8 +3,10 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts" import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import Switch from "@/interface/components/Switch.svelte" import Switch from "@/interface/components/Switch.svelte"
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Shortcuts from "@/seqta/content/links.json"
let isLoaded = $state(false); let isLoaded = $state(false);
let fileInput = $state<HTMLInputElement | null>(null);
onMount(async () => { onMount(async () => {
// Wait for settingsState to be initialized // Wait for settingsState to be initialized
@@ -21,15 +23,38 @@
}); });
}); });
const switchChange = (index: number) => { const switchChange = (shortcut: any) => {
const updatedShortcuts = [...settingsState.shortcuts]; const value = $settingsState.shortcuts.find(s => s.name === shortcut);
updatedShortcuts[index].enabled = !updatedShortcuts[index].enabled; if (value) {
settingsState.shortcuts = updatedShortcuts; value.enabled = !value.enabled;
settingsState.shortcuts = settingsState.shortcuts;
} else {
settingsState.shortcuts = [...settingsState.shortcuts, { name: shortcut, enabled: true }];
}
} }
let isFormVisible = $state(false); let isFormVisible = $state(false);
let newTitle = $state(""); let newTitle = $state("");
let newURL = $state(""); let newURL = $state("");
let newIcon = $state<string | null>(null);
function handleIconChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (file && file.type === "image/svg+xml") {
const reader = new FileReader();
reader.onload = () => {
newIcon = reader.result as string;
};
reader.readAsText(file);
}
}
const clearIcon = () => {
newIcon = null;
if (fileInput) {
fileInput.value = ""; // Clear the file input so the same file can be re-selected
}
};
const toggleForm = () => { const toggleForm = () => {
isFormVisible = !isFormVisible; isFormVisible = !isFormVisible;
@@ -49,11 +74,13 @@
const addNewCustomShortcut = () => { const addNewCustomShortcut = () => {
if (isValidTitle(newTitle) && isValidURL(newURL)) { if (isValidTitle(newTitle) && isValidURL(newURL)) {
const newShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon: newTitle[0] }; const icon = newIcon || newTitle[0];
const newShortcut = { name: newTitle.trim(), url: formatUrl(newURL).trim(), icon };
settingsState.customshortcuts = [...settingsState.customshortcuts, newShortcut]; settingsState.customshortcuts = [...settingsState.customshortcuts, newShortcut];
newTitle = ""; newTitle = "";
newURL = ""; newURL = "";
newIcon = null;
isFormVisible = false; isFormVisible = false;
} else { } else {
alert("Please enter a valid title and URL."); alert("Please enter a valid title and URL.");
@@ -65,15 +92,6 @@
}; };
</script> </script>
{#snippet Shortcuts([index, Shortcut]: [string, { name: string, enabled: boolean }]) }
<div class="flex items-center justify-between px-4 py-3">
<div class="pr-4">
<h2 class="text-sm">{Shortcut.name}</h2>
</div>
<Switch state={Shortcut.enabled} onChange={() => switchChange(parseInt(index))} />
</div>
{/snippet}
<div class="flex flex-col pt-4 divide-y divide-zinc-100 dark:divide-zinc-700"> <div class="flex flex-col pt-4 divide-y divide-zinc-100 dark:divide-zinc-700">
{#if isLoaded} {#if isLoaded}
<div> <div>
@@ -95,7 +113,7 @@
class="w-full" class="w-full"
> >
<input <input
class="w-full p-2 transition border-0 rounded-lg placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600" class="p-2 w-full rounded-lg border-0 transition placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
type="text" type="text"
placeholder="Shortcut Name" placeholder="Shortcut Name"
bind:value={newTitle} bind:value={newTitle}
@@ -105,14 +123,56 @@
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05, duration: 0.2 }} transition={{ delay: 0.05, duration: 0.2 }}
class="w-full" class="flex gap-2 w-full"
> >
<input <input
class="w-full p-2 my-2 transition border-0 rounded-lg placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600" class="p-2 my-2 w-full rounded-lg border-0 transition placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
type="text" type="text"
placeholder="URL eg. https://google.com" placeholder="URL eg. https://google.com"
bind:value={newURL} bind:value={newURL}
/> />
<input
bind:this={fileInput}
class="p-2 w-full rounded-lg border-0 transition placeholder-zinc-300 bg-zinc-100 dark:bg-zinc-700 focus:bg-zinc-200/50 dark:focus:bg-zinc-600"
type="file"
accept=".svg"
onchange={handleIconChange}
hidden
/>
<button
type="button"
class="flex justify-between items-center p-2 my-2 text-left rounded-lg border border-dashed transition text-nowrap text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-700/50 hover:bg-zinc-200/50 dark:hover:bg-zinc-700/30 focus:bg-zinc-200/50 dark:focus:bg-zinc-600/50 border-zinc-300 dark:border-zinc-600"
onclick={() => fileInput?.click()}
>
{#if newIcon}
<div class="flex overflow-hidden items-center">
<div class="flex-shrink-0 mr-2 w-6 h-6">
<img src={`data:image/svg+xml;base64,${btoa(newIcon)}`} alt="Selected Icon" class="object-contain w-full h-full" />
</div>
<span class="truncate">Selected Icon</span>
</div>
<span
class="p-1 ml-2 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600"
aria-label="Clear icon"
role="button"
tabindex="0"
onclick={(event) => { event.stopPropagation(); clearIcon(); }}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
clearIcon();
}
}}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</span>
{:else}
<span class="font-IconFamily">{ '\ued47' }</span>
<span class="ml-2">SVG icon <span class="text-xs italic text-zinc-400 dark:text-zinc-500">(Optional)</span></span>
{/if}
</button>
</MotionDiv> </MotionDiv>
</div> </div>
{/if} {/if}
@@ -136,15 +196,21 @@
</MotionDiv> </MotionDiv>
</div> </div>
{#each Object.entries($settingsState.shortcuts) as shortcut} {#each Object.entries(Shortcuts) as shortcut}
{@render Shortcuts(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} {/each}
<!-- Custom Shortcuts Section --> <!-- Custom Shortcuts Section -->
{#each $settingsState.customshortcuts as shortcut, index} {#each $settingsState.customshortcuts as shortcut, index}
<div class="flex items-center justify-between px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
{shortcut.name} {shortcut.name}
<button onclick={() => deleteCustomShortcut(index)}> <button aria-label="Delete Shortcut" onclick={() => deleteCustomShortcut(index)}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
+1 -1
View File
@@ -2,6 +2,6 @@ export interface SettingsList {
title: string; title: string;
id: number; id: number;
description: string; description: string;
Component: any; /* TODO: Give this a type */ Component: any /* TODO: Give this a type */;
props?: any; props?: any;
} }
+1 -1
View File
@@ -16,7 +16,7 @@ export class Standalone {
public setStandalone(value: boolean) { public setStandalone(value: boolean) {
this._standalone = value; this._standalone = value;
this.subscribers.forEach(subscriber => subscriber(value)); this.subscribers.forEach((subscriber) => subscriber(value));
} }
public get standalone() { public get standalone() {
+81 -11
View File
@@ -1,23 +1,49 @@
import type { LoadedCustomTheme } from '@/types/CustomThemes'; import type { LoadedCustomTheme } from "@/types/CustomThemes";
/**
* Generates a random 9-character alphanumeric string to be used as a unique ID for images.
* This helps in identifying and managing custom images within a theme.
*
* @returns {string} A randomly generated unique ID string.
*/
export function generateImageId(): string { export function generateImageId(): string {
return Math.random().toString(36).substr(2, 9); return Math.random().toString(36).substr(2, 9);
} }
export function handleImageUpload(event: Event, theme: LoadedCustomTheme): Promise<LoadedCustomTheme> | LoadedCustomTheme { /**
* Handles the upload of a new custom image from a file input event.
* If a file is selected, it reads the file using FileReader, converts it to a Blob,
* generates a unique ID and a default variable name for it, and then adds this new image
* to the `CustomImages` array within the provided `theme` object.
*
* @param {Event} event The file input change event, typically from an `<input type="file">` element.
* @param {LoadedCustomTheme} theme The current theme object to which the new image will be added.
* @returns {Promise<LoadedCustomTheme> | LoadedCustomTheme} A Promise that resolves with the updated theme object
* containing the new image if a file was processed.
* Returns the original theme object synchronously if no file was selected.
*/
export function handleImageUpload(
event: Event,
theme: LoadedCustomTheme,
): Promise<LoadedCustomTheme> | LoadedCustomTheme {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
input.value = ''; input.value = "";
if (file) { if (file) {
return new Promise((resolve) => { return new Promise((resolve) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async () => { reader.onload = async () => {
const imageBlob = await fetch(reader.result as string).then(res => res.blob()); const imageBlob = await fetch(reader.result as string).then((res) =>
res.blob(),
);
const imageId = generateImageId(); const imageId = generateImageId();
const variableName = `custom-image-${theme.CustomImages.length}`; const variableName = `custom-image-${theme.CustomImages.length}`;
resolve({ resolve({
...theme, ...theme,
CustomImages: [...theme.CustomImages, { id: imageId, blob: imageBlob, variableName, url: null }], CustomImages: [
...theme.CustomImages,
{ id: imageId, blob: imageBlob, variableName, url: null },
],
}); });
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
@@ -26,31 +52,75 @@ export function handleImageUpload(event: Event, theme: LoadedCustomTheme): Promi
return theme; return theme;
} }
export function handleRemoveImage(imageId: string, theme: LoadedCustomTheme): LoadedCustomTheme { /**
* Removes a custom image from the theme based on its ID.
* It filters out the image with the specified `imageId` from the `CustomImages` array
* in the `theme` object.
*
* @param {string} imageId The unique ID of the custom image to be removed.
* @param {LoadedCustomTheme} theme The current theme object from which the image will be removed.
* @returns {LoadedCustomTheme} A new theme object with the specified image removed from its `CustomImages` array.
* This function is synchronous.
*/
export function handleRemoveImage(
imageId: string,
theme: LoadedCustomTheme,
): LoadedCustomTheme {
return { return {
...theme, ...theme,
CustomImages: theme.CustomImages.filter((image) => image.id !== imageId), CustomImages: theme.CustomImages.filter((image) => image.id !== imageId),
} as LoadedCustomTheme; } as LoadedCustomTheme;
} }
export function handleImageVariableChange(imageId: string, variableName: string, theme: LoadedCustomTheme): LoadedCustomTheme { /**
* Updates the CSS variable name associated with a specific custom image in the theme.
* It finds the image by `imageId` in the `CustomImages` array of the `theme` object
* and updates its `variableName` property.
*
* @param {string} imageId The unique ID of the custom image whose variable name is to be updated.
* @param {string} variableName The new CSS variable name to assign to the image.
* @param {LoadedCustomTheme} theme The current theme object containing the image to be updated.
* @returns {LoadedCustomTheme} A new theme object with the updated image variable name.
* This function is synchronous.
*/
export function handleImageVariableChange(
imageId: string,
variableName: string,
theme: LoadedCustomTheme,
): LoadedCustomTheme {
return { return {
...theme, ...theme,
CustomImages: theme.CustomImages.map((image) => CustomImages: theme.CustomImages.map((image) =>
image.id === imageId ? { ...image, variableName } : image image.id === imageId ? { ...image, variableName } : image,
), ),
} as LoadedCustomTheme; } as LoadedCustomTheme;
} }
export function handleCoverImageUpload(event: Event, theme: LoadedCustomTheme): Promise<LoadedCustomTheme> { /**
* Handles the upload of a cover image for the theme from a file input event.
* If a file is selected, it reads the file using FileReader, converts it to a Blob,
* and then updates the `coverImage` property of the provided `theme` object with this new blob.
*
* @param {Event} event The file input change event, typically from an `<input type="file">` element.
* @param {LoadedCustomTheme} theme The current theme object whose cover image will be updated.
* @returns {Promise<LoadedCustomTheme>} A Promise that resolves with the updated theme object
* containing the new cover image if a file was processed.
* Returns a Promise resolving with the original theme object if no file was selected.
*/
export function handleCoverImageUpload(
event: Event,
theme: LoadedCustomTheme,
): Promise<LoadedCustomTheme> {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
input.value = ''; input.value = "";
if (file) { if (file) {
return new Promise((resolve) => { return new Promise((resolve) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async () => { reader.onload = async () => {
const imageBlob = await fetch(reader.result as string).then(res => res.blob()); const imageBlob = await fetch(reader.result as string).then((res) =>
res.blob(),
);
resolve({ ...theme, coverImage: imageBlob }); resolve({ ...theme, coverImage: imageBlob });
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest' import { createManifest } from "../../lib/createManifest";
import baseManifest from './manifest.json' import baseManifest from "./manifest.json";
import pkg from '../../package.json' import pkg from "../../package.json";
export const brave = createManifest({ export const brave = createManifest(
{
...baseManifest, ...baseManifest,
version: pkg.version, version: pkg.version,
description: pkg.description, description: pkg.description,
}, 'brave') },
"brave",
);
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest' import { createManifest } from "../../lib/createManifest";
import baseManifest from './manifest.json' import baseManifest from "./manifest.json";
import pkg from '../../package.json' import pkg from "../../package.json";
export const chrome = createManifest({ export const chrome = createManifest(
{
...baseManifest, ...baseManifest,
version: pkg.version, version: pkg.version,
description: pkg.description, description: pkg.description,
}, 'chrome') },
"chrome",
);
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest' import { createManifest } from "../../lib/createManifest";
import baseManifest from './manifest.json' import baseManifest from "./manifest.json";
import pkg from '../../package.json' import pkg from "../../package.json";
export const edge = createManifest({ export const edge = createManifest(
{
...baseManifest, ...baseManifest,
version: pkg.version, version: pkg.version,
description: pkg.description, description: pkg.description,
}, 'edge') },
"edge",
);
+7 -7
View File
@@ -1,6 +1,6 @@
import { createManifest } from '../../lib/createManifest' import { createManifest } from "../../lib/createManifest";
import baseManifest from './manifest.json' import baseManifest from "./manifest.json";
import pkg from '../../package.json' import pkg from "../../package.json";
const updatedFirefoxManifest = { const updatedFirefoxManifest = {
...baseManifest, ...baseManifest,
@@ -10,13 +10,13 @@ const updatedFirefoxManifest = {
scripts: [baseManifest.background.service_worker], scripts: [baseManifest.background.service_worker],
}, },
action: { action: {
"default_popup": "interface/index.html#settings", default_popup: "interface/index.html#settings",
}, },
browser_specific_settings: { browser_specific_settings: {
gecko: { gecko: {
id: pkg.author.email, id: pkg.author.email,
}, },
} },
} };
export const firefox = createManifest(updatedFirefoxManifest, 'firefox') export const firefox = createManifest(updatedFirefoxManifest, "firefox");
+1 -9
View File
@@ -32,15 +32,7 @@
], ],
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": ["*/*"], "resources": ["resources/icons/*", "resources/update-image.webp"],
"matches": ["*://*/*"]
},
{
"resources": ["resources/*"],
"matches": ["*://*/*"]
},
{
"resources": ["seqta/utils/migration/migrate.html"],
"matches": ["*://*/*"] "matches": ["*://*/*"]
} }
] ]
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest' import { createManifest } from "../../lib/createManifest";
import baseManifest from './manifest.json' import baseManifest from "./manifest.json";
import pkg from '../../package.json' import pkg from "../../package.json";
export const opera = createManifest({ export const opera = createManifest(
{
...baseManifest, ...baseManifest,
version: pkg.version, version: pkg.version,
description: pkg.description, description: pkg.description,
}, 'opera') },
"opera",
);
+7 -7
View File
@@ -1,6 +1,6 @@
import { createManifest } from '../../lib/createManifest' import { createManifest } from "../../lib/createManifest";
import baseManifest from './manifest.json' import baseManifest from "./manifest.json";
import pkg from '../../package.json' import pkg from "../../package.json";
const updatedSafariManifest = { const updatedSafariManifest = {
...baseManifest, ...baseManifest,
@@ -8,12 +8,12 @@ const updatedSafariManifest = {
description: pkg.description, description: pkg.description,
browser_specific_settings: { browser_specific_settings: {
safari: { safari: {
strict_min_version: '15.4', strict_min_version: "15.4",
strict_max_version: '*', strict_max_version: "*",
}, },
// ^^^ https://developer.apple.com/documentation/safariservices/safari_web_extensions/optimizing_your_web_extension_for_safari#3743239 // ^^^ https://developer.apple.com/documentation/safariservices/safari_web_extensions/optimizing_your_web_extension_for_safari#3743239
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings#safari_properties // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings#safari_properties
}, },
} };
export const safari = createManifest(updatedSafariManifest, 'safari') export const safari = createManifest(updatedSafariManifest, "safari");
+58 -36
View File
@@ -3,8 +3,8 @@ class ReactFiber {
this.selector = selector; this.selector = selector;
this.debug = options.debug || false; this.debug = options.debug || false;
this.nodes = [...document.querySelectorAll(selector)]; // Support multiple elements this.nodes = [...document.querySelectorAll(selector)]; // Support multiple elements
this.fibers = this.nodes.map(node => this.getFiberNode(node)); this.fibers = this.nodes.map((node) => this.getFiberNode(node));
this.components = this.fibers.map(fiber => this.getOwnerComponent(fiber)); this.components = this.fibers.map((fiber) => this.getOwnerComponent(fiber));
if (this.debug) { if (this.debug) {
console.log("Selected Nodes:", this.nodes); console.log("Selected Nodes:", this.nodes);
@@ -19,8 +19,10 @@ class ReactFiber {
getFiberNode(node) { getFiberNode(node) {
if (!node) return null; if (!node) return null;
const fiberKey = Object.getOwnPropertyNames(node).find(name => const fiberKey = Object.getOwnPropertyNames(node).find(
name.startsWith('__reactFiber') || name.startsWith('__reactInternalInstance') (name) =>
name.startsWith("__reactFiber") ||
name.startsWith("__reactInternalInstance"),
); );
return fiberKey ? node[fiberKey] : null; return fiberKey ? node[fiberKey] : null;
} }
@@ -28,7 +30,10 @@ class ReactFiber {
getOwnerComponent(fiberNode) { getOwnerComponent(fiberNode) {
let current = fiberNode; let current = fiberNode;
while (current) { while (current) {
if (current.stateNode && (current.stateNode.setState || current.stateNode.forceUpdate)) { if (
current.stateNode &&
(current.stateNode.setState || current.stateNode.forceUpdate)
) {
return current.stateNode; return current.stateNode;
} }
current = current.return; current = current.return;
@@ -42,7 +47,7 @@ class ReactFiber {
if (key === undefined) { if (key === undefined) {
return state; return state;
} else if (typeof key === 'string') { } else if (typeof key === "string") {
return state?.[key]; return state?.[key];
} else if (Array.isArray(key)) { } else if (Array.isArray(key)) {
const filteredState = {}; const filteredState = {};
@@ -57,23 +62,25 @@ class ReactFiber {
} }
setState(update) { setState(update) {
this.components.forEach(component => { this.components.forEach((component) => {
if (component?.setState) { if (component?.setState) {
if (typeof update === 'function') { if (typeof update === "function") {
// Functional update // Functional update
component.setState(prevState => { component.setState((prevState) => {
const newState = update(prevState); const newState = update(prevState);
if (this.debug) console.log("✅ Updated State (Functional):", newState); if (this.debug)
console.log("✅ Updated State (Functional):", newState);
return newState; return newState;
}); });
} else { } else {
// Object update (merge with existing state) // Object update (merge with existing state)
component.setState(prevState => { component.setState((prevState) => {
const newState = { const newState = {
...prevState, ...prevState,
...update ...update,
}; };
if (this.debug) console.log("✅ Updated State (Object Merge):", newState); if (this.debug)
console.log("✅ Updated State (Object Merge):", newState);
return newState; return newState;
}); });
} }
@@ -93,7 +100,7 @@ class ReactFiber {
} }
setProp(propName) { setProp(propName) {
this.fibers.forEach(fiber => { this.fibers.forEach((fiber) => {
if (fiber?.memoizedProps) { if (fiber?.memoizedProps) {
fiber.memoizedProps[propName] = value; fiber.memoizedProps[propName] = value;
} }
@@ -102,7 +109,7 @@ class ReactFiber {
} }
forceUpdate() { forceUpdate() {
this.components.forEach(component => { this.components.forEach((component) => {
if (component?.forceUpdate) { if (component?.forceUpdate) {
component.forceUpdate(); component.forceUpdate();
if (this.debug) console.log("🔄 Forced React Re-render"); if (this.debug) console.log("🔄 Forced React Re-render");
@@ -113,12 +120,12 @@ class ReactFiber {
} }
function makeSerializable(obj) { function makeSerializable(obj) {
if (typeof obj !== 'object' || obj === null) { if (typeof obj !== "object" || obj === null) {
return obj; return obj;
} }
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
return obj.map(item => makeSerializable(item)); return obj.map((item) => makeSerializable(item));
} }
const serializableObj = {}; const serializableObj = {};
@@ -126,17 +133,17 @@ function makeSerializable(obj) {
if (Object.hasOwn(obj, key)) { if (Object.hasOwn(obj, key)) {
let value = obj[key]; let value = obj[key];
if (typeof value === 'function') { if (typeof value === "function") {
value = '[Function]'; value = "[Function]";
} else if (value instanceof HTMLElement) { } else if (value instanceof HTMLElement) {
value = { value = {
type: 'HTMLElement', type: "HTMLElement",
id: value.id, id: value.id,
tagName: value.tagName tagName: value.tagName,
}; // Replace DOM node with ID/tag info }; // Replace DOM node with ID/tag info
} else if (typeof value === 'symbol') { } else if (typeof value === "symbol") {
value = value.toString(); value = value.toString();
} else if (typeof value === 'object' && value !== null) { } else if (typeof value === "object" && value !== null) {
value = makeSerializable(value); value = makeSerializable(value);
} }
@@ -146,17 +153,11 @@ function makeSerializable(obj) {
return serializableObj; return serializableObj;
} }
window.addEventListener('message', (event) => { window.addEventListener("message", (event) => {
if (event.data.type === "reactFiberRequest") { if (event.data.type === "reactFiberRequest") {
const { const { selector, action, payload, debug, messageId } = event.data;
selector,
action,
payload,
debug,
messageId
} = event.data;
const fiberInstance = ReactFiber.find(selector, { const fiberInstance = ReactFiber.find(selector, {
debug debug,
}); });
let response; let response;
@@ -167,7 +168,7 @@ window.addEventListener('message', (event) => {
case "setState": case "setState":
// Handle both function and object updates // Handle both function and object updates
if (payload.updateFn) { if (payload.updateFn) {
const updateFn = eval(`(${payload.updateFn})`); const updateFn = new Function('return ' + payload.updateFn)();
fiberInstance.setState(updateFn); fiberInstance.setState(updateFn);
} else { } else {
fiberInstance.setState(payload.updateObject); fiberInstance.setState(payload.updateObject);
@@ -191,14 +192,35 @@ window.addEventListener('message', (event) => {
response = null; response = null;
} }
if (response !== null && typeof response === 'object') { if (response !== null && typeof response === "object") {
response = makeSerializable(response); response = makeSerializable(response);
} }
window.postMessage({ window.postMessage(
{
type: "reactFiberResponse", type: "reactFiberResponse",
response, response,
messageId, messageId,
}, "*"); },
"*",
);
} else if (event.data.type === "triggerKeyboardEvent") {
// Handle keyboard event triggering from content script
const { key, code, altKey, ctrlKey, metaKey, shiftKey, keyCode } = event.data;
const keyboardEvent = new KeyboardEvent('keydown', {
key,
code,
keyCode: keyCode || 0,
which: keyCode || 0,
altKey: altKey || false,
ctrlKey: ctrlKey || false,
metaKey: metaKey || false,
shiftKey: shiftKey || false,
bubbles: true,
cancelable: true
});
document.dispatchEvent(keyboardEvent);
} }
}); });
@@ -1,7 +1,11 @@
import { BasePlugin } from '../../core/settings'; import { BasePlugin } from "../../core/settings";
import { type Plugin } from '@/plugins/core/types'; import { type Plugin } from "@/plugins/core/types";
import { defineSettings, numberSetting, Setting } from '@/plugins/core/settingsHelpers'; import {
import styles from './styles.css?inline'; defineSettings,
numberSetting,
Setting,
} from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline";
const settings = defineSettings({ const settings = defineSettings({
speed: numberSetting({ speed: numberSetting({
@@ -10,8 +14,8 @@ const settings = defineSettings({
description: "Controls how fast the background moves", description: "Controls how fast the background moves",
min: 0.1, min: 0.1,
max: 2, max: 2,
step: 0.05 step: 0.05,
}) }),
}); });
class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> { class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
@@ -22,10 +26,10 @@ class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
const instance = new AnimatedBackgroundPluginClass(); const instance = new AnimatedBackgroundPluginClass();
const animatedBackgroundPlugin: Plugin<typeof settings> = { const animatedBackgroundPlugin: Plugin<typeof settings> = {
id: 'animated-background', id: "animated-background",
name: 'Animated Background', name: "Animated Background",
description: 'Adds an animated background to BetterSEQTA+', description: "Adds an animated background to BetterSEQTA+",
version: '1.0.0', version: "1.0.0",
disableToggle: true, disableToggle: true,
styles: styles, styles: styles,
settings: instance.settings, settings: instance.settings,
@@ -42,12 +46,12 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
const backgrounds = [ const backgrounds = [
{ classes: ["bg"] }, { classes: ["bg"] },
{ classes: ["bg", "bg2"] }, { classes: ["bg", "bg2"] },
{ classes: ["bg", "bg3"] } { classes: ["bg", "bg3"] },
]; ];
backgrounds.forEach(({ classes }) => { backgrounds.forEach(({ classes }) => {
const bk = document.createElement("div"); const bk = document.createElement("div");
classes.forEach(cls => bk.classList.add(cls)); classes.forEach((cls) => bk.classList.add(cls));
container.insertBefore(bk, menu); container.insertBefore(bk, menu);
}); });
@@ -55,20 +59,23 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
updateAnimationSpeed(api.settings.speed); updateAnimationSpeed(api.settings.speed);
// Listen for speed changes // Listen for speed changes
const speedUnregister = api.settings.onChange('speed', updateAnimationSpeed); const speedUnregister = api.settings.onChange(
"speed",
updateAnimationSpeed,
);
// Return cleanup function // Return cleanup function
return () => { return () => {
speedUnregister.unregister(); speedUnregister.unregister();
// Remove background elements // Remove background elements
const backgrounds = document.getElementsByClassName('bg'); const backgrounds = document.getElementsByClassName("bg");
Array.from(backgrounds).forEach(element => element.remove()); Array.from(backgrounds).forEach((element) => element.remove());
}; };
} },
}; };
function updateAnimationSpeed(speed: number) { function updateAnimationSpeed(speed: number) {
const bgElements = document.getElementsByClassName('bg'); const bgElements = document.getElementsByClassName("bg");
Array.from(bgElements).forEach((element, index) => { Array.from(bgElements).forEach((element, index) => {
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5; const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`; (element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`;
@@ -13,12 +13,12 @@ export function CreateBackground() {
const backgrounds = [ const backgrounds = [
{ classes: ["bg"] }, { classes: ["bg"] },
{ classes: ["bg", "bg2"] }, { classes: ["bg", "bg2"] },
{ classes: ["bg", "bg3"] } { classes: ["bg", "bg3"] },
]; ];
backgrounds.forEach(({ classes }) => { backgrounds.forEach(({ classes }) => {
const bk = document.createElement("div"); const bk = document.createElement("div");
classes.forEach(cls => bk.classList.add(cls)); classes.forEach((cls) => bk.classList.add(cls));
container.insertBefore(bk, menu); container.insertBefore(bk, menu);
}); });
} }
@@ -2,5 +2,5 @@ export function RemoveBackground() {
const backgrounds = document.getElementsByClassName("bg"); const backgrounds = document.getElementsByClassName("bg");
// Convert HTMLCollection to Array and remove each element // Convert HTMLCollection to Array and remove each element
Array.from(backgrounds).forEach(element => element.remove()); Array.from(backgrounds).forEach((element) => element.remove());
} }
@@ -1,5 +1,9 @@
import { BasePlugin } from "@/plugins/core/settings"; import { BasePlugin } from "@/plugins/core/settings";
import { booleanSetting, defineSettings, Setting } from "@/plugins/core/settingsHelpers"; import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
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";
@@ -8,7 +12,7 @@ const settings = defineSettings({
lettergrade: booleanSetting({ lettergrade: booleanSetting({
default: false, default: false,
title: "Letter Grades", title: "Letter Grades",
description: "Display the average as a letter instead of a percentage" description: "Display the average as a letter instead of a percentage",
}), }),
}); });
@@ -34,62 +38,105 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']", "#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
true, true,
10, 10,
1000 1000,
); );
// Helper function to find actual class names by their base pattern // Helper function to find actual class names by their base pattern
const getClassByPattern = (element: Element | Document, basePattern: string): string => { const getClassByPattern = (
element: Element | Document,
basePattern: string,
): string => {
// Find all classes on the element // Find all classes on the element
const classes = Array.from(element.querySelectorAll('*')) const classes = Array.from(element.querySelectorAll("*"))
.flatMap(el => Array.from(el.classList)) .flatMap((el) => Array.from(el.classList))
.filter(className => className.startsWith(basePattern)); .filter((className) => className.startsWith(basePattern));
return classes.length ? classes[0] : ''; return classes.length ? classes[0] : "";
}; };
// Find actual class names from the DOM // Find actual class names from the DOM
const sampleAssessmentItem = document.querySelector("[class*='AssessmentItem__AssessmentItem___']"); const sampleAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___']",
);
if (!sampleAssessmentItem) return; if (!sampleAssessmentItem) return;
// Extract all necessary class patterns from a sample assessment item // Extract all necessary class patterns from a sample assessment item
const assessmentItemClass = Array.from(sampleAssessmentItem.classList) const assessmentItemClass =
.find(c => c.startsWith('AssessmentItem__AssessmentItem___')) || ''; Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"),
) || "";
const metaContainerClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__metaContainer___'); const metaContainerClass = getClassByPattern(
const metaClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__meta___'); sampleAssessmentItem,
const simpleResultClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__simpleResult___'); "AssessmentItem__metaContainer___",
const titleClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__title___'); );
const metaClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__meta___",
);
const simpleResultClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__simpleResult___",
);
const titleClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__title___",
);
// Get Thermoscore classes // Get Thermoscore classes
const thermoscoreElement = document.querySelector("[class*='Thermoscore__Thermoscore___']"); const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
if (!thermoscoreElement) return; if (!thermoscoreElement) return;
const thermoscoreClass = Array.from(thermoscoreElement.classList) const thermoscoreClass =
.find(c => c.startsWith('Thermoscore__Thermoscore___')) || ''; Array.from(thermoscoreElement.classList).find((c) =>
const fillClass = getClassByPattern(thermoscoreElement, 'Thermoscore__fill___'); c.startsWith("Thermoscore__Thermoscore___"),
const textClass = getClassByPattern(thermoscoreElement, 'Thermoscore__text___'); ) || "";
const fillClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__fill___",
);
const textClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__text___",
);
// Find assessment list // Find assessment list
const assessmentsList = document.querySelector("#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']"); const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
if (!assessmentsList) return; if (!assessmentsList) return;
const gradeElements = document.querySelectorAll("[class*='Thermoscore__text___']"); const gradeElements = document.querySelectorAll(
"[class*='Thermoscore__text___']",
);
if (!gradeElements.length) return; if (!gradeElements.length) return;
// Parse and average grades // Parse and average grades
const letterToNumber: Record<string, number> = { const letterToNumber: Record<string, number> = {
"A+": 100, A: 95, "A-": 90, "A+": 100,
"B+": 85, B: 80, "B-": 75, A: 95,
"C+": 70, C: 65, "C-": 60, "A-": 90,
"D+": 55, D: 50, "D-": 45, "B+": 85,
"E+": 40, E: 35, "E-": 30, B: 80,
"B-": 75,
"C+": 70,
C: 65,
"C-": 60,
"D+": 55,
D: 50,
"D-": 45,
"E+": 40,
E: 35,
"E-": 30,
F: 0, F: 0,
}; };
function parseGrade(text: string): number { function parseGrade(text: string): number {
const str = text.trim().toUpperCase(); const str = text.trim().toUpperCase();
if (str.includes("/")) { if (str.includes("/")) {
const [raw, max] = str.split("/").map(n => parseFloat(n)); const [raw, max] = str.split("/").map((n) => parseFloat(n));
return (raw / max) * 100; return (raw / max) * 100;
} }
if (str.includes("%")) { if (str.includes("%")) {
@@ -112,16 +159,23 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
const avg = total / count; const avg = total / count;
const rounded = Math.ceil(avg / 5) * 5; const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce((acc, [k, v]) => { const numberToLetter = Object.entries(letterToNumber).reduce(
(acc, [k, v]) => {
acc[v] = k; acc[v] = k;
return acc; return acc;
}, {} as Record<number, string>); },
{} as Record<number, string>,
);
const letterAvg = numberToLetter[rounded] ?? "N/A"; const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`; const display = api.settings.lettergrade
? letterAvg
: `${avg.toFixed(2)}%`;
// Prevent duplicate // Prevent duplicate
const existing = assessmentsList.querySelector(`[class*='AssessmentItem__title___']`); const existing = assessmentsList.querySelector(
`[class*='AssessmentItem__title___']`,
);
if (existing?.textContent === "Subject Average") return; if (existing?.textContent === "Subject Average") return;
// Use the dynamic class names in the HTML template // Use the dynamic class names in the HTML template
@@ -144,7 +198,7 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild); assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild);
}); });
} },
}; };
export default assessmentsAveragePlugin; export default assessmentsAveragePlugin;
@@ -0,0 +1,384 @@
<script lang="ts">
import { determineStatus, formatDate, getGradeValue } from "./utils";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import confetti from "canvas-confetti";
export let data: any;
interface FilterOptions {
subject: string;
sortBy: "due" | "grade" | "subject" | "title";
}
function percentageToLetter(percentage: number): string {
const letterMap: Record<number, string> = {
100: "A+",
95: "A",
90: "A-",
85: "B+",
80: "B",
75: "B-",
70: "C+",
65: "C",
60: "C-",
55: "D+",
50: "D",
45: "D-",
40: "E+",
35: "E",
30: "E-",
0: "F",
};
const rounded = Math.ceil(percentage / 5) * 5;
return letterMap[rounded] || "F";
}
let currentFilters: FilterOptions = {
subject: "all",
sortBy: "due",
};
let filteredAssessments: any[] = [];
let statusGroups: Record<string, any[]> = {};
function updateAssessments() {
filteredAssessments = data.assessments.filter((a: any) => {
const subjectMatch =
currentFilters.subject === "all" || a.code === currentFilters.subject;
return subjectMatch;
});
filteredAssessments.sort((a: any, b: any) => {
switch (currentFilters.sortBy) {
case "due":
return new Date(a.due).getTime() - new Date(b.due).getTime();
case "grade":
const gradeA = getGradeValue(a);
const gradeB = getGradeValue(b);
if (gradeA === null && gradeB === null) return 0;
if (gradeA === null) return 1;
if (gradeB === null) return -1;
return gradeB - gradeA;
case "subject":
return a.code.localeCompare(b.code);
case "title":
return a.title.localeCompare(b.title);
default:
return 0;
}
});
statusGroups = {
UPCOMING: [],
DUE_SOON: [],
OVERDUE: [],
SUBMITTED: [],
MARKS_RELEASED: [],
};
filteredAssessments.forEach((assessment) => {
const status = determineStatus(assessment);
if (statusGroups[status]) {
statusGroups[status].push(assessment);
}
});
}
function getDueDateClass(assessment: any): string {
const status = determineStatus(assessment);
switch (status) {
case "OVERDUE":
return "overdue";
case "DUE_SOON":
return "due-soon";
case "UPCOMING":
return "upcoming";
default:
return "";
}
}
function markAssessmentCompleted(assessment: any) {
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
if (!completed.includes(assessment.id)) {
completed.push(assessment.id);
localStorage.setItem(completedKey, JSON.stringify(completed));
updateAssessments();
checkForCelebration();
}
}
function unmarkAssessmentCompleted(assessment: any) {
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
const index = completed.indexOf(assessment.id);
if (index > -1) {
completed.splice(index, 1);
localStorage.setItem(completedKey, JSON.stringify(completed));
updateAssessments();
}
}
function checkForCelebration() {
const overdueCount = statusGroups.OVERDUE?.length || 0;
const dueSoonCount = statusGroups.DUE_SOON?.length || 0;
if (overdueCount === 0 && dueSoonCount === 0) {
setTimeout(() => {
try {
const duration = 100;
const end = Date.now() + duration;
(function frame() {
confetti({
particleCount: 17,
angle: 60,
spread: 65,
drift: 0.8,
startVelocity: 40,
scalar: 2,
gravity: 2,
decay: 0.97,
ticks: 300,
origin: { x: 0, y: 1 },
disableForReducedMotion: true,
});
confetti({
particleCount: 17,
angle: 120,
spread: 65,
drift: -0.8,
startVelocity: 40,
scalar: 2,
decay: 0.97,
ticks: 300,
gravity: 2,
origin: { x: 1, y: 1 },
disableForReducedMotion: true,
});
if (Date.now() < end) {
requestAnimationFrame(frame);
}
}());
} catch (e) {
console.log("Confetti celebration failed:", e);
}
}, 500);
} else if (overdueCount === 0 || dueSoonCount === 0) {
setTimeout(() => {
try {
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
scalar: 0.9,
disableForReducedMotion: true,
});
} catch (e) {
console.log("Confetti celebration failed:", e);
}
}, 500);
}
}
function isManuallyCompleted(assessmentId: string): boolean {
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
return completed.includes(assessmentId);
}
function handleCardClick(assessment: any, event: Event) {
if ((event.target as HTMLElement).closest(".card-menu")) {
return;
}
window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`;
}
let openMenuId: string | null = null;
function toggleMenu(assessmentId: string, event: Event) {
event.stopPropagation();
openMenuId = openMenuId === assessmentId ? null : assessmentId;
}
function closeAllMenus() {
openMenuId = null;
}
$: {
if (data) {
updateAssessments();
}
}
const columns = [
{
key: "UPCOMING",
title: "Upcoming",
className: "column-upcoming",
icon: "📅",
},
{
key: "DUE_SOON",
title: "Due Soon",
className: "column-due-soon",
icon: "⏰",
},
{
key: "OVERDUE",
title: "Overdue",
className: "column-overdue",
icon: "🚨",
},
{
key: "SUBMITTED",
title: "Submitted",
className: "column-submitted",
icon: "📝",
},
{
key: "MARKS_RELEASED",
title: "Marked",
className: "column-marked",
icon: "✅",
},
];
</script>
<svelte:window on:click={closeAllMenus} />
<div id="grid-view-container">
<div class="grid-view-header">
<h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters">
<select class="filter-select" bind:value={currentFilters.subject}>
<option value="all">All Subjects</option>
{#each data.subjects as subject}
<option value={subject.code}>{subject.code} - {subject.title}</option>
{/each}
</select>
<select class="filter-select" bind:value={currentFilters.sortBy}>
<option value="due">Sort by Due Date</option>
<option value="grade">Sort by Grade</option>
<option value="subject">Sort by Subject</option>
<option value="title">Sort by Title</option>
</select>
</div>
</div>
<div id="main-grid-content">
{#if filteredAssessments.length === 0}
<div class="empty-state">
<div class="empty-icon">📋</div>
<p>No assessments found matching your filters</p>
</div>
{:else}
<div class="kanban-board">
{#each columns as column}
{#if statusGroups[column.key]?.length > 0}
<div class="kanban-column-parent">
<div class="kanban-column {column.className}">
<div class="column-header">
<div class="column-title">
{column.icon} {column.title}
<span class="column-count">{statusGroups[column.key].length}</span>
</div>
</div>
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
{#each statusGroups[column.key] as assessment}
{@const status = determineStatus(assessment)}
{@const dueDateClass = getDueDateClass(assessment)}
{@const isCompleted = isManuallyCompleted(assessment.id)}
{@const color = data.colors[assessment.code] || "#6366f1"}
<div
class="assessment-card"
data-subject={assessment.code}
data-status={status}
style="--subject-color: {color}"
on:click={(e) => handleCardClick(assessment, e)}
role="button"
tabindex="0"
on:keydown={(e) => e.key === 'Enter' && handleCardClick(assessment, e)}
>
<div class="card-labels">
<span class="card-label label-subject">{assessment.code}</span>
{#if assessment.submitted}
<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>
{/if}
{#if isCompleted && status === "MARKS_RELEASED" && !assessment.results}
<span class="card-label label-completed" style="background: #059669; color: white;">Completed</span>
{/if}
</div>
{#if status !== "MARKS_RELEASED" || isCompleted}
<div class="card-menu">
<button
class="menu-button"
data-assessment-id={assessment.id}
on:click={(e) => toggleMenu(assessment.id, e)}
aria-label="Open menu"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/>
</svg>
</button>
<div class="menu-dropdown" style="display: {openMenuId === assessment.id ? 'block' : 'none'};">
{#if status !== "MARKS_RELEASED"}
<button class="menu-item mark-completed" on:click={() => markAssessmentCompleted(assessment)}>
Mark as Completed
</button>
{:else if isCompleted}
<button class="menu-item mark-not-completed" on:click={() => unmarkAssessmentCompleted(assessment)}>
Mark as Not Complete
</button>
{/if}
</div>
</div>
{/if}
<h3 class="assessment-title">{assessment.title}</h3>
{#if !assessment.results && !isCompleted}
<div class="assessment-meta">
<div class="due-date {dueDateClass}">
📅 {formatDate(assessment.due, assessment.submitted)}
</div>
</div>
{/if}
{#if assessment.results}
<div class="card-footer">
<div class="Thermoscore__Thermoscore___WFpL3" style="--fill-colour: {color}">
<div style="width: {assessment.results.percentage}%" class="Thermoscore__fill___ojxDI">
<div title="{assessment.results.percentage}%" class="Thermoscore__text___XSR_M">
{(() => {
const allSettings = settingsState.getAll() as unknown as any;
const letterGradeSetting = allSettings["plugin.assessments-average.settings"]?.lettergrade;
return letterGradeSetting
? percentageToLetter(assessment.results.percentage)
: `${assessment.results.percentage}%`;
})()}
</div>
</div>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,8 @@
<script lang="ts">
export let error: string;
</script>
<div class="error-container">
<p class="error-text">Failed to load assessments</p>
<p style="color: #94a3b8; font-size: 0.875rem;">{error}</p>
</div>
@@ -0,0 +1,78 @@
<div id="grid-view-container">
<div class="grid-view-header">
<h1 class="grid-view-title">Assessments</h1>
<div class="grid-view-filters">
<select class="filter-select" disabled>
<option value="all">Loading subjects...</option>
</select>
<select class="filter-select" disabled>
<option value="due">Sort by Due Date</option>
</select>
</div>
</div>
<div id="main-grid-content">
<div class="kanban-board">
{#each columns as column}
<div class="kanban-column-parent">
<div class="kanban-column {column.className}">
<div class="column-header">
<div class="column-title">
{column.icon} {column.title}
<span class="column-count">...</span>
</div>
</div>
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
{#each Array(column.skeletonCount) as _}
<div class="assessment-card">
<div class="skeleton-element skeleton-label"></div>
<div class="skeleton-element skeleton-title"></div>
<div class="skeleton-element skeleton-title-line2"></div>
<div class="skeleton-element skeleton-meta"></div>
{#if column.key === "MARKS_RELEASED"}
<div class="skeleton-footer">
<div class="skeleton-element" style="height: 16px; width: 100%;"></div>
</div>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
<script lang="ts">
const columns = [
{
key: "UPCOMING",
title: "Upcoming",
className: "column-upcoming",
icon: "📅",
skeletonCount: 3,
},
{
key: "DUE_SOON",
title: "Due Soon",
className: "column-due-soon",
icon: "⏰",
skeletonCount: 2,
},
{
key: "OVERDUE",
title: "Overdue",
className: "column-overdue",
icon: "🚨",
skeletonCount: 1,
},
{
key: "MARKS_RELEASED",
title: "Marked",
className: "column-marked",
icon: "✅",
skeletonCount: 4,
},
];
</script>
@@ -0,0 +1,138 @@
interface Subject {
code: string;
programme: number;
metaclass: number;
title: string;
}
interface PrefItem {
name: string;
value: string;
}
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { getMockAssessmentsData } from "@/seqta/ui/dev/hideSensitiveContent";
let cache: { time: number; data: any } | null = null;
const CACHE_MS = 10 * 60 * 1000;
const student = 69;
async function fetchJSON(url: string, body: any) {
const res = await fetch(`${location.origin}${url}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
});
return res.json();
}
async function loadSubjects() {
const res = await fetchJSON("/seqta/student/load/subjects?", {});
return res.payload
.filter((s: any) => s.active === 1)
.flatMap((s: any) => s.subjects);
}
async function loadPrefs(student: number) {
const res = await fetchJSON("/seqta/student/load/prefs?", {
request: "userPrefs",
asArray: true,
user: student,
});
const colors: Record<string, string> = {};
res.payload.forEach((p: PrefItem) => {
if (p.name.startsWith("timetable.subject.colour.")) {
const code = p.name.replace("timetable.subject.colour.", "");
colors[code] = p.value;
}
});
return colors;
}
async function loadUpcoming(student: number) {
const res = await fetchJSON("/seqta/student/assessment/list/upcoming?", {
student,
});
return res.payload;
}
async function loadPast(student: number, subjects: Subject[]) {
const map: Record<number, any> = {};
await Promise.all(
subjects.map(async (s) => {
const res = await fetchJSON("/seqta/student/assessment/list/past?", {
programme: s.programme,
metaclass: s.metaclass,
student,
});
if (res.payload.tasks) {
res.payload.tasks.forEach((t: any) => {
map[t.id] = t;
});
}
}),
);
return map;
}
async function loadSubmissions(student: number, assessments: any[]) {
const submissionMap: Record<number, boolean> = {};
await Promise.all(
assessments.map(async (assessment) => {
try {
const res = await fetchJSON(
"/seqta/student/assessment/submissions/get",
{
assessment: assessment.id,
metaclass: assessment.metaclassID,
student,
},
);
submissionMap[assessment.id] = res.payload && res.payload.length > 0;
} catch (error) {
console.warn(
`Failed to fetch submission for assessment ${assessment.id}:`,
error,
);
submissionMap[assessment.id] = false;
}
}),
);
return submissionMap;
}
export async function getAssessmentsData() {
if (settingsState.mockNotices) {
return getMockAssessmentsData();
}
if (cache && Date.now() - cache.time < CACHE_MS) return cache.data;
const [subjects, colors, upcoming] = await Promise.all([
loadSubjects(),
loadPrefs(student),
loadUpcoming(student),
]);
const pastMap = await loadPast(student, subjects);
const map: Record<number, any> = {};
upcoming.forEach((a: any) => {
map[a.id] = { ...a };
});
Object.values(pastMap).forEach((t: any) => {
if (map[t.id]) Object.assign(map[t.id], t);
else map[t.id] = t;
});
const allAssessments = Object.values(map);
const submissions = await loadSubmissions(student, allAssessments);
allAssessments.forEach((assessment: any) => {
assessment.submitted = submissions[assessment.id] || false;
});
const data = { assessments: allAssessments, subjects, colors };
cache = { time: Date.now(), data };
return data;
}
@@ -0,0 +1,86 @@
import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api";
import { renderSkeletonLoader, renderErrorState } from "./ui";
import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay";
const assessmentsOverviewPlugin: Plugin<{}> = {
id: "assessments-overview",
name: "Assessments Overview",
description:
"Adds an overview option to the assessments page that organizes assessments by status",
version: "1.0.0",
settings: {},
disableToggle: false,
styles,
run: async () => {
const menu = (await waitForElm(
'[data-key="assessments"] > .sub > ul',
true,
100,
60,
)) as HTMLElement;
const gridItem = document.createElement("li");
gridItem.className = "item";
const label = document.createElement("label");
label.textContent = "Overview";
gridItem.appendChild(label);
menu.insertBefore(gridItem, menu.children[1] || null);
if (window.location.hash.includes("/assessments/overview")) {
loadGridView();
}
const clickHandler = (e: Event) => {
e.preventDefault();
loadGridView();
};
gridItem.addEventListener("click", clickHandler);
async function loadGridView() {
await delay(1);
window.history.pushState({}, "", "/#?page=/assessments/overview");
document.title = "Overview ― SEQTA Learn";
const main = document.getElementById("main");
if (!main) return;
document
.querySelectorAll('[data-key="assessments"] .item')
.forEach((item) => {
item.classList.remove("active");
});
gridItem.classList.add("active");
document
.querySelector('[data-key="assessments"]')
?.classList.add("active");
main.innerHTML = '<div id="grid-view-container"></div>';
const container = document.getElementById(
"grid-view-container",
) as HTMLElement;
renderSkeletonLoader(container);
try {
const data = await getAssessmentsData();
const { renderGrid } = await import("./ui");
renderGrid(container, data);
} catch (err) {
console.error("Failed to load assessments:", err);
renderErrorState(
container,
err instanceof Error ? err.message : "Unknown error",
);
}
}
return () => {
gridItem.removeEventListener("click", clickHandler);
gridItem.remove();
};
},
};
export default assessmentsOverviewPlugin;
@@ -0,0 +1,798 @@
#grid-view-container {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.grid-view-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
flex-shrink: 0;
}
.grid-view-title {
font-size: 1.875rem !important;
font-weight: 700;
color: #1a1a1a;
margin: 0;
}
/* Dark mode support */
.dark .grid-view-title {
color: #f8fafc;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.grid-view-filters {
display: flex;
gap: 0.75rem;
align-items: center;
}
.filter-select {
background: #ffffff !important;
border: 2px solid #e2e8f0;
border-radius: 8px;
color: #1a1a1a;
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
min-width: 180px;
}
.filter-select:focus {
outline: none;
border-color: #d41e3a;
box-shadow: 0 0 0 3px rgba(212, 30, 58, 0.1);
}
.filter-select:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Dark mode dropdowns */
.dark .filter-select {
background: var(--background-primary) !important;
border-color: var(--background-secondary);
color: var(--text-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dark .filter-select:focus {
border-color: #d41e3a;
box-shadow: 0 0 0 3px rgba(212, 30, 58, 0.2);
}
.dark .filter-select:hover {
border-color: var(--background-secondary);
background: var(--background-secondary);
}
.dark .filter-select option {
background: var(--background-primary);
color: var(--text-primary);
}
#main-grid-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Kanban Board Layout */
.kanban-board {
display: flex;
gap: 1.5rem;
overflow-x: auto;
padding: 1rem;
flex: 0 1 auto;
}
.kanban-column-parent {
flex: 0 0 320px;
}
.kanban-column {
max-height: 100%;
background: #f8fafc;
border-radius: 12px;
box-shadow: 0 0 0 2px #e2e8f0;
display: flex;
flex-direction: column;
min-height: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Dark mode columns */
.dark .kanban-column {
background: var(--background-primary);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
.column-header {
padding: 1rem 1.25rem;
border-bottom: 2px solid #e2e8f0;
background: #ffffff;
border-radius: 12px 12px 0 0;
position: sticky;
top: 0;
z-index: 10;
}
/* Dark mode column headers */
.dark .column-header {
background: var(--background-secondary);
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.column-title {
font-size: 1rem;
font-weight: 600;
color: #1a1a1a;
margin: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.dark .column-title {
color: var(--text-primary);
}
.column-count {
background: #e2e8f0;
color: #64748b;
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
}
.dark .column-count {
background: var(--background-secondary);
color: var(--text-primary);
}
.column-cards {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
overflow-y: auto;
}
/* Assessment Cards */
.assessment-card {
background: #ffffff;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
cursor: pointer;
position: relative;
border-left: 4px solid var(--subject-color, #d41e3a);
border: 1px solid #e2e8f0;
}
.assessment-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
border-color: #cbd5e1;
}
/* Dark mode cards */
.dark .assessment-card {
background: var(--background-secondary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.dark .assessment-card:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.15);
}
.card-labels {
display: flex;
gap: 0.25rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.card-label {
padding: 0.25rem 0.75rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
color: #ffffff;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.label-subject {
background: var(--subject-color, #d41e3a);
}
.card-menu {
position: absolute;
top: 0.75rem;
right: 0.75rem;
z-index: 20;
}
.menu-button {
background: transparent !important;
border: none !important;
padding: 0.25rem !important;
cursor: pointer;
border-radius: 4px;
color: #64748b;
transition: all 0.2s ease;
display: flex !important;
align-items: center;
justify-content: center;
width: 24px !important;
height: 24px !important;
margin: 0 !important;
position: static !important;
transform: none !important;
box-shadow: none !important;
outline: none !important;
}
.menu-button:hover {
background: #f1f5f9 !important;
color: #1a1a1a;
}
.menu-button svg {
width: 16px !important;
height: 16px !important;
fill: currentColor !important;
display: block !important;
}
.dark .menu-button {
color: var(--text-primary);
opacity: 0.7;
}
.dark .menu-button:hover {
background: rgba(255, 255, 255, 0.1) !important;
opacity: 1;
}
.menu-dropdown {
position: absolute;
top: 100%;
right: 0;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 140px;
z-index: 30;
margin-top: 4px;
}
.dark .menu-dropdown {
background: var(--background-secondary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.menu-item {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
font-size: 0.875rem;
color: #1a1a1a;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.menu-item:hover {
background: #f8fafc;
}
.dark .menu-item {
color: var(--text-primary);
}
.dark .menu-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.menu-item.mark-completed {
color: #059669;
font-weight: 500;
}
.dark .menu-item.mark-completed {
color: #10b981;
}
.menu-item.mark-not-completed {
color: #dc2626;
font-weight: 500;
}
.dark .menu-item.mark-not-completed {
color: #ef4444;
}
.assessment-title {
font-size: 0.875rem;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 0.75rem 0;
line-height: 1.4;
padding-right: 2rem; /* Make room for menu button */
}
.dark .assessment-title {
color: var(--text-primary);
}
.assessment-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.75rem;
font-size: 0.75rem;
color: #64748b;
}
.dark .assessment-meta {
color: var(--text-primary);
opacity: 0.7;
}
.due-date {
display: flex;
align-items: center;
gap: 0.25rem;
font-weight: 500;
}
.due-date.overdue {
color: #dc2626;
}
.due-date.due-soon {
color: #d97706;
}
.due-date.upcoming {
color: #059669;
}
.card-footer {
display: flex;
align-items: center;
justify-content: flex-start;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #e5e7eb;
}
.dark .card-footer {
border-top-color: rgba(255, 255, 255, 0.1);
}
.grade-display {
font-weight: 700;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
border-radius: 6px;
}
.grade-good {
background: rgba(16, 185, 129, 0.1);
color: #059669;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.grade-average {
background: rgba(245, 158, 11, 0.1);
color: #d97706;
border: 1px solid rgba(245, 158, 11, 0.2);
}
.grade-bad {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
border: 1px solid rgba(239, 68, 68, 0.2);
}
.grade-empty {
color: #64748b;
font-style: italic;
font-weight: 500;
background: #f1f5f9;
border: 1px solid #e2e8f0;
}
.dark .grade-empty {
color: var(--text-primary);
opacity: 0.7;
background: var(--background-secondary);
border-color: var(--background-secondary);
}
/* Column-specific styling */
.column-upcoming .column-header {
background: linear-gradient(135deg, #ffffff 0%, #f0f9ff 100%);
}
.column-due-soon .column-header {
background: linear-gradient(135deg, #ffffff 0%, #fffbeb 100%);
}
.column-overdue .column-header {
background: linear-gradient(135deg, #ffffff 0%, #fef2f2 100%);
}
.column-submitted .column-header {
background: linear-gradient(135deg, #ffffff 0%, #fef3c7 100%);
}
.column-marked .column-header {
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
}
/* Dark mode column headers */
.dark .column-upcoming .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #1e3a8a 100%);
}
.dark .column-due-soon .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #92400e 100%);
}
.dark .column-overdue .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #991b1b 100%);
}
.dark .column-submitted .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #92400e 100%);
}
.dark .column-marked .column-header {
background: linear-gradient(135deg, var(--background-secondary) 0%, #065f46 100%);
}
/* Subject filter view */
.subject-section {
margin-bottom: 2rem;
}
.subject-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 1rem;
background: #ffffff;
border-radius: 8px;
border: 2px solid #e2e8f0;
border-left: 4px solid var(--subject-color, #d41e3a);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.dark .subject-header {
background: var(--background-secondary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.subject-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.subject-title {
font-size: 1.125rem;
font-weight: 600;
color: #1a1a1a;
margin: 0;
}
.dark .subject-title {
color: var(--text-primary);
}
.subject-code {
font-size: 0.875rem;
color: #64748b;
}
.dark .subject-code {
color: var(--text-primary);
opacity: 0.7;
}
/* Loading and error states */
.loading-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 4rem 0;
background: #ffffff;
border-radius: 12px;
border: 2px solid #e2e8f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dark .loading-container {
background: var(--background-primary);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.loading-spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid #e2e8f0;
border-top: 3px solid #d41e3a;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.dark .loading-spinner {
border-color: rgba(255, 255, 255, 0.1);
border-top-color: #d41e3a;
}
.loading-text {
margin-top: 1rem;
color: #64748b;
font-size: 0.875rem;
font-weight: 500;
}
.dark .loading-text {
color: var(--text-primary);
opacity: 0.7;
}
.error-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 4rem 0;
background: #ffffff;
border-radius: 12px;
border: 2px solid #fecaca;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dark .error-container {
background: var(--background-primary);
border-color: #991b1b;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.error-text {
color: #ef4444;
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.empty-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 2rem;
color: #64748b;
font-size: 0.875rem;
text-align: center;
}
.dark .empty-state {
color: var(--text-primary);
opacity: 0.7;
}
.empty-column {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
color: #64748b;
font-size: 0.875rem;
text-align: center;
min-height: 200px;
}
.dark .empty-column {
color: var(--text-primary);
opacity: 0.7;
}
.empty-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.5;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.skeleton-element {
background: linear-gradient(
90deg,
#f1f5f9 0%,
#e2e8f0 50%,
#f1f5f9 100%
);
background-size: 1000px 100%;
animation: shimmer 2s infinite linear;
border-radius: 4px;
}
.dark .skeleton-element {
background: linear-gradient(
90deg,
var(--background-primary) 0%,
var(--background-secondary) 50%,
var(--background-primary) 100%
);
background-size: 1000px 100%;
}
.skeleton-label {
height: 20px;
width: 60px;
margin-bottom: 0.75rem;
border-radius: 6px;
}
.skeleton-title {
height: 16px;
width: 80%;
margin-bottom: 0.5rem;
}
.skeleton-title-line2 {
height: 16px;
width: 60%;
margin-bottom: 0.75rem;
}
.skeleton-meta {
height: 12px;
width: 40%;
margin-top: 0.75rem;
}
.skeleton-footer {
height: 8px;
width: 100%;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #e5e7eb;
}
.dark .skeleton-footer {
border-top-color: rgba(255, 255, 255, 0.1);
}
/* Responsive design */
@media (max-width: 768px) {
#grid-view-container {
padding: 1rem;
}
.grid-view-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.grid-view-filters {
justify-content: center;
flex-wrap: wrap;
}
.kanban-board {
flex-direction: column;
gap: 1rem;
}
.kanban-column {
flex: none;
max-height: none;
}
.filter-select {
min-width: 140px;
}
}
@media (max-width: 480px) {
.grid-view-title {
font-size: 1.5rem !important;
}
.filter-select {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
min-width: 120px;
}
.assessment-card {
padding: 0.75rem;
}
.card-footer {
flex-direction: column;
gap: 0.5rem;
align-items: stretch;
}
}
/* Scrollbar styling for webkit browsers */
.column-cards::-webkit-scrollbar {
width: 6px;
}
.column-cards::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.column-cards::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.column-cards::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Dark mode scrollbars */
.dark .column-cards::-webkit-scrollbar-track {
background: var(--background-secondary);
}
.dark .column-cards::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
.dark .column-cards::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
@@ -0,0 +1,45 @@
import renderSvelte from "@/interface/main";
import AssessmentsOverview from "./AssessmentsOverview.svelte";
import SkeletonLoader from "./SkeletonLoader.svelte";
import ErrorState from "./ErrorState.svelte";
import { unmount } from "svelte";
let currentApp: any = null;
export function renderGrid(container: HTMLElement, data: any) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(AssessmentsOverview, container, { data });
}
export function renderSkeletonLoader(container: HTMLElement) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(SkeletonLoader, container);
}
export function renderLoadingState(container: HTMLElement) {
renderSkeletonLoader(container);
}
export function renderErrorState(container: HTMLElement, error: string) {
if (currentApp) {
unmount(currentApp);
}
container.innerHTML = "";
container.className = "";
currentApp = renderSvelte(ErrorState, container, { error });
}
@@ -0,0 +1,101 @@
export function formatDate(dateStr: string, submitted?: boolean): string {
const d = new Date(dateStr);
const now = new Date();
const diffTime = d.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0 && !submitted) {
const overdueDays = Math.abs(diffDays);
if (overdueDays === 1) return "1 day overdue";
return `${overdueDays} days overdue`;
}
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Tomorrow";
if (diffDays <= 7) {
const weekdayName = d.toLocaleDateString(undefined, { weekday: "long" });
return diffDays < 0 ? `Last ${weekdayName}` : weekdayName;
}
return d.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function determineStatus(item: any): string {
if (
item.status === "MARKS_RELEASED" ||
item.grade ||
(item.percentage !== undefined && item.percentage !== null) ||
(item.achieved !== undefined && item.achieved !== null)
) {
return "MARKS_RELEASED";
}
const completedKey = "betterseqta-completed-assessments";
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
if (completed.includes(item.id)) {
return "MARKS_RELEASED";
}
if (item.submitted) {
return "SUBMITTED";
}
const now = new Date();
const due = new Date(item.due);
const diffTime = due.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return "OVERDUE";
}
if (diffDays <= 7) {
return "DUE_SOON";
}
return "UPCOMING";
}
export function getGradeValue(assessment: any): number | null {
if (
assessment.results?.percentage !== undefined &&
assessment.results.percentage !== null
) {
return assessment.results.percentage;
}
if (assessment.percentage !== undefined && assessment.percentage !== null) {
return assessment.percentage;
}
if (
assessment.achieved !== undefined &&
assessment.outOf !== undefined &&
assessment.achieved !== null &&
assessment.outOf !== null &&
assessment.outOf > 0
) {
return (assessment.achieved / assessment.outOf) * 100;
}
if (
assessment.results?.achieved !== undefined &&
assessment.results?.outOf !== undefined &&
assessment.results.achieved !== null &&
assessment.results.outOf !== null &&
assessment.results.outOf > 0
) {
return (assessment.results.achieved / assessment.results.outOf) * 100;
}
return null;
}
@@ -0,0 +1,93 @@
<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte';
import { calculateExpression } from '../utils/calculator';
let { searchTerm = '', isSelected = false } = $props<{ searchTerm: string, isSelected: boolean }>();
const dispatch = createEventDispatcher<{
hasResult: string | null;
}>();
let result = $state<string | null>(null);
let isCalculating = $state(false);
let inputUnit = $state<string>('');
let outputUnit = $state<string>('');
let isPartial = $state(false);
const processInput = (input: string) => {
isCalculating = true;
try {
const calcResult = calculateExpression(input);
if (calcResult.isValid) {
result = calcResult.result;
inputUnit = calcResult.inputUnit;
outputUnit = calcResult.outputUnit;
isPartial = calcResult.isPartial;
dispatch('hasResult', calcResult.result);
} else {
result = null;
inputUnit = '';
outputUnit = '';
isPartial = false;
dispatch('hasResult', null);
}
} catch (e) {
result = null;
inputUnit = '';
outputUnit = '';
isPartial = false;
dispatch('hasResult', null);
} finally {
isCalculating = false;
}
}
$effect(() => {
processInput(searchTerm);
});
onDestroy(() => {
dispatch('hasResult', null);
});
</script>
{#if result !== null}
<div class="">
<p class="text-[0.85rem] p-1 pb-0.5 pt-0 font-semibold text-zinc-500 dark:text-zinc-400">Calculator</p>
<div class="flex items-center justify-between gap-8 rounded-lg border border-transparent {isSelected ? 'bg-zinc-900/5 dark:bg-white/10 border-zinc-900/5 dark:border-zinc-100/5' : ''}">
<div class="flex flex-col flex-1 items-center py-4 pl-4 min-w-0">
<div class="overflow-hidden py-2 w-full font-semibold text-center whitespace-nowrap text-zinc-900 dark:text-white text-ellipsis"
style="--char-count: {searchTerm?.length || 10}; font-size: min(2.5rem, max(1rem, calc(35vw / var(--char-count, 10))))">
{searchTerm}
</div>
<div class="px-3 py-1 mt-1 text-sm rounded-md text-zinc-900 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-100/10">
{inputUnit || 'Question'}
</div>
</div>
<div class="flex flex-col flex-shrink-0 justify-center items-center w-12">
<div class="h-8 w-[1px] bg-zinc-900/5 dark:bg-zinc-100/5"></div>
<div class="text-2xl text-zinc-900 dark:text-zinc-100">
</div>
<div class="h-8 w-[1px] bg-zinc-900/5 dark:bg-zinc-100/5"></div>
</div>
{#if !isCalculating}
<div class="flex flex-col flex-1 items-center py-4 pr-4 min-w-0">
<div class="overflow-hidden py-2 w-full font-semibold text-center whitespace-nowrap text-zinc-900 dark:text-white text-ellipsis"
style="--char-count: {result?.length || 10}; font-size: min(2.5rem, max(1rem, calc(30vw / var(--char-count, 10))))">
{result}
</div>
<div class="px-3 py-1 mt-1 text-sm rounded-md text-zinc-900 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-100/10">
{outputUnit || (isPartial ? 'Partial' : 'Result')}
</div>
</div>
{:else}
<div class="w-6 h-6 rounded-full border-2 animate-spin border-zinc-300 dark:border-zinc-700 border-t-zinc-600 dark:border-t-zinc-300"></div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,422 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import { fade, scale } from 'svelte/transition';
import { circOut, quintOut } from 'svelte/easing';
import { type StaticCommandItem } from '../core/commands';
import type { CombinedResult } from '../core/types';
import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils';
import Fuse from 'fuse.js';
import Calculator from './Calculator.svelte';
import { actionMap } from '../indexing/actions';
import type { IndexItem } from '../indexing/types';
import debounce from 'lodash/debounce';
import { renderComponentMap } from '../indexing/renderComponents';
import HighlightedText from '../utils/HighlightedText.svelte';
import { matchesHotkey } from '../utils/hotkeyUtils';
import browser from 'webextension-polyfill';
const {
transparencyEffects,
searchHotkey: initialSearchHotkey
} = $props<{
transparencyEffects: boolean,
searchHotkey: string
}>();
let currentSearchHotkey = $state(initialSearchHotkey);
let commandsFuse = $state<Fuse<StaticCommandItem>>();
let dynamicContentFuse = $state<Fuse<IndexItem>>();
const dynamicIdToItemMap = $state(new Map<string, IndexItem>());
const commandIdToItemMap = $state(new Map<string, StaticCommandItem>());
let isIndexing = $state(false);
let completedJobs = $state(0);
let totalJobs = $state(0);
let commandPalleteOpen = $state(false);
let searchTerm = $state('');
let selectedIndex = $state(0);
let combinedResults = $state<CombinedResult[]>([]);
let searchbar = $state<HTMLInputElement>();
let isLoading = $state(false);
let calculatorResult = $state<string | null>(null);
let resultsList = $state<HTMLUListElement>();
const updateCalculatorState = (hasResult: string | null) => {
calculatorResult = hasResult;
};
let keydownHandler: ((e: KeyboardEvent) => void) | null = null;
// Listen for setting changes
$effect(() => {
const loadSettings = async () => {
const settings = await browser.storage.local.get('plugin.global-search.settings');
const pluginSettings = settings['plugin.global-search.settings'] as { searchHotkey?: string } | undefined;
if (pluginSettings?.searchHotkey) {
currentSearchHotkey = pluginSettings.searchHotkey;
}
};
loadSettings();
// Listen for storage changes
const handleStorageChange = (changes: any, area: string) => {
if (area === 'local' && changes['plugin.global-search.settings']) {
const newSettings = changes['plugin.global-search.settings'].newValue as { searchHotkey?: string } | undefined;
if (newSettings?.searchHotkey) {
currentSearchHotkey = newSettings.searchHotkey;
}
}
};
browser.storage.onChanged.addListener(handleStorageChange);
return () => {
browser.storage.onChanged.removeListener(handleStorageChange);
};
});
// Update keydown handler when hotkey changes
$effect(() => {
if (keydownHandler) {
window.removeEventListener('keydown', keydownHandler);
}
keydownHandler = (e: KeyboardEvent) => {
if (matchesHotkey(e, currentSearchHotkey)) {
e.preventDefault();
commandPalleteOpen = true;
tick().then(() => searchbar?.focus());
}
if (e.key === 'Escape') {
commandPalleteOpen = false;
}
};
window.addEventListener('keydown', keydownHandler);
return () => {
if (keydownHandler) {
window.removeEventListener('keydown', keydownHandler);
keydownHandler = null;
}
};
});
onMount(() => {
const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing } = event.detail;
completedJobs = completed;
totalJobs = total;
isIndexing = indexing;
};
window.addEventListener('indexing-progress', progressHandler as EventListener);
const itemsUpdatedHandler = () => {
setupSearchIndexes();
performSearch();
};
window.addEventListener('dynamic-items-updated', itemsUpdatedHandler);
setupSearchIndexes();
// @ts-ignore - Intentionally adding to window
window.setCommandPalleteOpen = (open: boolean) => {
commandPalleteOpen = open;
};
return () => {
window.removeEventListener('indexing-progress', progressHandler as EventListener);
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
};
});
function setupSearchIndexes() {
const { commandsFuse: cfuse, dynamicContentFuse: dfuse, commands, dynamicItems } = createSearchIndexes();
commandsFuse = cfuse;
dynamicContentFuse = dfuse;
dynamicIdToItemMap.clear();
commandIdToItemMap.clear();
dynamicItems.forEach(item => dynamicIdToItemMap.set(item.id, item));
commands.forEach(item => commandIdToItemMap.set(item.id, item));
console.debug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`);
}
const performSearch = async () => {
isLoading = true;
selectedIndex = 0;
tick().then(() => {
const selectedElement = resultsList?.querySelector(`li:nth-child(1)`);
selectedElement?.scrollIntoView({ block: 'nearest' });
});
const term = searchTerm.trim().toLowerCase();
if (commandsFuse && dynamicContentFuse) {
combinedResults = await doSearch(
term,
commandsFuse,
commandIdToItemMap,
);
} else {
combinedResults = [];
}
isLoading = false;
};
const debouncedPerformSearch = debounce(performSearch, 20);
$effect(() => {
if (commandPalleteOpen) {
if (searchTerm === '') {
performSearch();
} else {
debouncedPerformSearch();
}
tick().then(() => searchbar?.focus());
} else {
searchTerm = '';
selectedIndex = 0;
combinedResults = [];
}
});
$effect(() => {
if (combinedResults.length === 0 && calculatorResult && commandPalleteOpen) {
selectedIndex = 0;
}
});
const selectNext = () => {
const maxIndex = (calculatorResult ? 1 : 0) + combinedResults.length - 1;
if (selectedIndex < maxIndex) {
selectedIndex++;
tick().then(() => {
const selectedElement = resultsList?.querySelector(`li:nth-child(${selectedIndex + 1})`);
selectedElement?.scrollIntoView({ block: 'nearest' });
});
}
};
const selectPrev = () => {
if (selectedIndex > 0) {
selectedIndex--;
tick().then(() => {
const selectedElement = resultsList?.querySelector(`li:nth-child(${selectedIndex + 1})`);
selectedElement?.scrollIntoView({ block: 'nearest' });
});
}
};
function executeItemAction(item: StaticCommandItem | IndexItem) {
if ('action' in item && typeof item.action === 'function') {
(item as StaticCommandItem).action();
} else if ('actionId' in item && item.actionId && actionMap[item.actionId]) {
actionMap[item.actionId](item as IndexItem);
}
commandPalleteOpen = false;
}
const executeSelected = () => {
if (calculatorResult && selectedIndex === 0) {
navigator.clipboard.writeText(calculatorResult);
commandPalleteOpen = false;
} else {
const resultIndex = calculatorResult ? selectedIndex - 1 : selectedIndex;
const result = combinedResults[resultIndex];
if (result?.item) {
executeItemAction(result.item);
}
}
};
const handleKeyNav = (e: KeyboardEvent) => {
// Handle regular navigation
if (e.key === 'ArrowDown') {
e.preventDefault();
selectNext();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectPrev();
} else if (e.key === 'Enter') {
e.preventDefault();
executeSelected();
} else if (e.key === 'Escape') {
commandPalleteOpen = false;
}
};
</script>
{#if commandPalleteOpen}
<div role="dialog" aria-modal="true" class={settingsState.DarkMode ? 'dark' : ''}>
<div
class="fixed inset-0 z-[50000] bg-zinc-900/40 dark:bg-black/60"
transition:fade={{ duration: 150, easing: quintOut }}
></div>
<div class="fixed inset-0 z-[50000] flex justify-center place-items-start p-8 sm:p-6 md:p-8 select-none scale-120 origin-top"
onclick={() => commandPalleteOpen = false}
onkeydown={(e: KeyboardEvent) => e.key === 'Escape' && (commandPalleteOpen = false)}
role="button"
tabindex="0">
<div
class="w-full max-w-2xl overflow-clip rounded-xl ring-1 shadow-2xl ring-black/5 dark:ring-white/10 { transparencyEffects ? 'bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xl' : 'bg-white dark:bg-zinc-900' }"
transition:scale={{ duration: 100, start: 0.95, opacity: 0, easing: circOut }}
onclick={(e: MouseEvent) => {
e.stopPropagation();
}}
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Escape') {
commandPalleteOpen = false;
}
}}
role="button"
tabindex="0">
<div class="relative p-2 border-b border-zinc-900/5 dark:border-zinc-100/5">
<div class="absolute top-1/2 translate-y-[calc(-50%-3px)] scale-105 left-5 w-6 h-6 text-[1.3rem] text-zinc-900 dark:text-zinc-400 text-opacity-40 pointer-events-none font-IconFamily">
{'\ueca5'}
</div>
<input
bind:this={searchbar}
bind:value={searchTerm}
onkeydown={handleKeyNav}
class="pr-4 pl-12 w-full h-10 text-lg bg-transparent border-0 outline-none placeholder-zinc-400 text-zinc-700 dark:placeholder-zinc-500 dark:text-white focus:ring-0 sm:text-xl"
placeholder="Search..."
/>
</div>
<ul
bind:this={resultsList}
class="overflow-y-auto max-h-[24rem] text-base scroll-py-2 p-2 gap-0.5 flex flex-col"
>
<Calculator
searchTerm={searchTerm}
isSelected={selectedIndex === 0}
on:hasResult={(e) => updateCalculatorState(e.detail)}
/>
{#if combinedResults.length > 0}
{#each combinedResults as result, i (result.id)}
{@const isSelected = selectedIndex === (calculatorResult ? i + 1 : i)}
{@const item = result.item}
<li>
{#if result.type === 'command'}
{@const staticItem = item as StaticCommandItem}
<button
class="w-full flex items-center px-2 py-1.5 rounded-lg select-none cursor-pointer group transition-colors duration-100 ring-0 dark:ring-zinc-600/50
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white dark:ring-[1px] dark:shadow' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={() => executeItemAction(staticItem)}
>
<div class="flex-none scale-90 w-8 h-8 text-xl font-IconFamily flex items-center justify-center bg-gradient-to-br {staticItem.category === 'navigation' ? 'from-[#FA5D5D] to-[#DC2F30]' : 'from-[#4FBBFE] to-[#2090F3]'} rounded-md text-white">{staticItem.icon}</div>
<span class="ml-4 text-lg truncate">
<HighlightedText text={staticItem.text} term={searchTerm} matches={result.matches} />
</span>
</button>
{:else if result.type === 'dynamic'}
{@const dynamicItem = item as IndexItem}
{@const RenderComponent = renderComponentMap[dynamicItem.renderComponentId]}
{#if RenderComponent}
<RenderComponent
item={dynamicItem}
isSelected={isSelected}
searchTerm={searchTerm}
matches={result.matches}
onclick={() => executeItemAction(dynamicItem)}
onkeydown={() => executeItemAction(dynamicItem)}
role="button"
tabindex="0"
/>
{:else}
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white dark:ring-[1px]' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={() => executeItemAction(dynamicItem)}
>
<div class="flex items-center w-full">
<div class="flex-none w-8 h-8 text-xl font-IconFamily flex items-center justify-center {isSelected ? 'text-zinc-900 dark:text-white' : 'text-zinc-600 dark:text-zinc-400'}">{dynamicItem.metadata?.icon || '\ue924'}</div>
<span class="ml-4 text-lg truncate">
<HighlightedText text={dynamicItem.text} term={searchTerm} matches={result.matches} />
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
{dynamicItem.category}
</span>
</div>
{#if dynamicItem.content}
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
<HighlightedText text={dynamicItem.content} term={searchTerm} matches={result.matches} />
</div>
{/if}
</button>
{/if}
{/if}
</li>
{/each}
{:else if !calculatorResult}
<div class="px-8 py-16 text-center text-zinc-900 dark:text-zinc-200 sm:px-16">
{#if isLoading}
<div class="mx-auto w-8 h-8 rounded-full border-2 animate-spin border-zinc-300 dark:border-zinc-700 border-t-zinc-600 dark:border-t-zinc-300"></div>
<p class="mt-4 text-lg dark:text-zinc-300">Searching...</p>
{:else}
<svg class="mx-auto w-8 h-8 text-opacity-40 dark:text-opacity-60" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
<p class="mt-6 text-lg dark:text-zinc-300">No matches found. Try something else.</p>
{/if}
</div>
{/if}
</ul>
<div class="px-3 py-2 w-full border-t border-zinc-900/5 dark:border-zinc-100/5 bg-white/5">
{#if combinedResults.length > 0 || calculatorResult}
<div class="flex justify-between items-center h-7 text-sm text-zinc-500 dark:text-zinc-400">
<div class="flex gap-4 items-center">
{@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})}
{#if calculatorResult && selectedIndex === 0}
{@render Shortcut({ text: 'Copy result', keybind: ['↵']})}
{:else}
{@render Shortcut({ text: 'Select', keybind: ['↵']})}
{/if}
</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>
{/if}
</div>
</div>
</div>
</div>
{/if}
{#snippet Shortcut({ text, keybind }: { text: string, keybind: string[] }) }
<div class="flex gap-2 items-center">
<div class="flex gap-1 items-center">
{#each keybind as key}
<kbd class="size-6 text-[0.9rem] flex justify-center items-center rounded bg-zinc-100 dark:bg-zinc-100/10">{key}</kbd>
{/each}
</div>
<span>{text}</span>
</div>
{/snippet}
@@ -0,0 +1,34 @@
<script lang="ts">
import HighlightedText from '../../utils/HighlightedText.svelte';
import type { DynamicContentItem } from '../../utils/dynamicItems';
import type { FuseResultMatch } from '../../core/types';
const { item, isSelected, searchTerm, matches, onclick } = $props<{
item: DynamicContentItem;
isSelected: boolean;
searchTerm: string;
matches?: readonly FuseResultMatch[];
onclick: () => void;
}>();
</script>
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group transition-colors duration-100 ring-0 dark:ring-zinc-600/50
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white dark:ring-[1px] dark:shadow' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={onclick}
>
<div class="flex items-center w-full">
<div class="flex-none scale-90 w-8 h-8 text-xl font-IconFamily flex items-center justify-center bg-gradient-to-br from-[#59F675] to-[#1BC636] rounded-md text-white">{item.metadata?.icon || '\uebee'}</div>
<span class="ml-4 text-lg truncate">
<HighlightedText text={item.text} term={searchTerm} matches={matches} />
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
{item.category}
</span>
</div>
{#if item.content}
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
<HighlightedText text={item.content} term={searchTerm} matches={matches} />
</div>
{/if}
</button>
@@ -0,0 +1,29 @@
<script lang="ts">
import HighlightedText from '../../utils/HighlightedText.svelte';
import type { DynamicContentItem } from '../../utils/dynamicItems';
import type { FuseResultMatch } from '../../core/types';
const { item, isSelected, searchTerm, matches, onclick } = $props<{
item: DynamicContentItem;
isSelected: boolean;
searchTerm: string;
matches?: readonly FuseResultMatch[];
onclick: () => void;
}>();
</script>
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group transition-colors duration-100 ring-0 dark:ring-zinc-600/50
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white dark:ring-[1px] dark:shadow' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={onclick}
>
<div class="flex items-center w-full">
<div class="flex-none scale-90 w-8 h-8 text-xl font-IconFamily flex items-center justify-center text-white bg-gradient-to-br from-[#59aaf6] to-[#1b62c6] rounded-md">{item.metadata?.icon || '\uebe7'}</div>
<span class="ml-4 text-lg truncate {item.metadata?.closed ? 'line-through opacity-80' : ''}">
<HighlightedText text={item.text} term={searchTerm} matches={matches} />
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
{item.category}
</span>
</div>
</button>
@@ -0,0 +1,36 @@
<script lang="ts">
import HighlightedText from '../../utils/HighlightedText.svelte';
import type { IndexItem } from '../../indexing/types';
import type { FuseResultMatch } from '../../core/types';
export let item: IndexItem;
export let isSelected: boolean;
export let searchTerm: string;
export let matches: readonly FuseResultMatch[] | undefined;
export let onclick: (() => void) | undefined;
</script>
<button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group transition-colors duration-100 ring-0 dark:ring-zinc-600/50
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white dark:ring-[1px] dark:shadow' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={onclick}
>
<div class="flex items-center w-full">
<div class="flex-none scale-90 w-8 h-8 text-xl font-IconFamily flex items-center justify-center text-white {item.metadata?.type === 'assessments' ? 'bg-gradient-to-br from-[#fa915d] to-[#dc6c2f] rounded-md' : 'bg-gradient-to-br from-[#4FBBFE] to-[#2090F3] rounded-md'} {item.metadata.isActive ? 'opacity-100' : 'opacity-80'}">
{item.metadata?.type === 'assessments' ? '\ueac3' : '\ueb4d'}
</div>
<span class="ml-4 text-lg truncate {item.metadata.isActive ? 'opacity-100' : 'opacity-70'}">
<HighlightedText text={item.text} term={searchTerm} matches={matches} />
</span>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400 {item.metadata.isActive ? 'opacity-100' : 'opacity-70'}">
{item.metadata?.subjectCode}
</span>
</div>
{#if item.content}
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
<HighlightedText text={item.content} term={searchTerm} matches={matches} />
</div>
{/if}
</button>
@@ -0,0 +1,238 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { waitForElm } from "@/seqta/utils/waitForElm";
export interface BaseCommandItem {
id: string;
text: string;
category: string;
icon: string;
action: () => void;
keywords?: string[];
priority?: number;
}
export interface StaticCommandItem extends BaseCommandItem {
keybind?: string[];
keybindLabel?: string[];
}
// Function to get current lesson
async function getCurrentLesson() {
const date = new Date();
const todayFormatted = formatDate(date);
try {
const response = await fetch(`${location.origin}/seqta/student/load/timetable?`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from: todayFormatted,
until: todayFormatted,
student: 69,
}),
});
const timetableData = await response.json();
if (!timetableData.payload.items.length) {
alert("No lessons today!");
return null;
}
const lessons = timetableData.payload.items.sort((a: any, b: any) =>
a.from.localeCompare(b.from)
);
const currentTime = new Date();
for (const lesson of lessons) {
const [startHour, startMinute] = lesson.from.split(":").map(Number);
const [endHour, endMinute] = lesson.until.split(":").map(Number);
const startDate = new Date(currentTime);
startDate.setHours(startHour, startMinute, 0);
const endDate = new Date(currentTime);
endDate.setHours(endHour, endMinute, 0);
if (startDate <= currentTime && endDate > currentTime) {
return lesson;
}
}
alert("There is no current lesson!");
return null;
} catch (error) {
console.error("Error fetching current lesson:", error);
alert("Error getting current lesson. Please try again.");
return null;
}
}
async function navigateToSpecificLesson(lesson: any) {
try {
await waitForElm(".course .navigator", true, 100, 100);
const today = new Date();
const todayDateString = today.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short'
});
const weeks = document.querySelectorAll(".course .navigator .week");
for (const week of weeks) {
// Look for lessons in this week
const lessons = week.querySelectorAll(".lesson");
for (const lessonElement of lessons) {
const metaElement = lessonElement.querySelector(".meta");
if (!metaElement) continue;
const dateElement = metaElement.querySelector(".date");
const periodElement = metaElement.querySelector(".period");
if (!dateElement || !periodElement) continue;
const lessonDate = dateElement.textContent?.trim();
const lessonPeriod = periodElement.textContent?.trim().match(/\d+/)?.[0];
// extract the number from the period
const normalizedLessonPeriod = lesson.period?.match(/\d+/)?.[0];
// Check if this lesson matches today's date and the current lesson's period
if (lessonDate === todayDateString && lessonPeriod === normalizedLessonPeriod) {
// Found the exact matching lesson, click it
(lessonElement as HTMLElement).click();
console.log(`Navigated to exact lesson: ${lessonDate} ${lessonPeriod}`);
return true;
}
}
}
const todayButton = Array.from(document.querySelectorAll('#toolbar .uiButton'))
.find(button => button.textContent?.trim() === 'Today') as HTMLElement;
if (todayButton) {
todayButton.click();
}
return true;
} catch (error) {
console.error("Error navigating to specific lesson:", error);
return false;
}
}
function formatDate(date: Date): string {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
const staticCommands: StaticCommandItem[] = [
{
id: "home",
icon: "\ueb4c",
category: "navigation",
text: "Home",
action: () => {
window.location.hash = "?page=/home";
loadHomePage();
},
priority: 4,
},
{
id: "messages",
icon: "\uebfd",
category: "navigation",
text: "Direct Messages",
action: () => {
window.location.hash = "?page=/messages";
},
priority: 4,
},
{
id: "timetable",
icon: "\ue9cd",
category: "navigation",
text: "Timetable",
action: () => {
window.location.hash = "?page=/timetable";
},
priority: 4,
},
{
id: "Current Lesson",
icon: "\ue9a5",
category: "navigation",
text: "Current Lesson",
priority: 4,
action: async () => {
const currentLesson = await getCurrentLesson();
if (currentLesson && currentLesson.programmeID !== 0) {
// Navigate to course page first
window.location.hash = `?page=/courses/${currentLesson.programmeID}:${currentLesson.metaID}`;
await navigateToSpecificLesson(currentLesson);
}
},
},
{
id: "assessments",
icon: "\ueac3",
category: "navigation",
text: "Assessments",
keybind: ["alt+a"],
keybindLabel: ["Alt", "A"],
action: () => {
window.location.hash = "?page=/assessments/upcoming";
},
priority: 4,
},
{
id: "dashboard",
icon: "\ueb87",
category: "navigation",
text: "Dashboard",
priority: 4,
action: () => {
window.location.hash = "?page=/dashboard";
},
},
{
id: "compose-message",
icon: "\ue924",
category: "action",
text: "Compose Message",
action: () => {
window.postMessage({
type: "triggerKeyboardEvent",
key: 'm',
code: 'KeyM',
keyCode: 77,
altKey: true
}, "*");
},
keywords: ["compose", "message", "dm", "direct message", "new message"],
priority: 3,
},
{
id: "toggle-dark-mode",
icon: "\uecfe",
category: "action",
text: "Toggle Dark Mode",
action: () => (settingsState.DarkMode = !settingsState.DarkMode),
priority: 3,
keywords: ["theme", "appearance"],
},
];
/**
* Returns the predefined list of static commands.
*/
export const getStaticCommands = (): StaticCommandItem[] => {
return [...staticCommands];
};
@@ -0,0 +1,193 @@
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
buttonSetting,
defineSettings,
Setting,
hotkeySetting,
} from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline";
import { waitForElm } from "@/seqta/utils/waitForElm";
import { runIndexing } from "../indexing/indexer";
import { initVectorSearch } from "../search/vector/vectorSearch";
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar";
import { IndexedDbManager } from "embeddia";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
// Platform-aware default hotkey
const getDefaultHotkey = () => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
return isMac ? "cmd+k" : "ctrl+k";
};
const settings = defineSettings({
searchHotkey: hotkeySetting({
default: getDefaultHotkey(),
title: "Search Hotkey",
description: "Keyboard shortcut to open the search",
}),
showRecentFirst: booleanSetting({
default: true,
title: "Show Recent First",
description: "Sort dynamic content by most recent first",
}),
transparencyEffects: booleanSetting({
default: true,
title: "Transparency Effects",
description: "Enable transparency effects for the search bar",
}),
runIndexingOnLoad: booleanSetting({
default: true,
title: "Index on Page Load",
description: "Run content indexing when SEQTA loads",
}),
resetIndex: buttonSetting({
title: "Reset Index",
description: "Reset the search index and storage",
trigger: async () => {
const confirmed = confirm("Are you sure you want to reset the search index and storage?");
if (confirmed) {
try {
// Reset the vector worker first
const workerManager = VectorWorkerManager.getInstance();
await workerManager.resetWorker();
console.log("Vector worker reset successfully");
} catch (e) {
console.warn("Failed to reset vector worker:", e);
}
// Delete both 'embeddiaDB' and 'betterseqta-index' using native IndexedDB APIs
const deleteDb = (dbName: string) => {
return new Promise<void>((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
req.onblocked = () => {
reject(new Error(`One database is open, failed to remove: ${dbName}`));
};
});
};
try {
await deleteDb("embeddiaDB");
await deleteDb("betterseqta-index");
alert("Search index and storage have been reset.");
} catch (e) {
alert("Failed to reset one or more databases: " + String(e));
}
}
},
}),
});
class GlobalSearchPlugin extends BasePlugin<typeof settings> {
@Setting(settings.searchHotkey)
searchHotkey!: string;
@Setting(settings.showRecentFirst)
showRecentFirst!: boolean;
@Setting(settings.transparencyEffects)
transparencyEffects!: boolean;
@Setting(settings.runIndexingOnLoad)
runIndexingOnLoad!: boolean;
@Setting(settings.resetIndex)
resetIndex!: () => void;
}
const settingsInstance = new GlobalSearchPlugin();
const globalSearchPlugin: Plugin<typeof settings> = {
id: "global-search",
name: "Global Search",
description: "Quick search for everything in SEQTA",
version: "1.0.0",
settings: settingsInstance.settings,
disableToggle: true,
defaultEnabled: false,
beta: true,
styles: styles,
run: async (api) => {
const appRef = { current: null };
try {
await IndexedDbManager.create("embeddiaDB", "embeddiaObjectStore", {
primaryKey: "id",
autoIncrement: false,
});
} catch (error) {
console.error("Failed to create IndexedDB:", error);
// Continue execution - the search might still work without persistence
}
initVectorSearch();
// Warm up vector worker in background to improve initial response time
setTimeout(async () => {
try {
VectorWorkerManager.getInstance();
} catch (error) {
console.warn("[Global Search] Vector worker warm-up failed:", error);
}
}, 1000);
// Add debug helpers to window for troubleshooting
// @ts-ignore
window.globalSearchDebug = {
resetWorker: async () => {
const workerManager = VectorWorkerManager.getInstance();
await workerManager.resetWorker();
console.log("Vector worker reset via debug helper");
},
checkWorkerStatus: () => {
const workerManager = VectorWorkerManager.getInstance();
console.log("Streaming active:", workerManager.isStreamingActive());
},
checkIndexedDBSize: async () => {
try {
const estimate = await navigator.storage.estimate();
console.log("Storage estimate:", estimate);
// Check embeddiaDB size
const dbRequest = indexedDB.open("embeddiaDB");
dbRequest.onsuccess = () => {
const db = dbRequest.result;
const transaction = db.transaction(["embeddiaObjectStore"], "readonly");
const store = transaction.objectStore("embeddiaObjectStore");
const countRequest = store.count();
countRequest.onsuccess = () => {
console.log("embeddiaDB item count:", countRequest.result);
};
};
} catch (e) {
console.error("Error checking storage:", e);
}
}
};
if (api.settings.runIndexingOnLoad) {
setTimeout(async () => {
await runIndexing();
}, 2000);
}
const title = document.querySelector("#title");
if (title) {
mountSearchBar(title, api, appRef);
} else {
const titleElement = await waitForElm("#title", true, 100, 60);
mountSearchBar(titleElement, api, appRef);
}
return () => {
cleanupSearchBar(appRef);
};
},
};
export default globalSearchPlugin;
@@ -0,0 +1,104 @@
import renderSvelte from "@/interface/main";
import SearchBar from "../components/SearchBar.svelte";
import { unmount } from "svelte";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
import browser from "webextension-polyfill";
export function mountSearchBar(
titleElement: Element,
api: any,
appRef: { current: any; storageChangeHandler?: any },
) {
if (titleElement.querySelector(".search-trigger")) {
return;
}
// Fallback to default hotkey if the current one is invalid
let currentHotkey = isValidHotkey(api.settings.searchHotkey) ? api.settings.searchHotkey : "ctrl+k";
let hotkeyDisplay = formatHotkeyForDisplay(currentHotkey);
const searchButton = document.createElement("div");
searchButton.className = "search-trigger";
const updateSearchButtonDisplay = () => {
searchButton.innerHTML = /* html */ `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<p>Quick search...</p>
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">${hotkeyDisplay}</span>
`;
};
updateSearchButtonDisplay();
titleElement.appendChild(searchButton);
// Listen for hotkey setting changes
const handleStorageChange = (changes: any, area: string) => {
if (area === 'local' && changes['plugin.global-search.settings']) {
const newSettings = changes['plugin.global-search.settings'].newValue as { searchHotkey?: string } | undefined;
if (newSettings?.searchHotkey && isValidHotkey(newSettings.searchHotkey)) {
currentHotkey = newSettings.searchHotkey;
hotkeyDisplay = formatHotkeyForDisplay(currentHotkey);
updateSearchButtonDisplay();
}
}
};
browser.storage.onChanged.addListener(handleStorageChange);
// Store reference to cleanup function for proper removal
appRef.storageChangeHandler = handleStorageChange;
const searchRoot = document.createElement("div");
document.body.appendChild(searchRoot);
const searchRootShadow = searchRoot.attachShadow({ mode: "open" });
searchButton.addEventListener("click", () => {
// @ts-ignore - Intentionally adding to window
window.setCommandPalleteOpen(true);
});
try {
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
transparencyEffects: api.settings.transparencyEffects ? true : false,
showRecentFirst: api.settings.showRecentFirst,
searchHotkey: currentHotkey,
});
} catch (error) {
console.error("Error rendering Svelte component:", error);
}
}
export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any }) {
if (appRef.current) {
try {
unmount(appRef.current);
appRef.current = null;
} catch (error) {
console.error("Error unmounting Svelte component:", error);
}
}
// Remove search trigger button
const searchTrigger = document.querySelector(".search-trigger");
if (searchTrigger) {
searchTrigger.remove();
}
// Remove search root
const searchRoot = document.querySelector("div[data-search-root]");
if (searchRoot) {
searchRoot.remove();
}
// Clean up vector worker
VectorWorkerManager.getInstance().terminate();
if (appRef.storageChangeHandler) {
browser.storage.onChanged.removeListener(appRef.storageChangeHandler);
appRef.storageChangeHandler = null;
}
}
@@ -0,0 +1,71 @@
.search-trigger {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
margin-left: 10px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
margin-right: auto !important;
padding: 3px 12px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
user-select: none;
svg {
opacity: 0.8;
}
p {
font-size: 14px;
margin-left: 8px;
margin-right: 48px;
height: 100%;
margin-bottom: 0;
line-height: 32px;
font-weight: 400;
}
}
/* Light mode styles */
.search-trigger {
background-color: rgba(248, 250, 252, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
color: #555 !important;
p {
color: #555 !important;
}
svg {
color: #555;
}
}
.dark .search-trigger {
background-color: rgba(0, 0, 0, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
color: #aaa !important;
p {
color: #aaa !important;
}
svg {
color: #aaa;
}
}
.highlight {
background-color: rgba(255, 213, 0, 0.3);
font-weight: 500;
border-radius: 2px;
padding: 0 1px;
margin: 0 -1px;
}
.dark .highlight {
background-color: rgba(255, 230, 100, 0.4);
}
@@ -0,0 +1,28 @@
import type { StaticCommandItem } from "./commands";
import type { IndexItem } from "../indexing/types";
export interface MatchIndices {
readonly 0: number;
readonly 1: number;
}
export interface FuseResultMatch {
key?: string;
value?: string;
indices: readonly MatchIndices[];
}
export interface CombinedResult {
id: string;
type: "command" | "dynamic";
score: number;
item: StaticCommandItem | IndexItem;
matches?: readonly FuseResultMatch[];
}
export interface FuseResult<T> {
item: T;
refIndex: number;
score?: number;
matches?: readonly FuseResultMatch[];
}
@@ -0,0 +1,87 @@
import { waitForElm } from "@/seqta/utils/waitForElm";
import type { IndexItem } from "./types";
import ReactFiber from "@/seqta/utils/ReactFiber";
import { delay } from "@/seqta/utils/delay";
interface MessageMetadata {
messageId: number;
author: string;
senderId: number;
senderType: string;
timestamp: string;
hasAttachments: boolean;
attachmentCount: number;
read: boolean;
}
interface AssessmentMetadata {
assessmentId?: number;
messageId?: number;
subject?: string;
term?: string;
programmeId?: number;
metaclassId?: number;
timestamp: string;
isMessageBased?: boolean;
author?: string;
}
type ActionHandler<T = any> = (item: IndexItem & { metadata: T }) => void;
export const actionMap: Record<string, ActionHandler<any>> = {
message: (async (item: IndexItem & { metadata: MessageMetadata }) => {
window.location.hash = `#?page=/messages`;
await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20);
// Select the specific direct message
ReactFiber.find('[class*="Viewer__Viewer___"] > div').setState({
selected: new Set([item.metadata.messageId]),
});
// send a network request to mark as read
fetch('/seqta/student/save/message', {
method: "POST",
credentials: "include",
body: JSON.stringify({
items: [item.metadata.messageId],
mode: 'x-read',
read: true,
}),
});
await delay(10);
const button = document.querySelector('[class*="MessageList__selected___"]');
if (button) {
(button as HTMLElement).click();
}
}) as ActionHandler<any>,
assessment: (async (item: IndexItem & { metadata: AssessmentMetadata }) => {
if (item.metadata.isMessageBased) {
window.location.hash = `#?page=/messages`;
await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20);
// Select the specific direct message
ReactFiber.find('[class*="Viewer__Viewer___"] > div').setState({
selected: new Set([item.metadata.messageId]),
});
} else {
window.location.hash = `#?page=/assessments&id=${item.metadata.assessmentId}`;
}
}) as ActionHandler<any>,
subjectassessment: ((item: IndexItem) => {
window.location.href = `/#?page=/assessments/${item.metadata.programme}:${item.metadata.subjectId}`;
}) as ActionHandler<any>,
subjectcourse: ((item: IndexItem) => {
window.location.href = `/#?page=/courses/${item.metadata.programme}:${item.metadata.subjectId}`;
}) as ActionHandler<any>,
forum: ((item: IndexItem) => {
window.location.href = `/#?page=/forums/${item.metadata.forumId}`;
}) as ActionHandler<any>,
};
@@ -0,0 +1,237 @@
const DB_NAME = "betterseqta-index";
const META_STORE = "meta";
const VERSION_KEY = "betterseqta-index-version";
let dbPromise: Promise<IDBDatabase> | null = null;
let cachedDb: IDBDatabase | null = null;
function getCurrentVersion(): number {
const storedVersion = localStorage.getItem(VERSION_KEY);
return storedVersion ? parseInt(storedVersion, 10) : 1;
}
function updateVersion(version: number) {
localStorage.setItem(VERSION_KEY, version.toString());
}
function openDB(): Promise<IDBDatabase> {
if (cachedDb && cachedDb.version >= getCurrentVersion()) {
return Promise.resolve(cachedDb);
}
if (dbPromise) return dbPromise;
const currentVersion = getCurrentVersion();
dbPromise = new Promise((resolve, reject) => {
let request: IDBOpenDBRequest;
try {
request = indexedDB.open(DB_NAME, currentVersion);
} catch (e) {
console.warn("Database version conflict, recreating database...");
if (cachedDb) {
cachedDb.close();
cachedDb = null;
}
indexedDB.deleteDatabase(DB_NAME);
localStorage.removeItem(VERSION_KEY);
request = indexedDB.open(DB_NAME, 1);
updateVersion(1);
}
request.onupgradeneeded = (event) => {
const db = request.result;
const existingStores = Array.from(db.objectStoreNames);
if (!existingStores.includes(META_STORE)) {
db.createObjectStore(META_STORE);
}
updateVersion(event.newVersion || 1);
};
request.onsuccess = () => {
if (cachedDb && cachedDb !== request.result) {
cachedDb.close();
}
cachedDb = request.result;
cachedDb.onclose = () => {
cachedDb = null;
dbPromise = null;
};
resolve(request.result);
};
request.onerror = () => {
console.error("Error opening database:", request.error);
if (cachedDb) {
cachedDb.close();
cachedDb = null;
}
indexedDB.deleteDatabase(DB_NAME);
localStorage.removeItem(VERSION_KEY);
dbPromise = null;
reject(request.error);
};
});
return dbPromise;
}
async function getStore(store: string, mode: IDBTransactionMode = "readonly") {
const db = await openDB();
if (!db.objectStoreNames.contains(store)) {
await upgradeDB(store);
const upgradedDb = await openDB();
const tx = upgradedDb.transaction(store, mode);
return tx.objectStore(store);
}
const tx = db.transaction(store, mode);
return tx.objectStore(store);
}
function upgradeDB(newStore: string): Promise<void> {
return new Promise((resolve, reject) => {
const currentVersion = getCurrentVersion();
const newVersion = currentVersion + 1;
if (cachedDb) {
cachedDb.close();
cachedDb = null;
}
dbPromise = null;
const request = indexedDB.open(DB_NAME, newVersion);
request.onupgradeneeded = (event) => {
const db = request.result;
if (!db.objectStoreNames.contains(newStore)) {
db.createObjectStore(newStore);
}
updateVersion(event.newVersion || newVersion);
};
request.onsuccess = () => {
cachedDb = request.result;
cachedDb.onclose = () => {
cachedDb = null;
dbPromise = null;
};
dbPromise = Promise.resolve(request.result);
resolve();
};
request.onerror = () => {
console.error("Error upgrading database:", request.error);
reject(request.error);
};
});
}
export async function getAll(store: string): Promise<any[]> {
try {
const s = await getStore(store);
return new Promise((resolve, reject) => {
const req = s.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in getAll for store ${store}:`, error);
return [];
}
}
export async function get(store: string, key: string): Promise<any> {
try {
const s = await getStore(store);
return new Promise((resolve, reject) => {
const req = s.get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in get for store ${store}, key ${key}:`, error);
return null;
}
}
export async function put(
store: string,
value: any,
key?: string,
): Promise<void> {
try {
const s = await getStore(store, "readwrite");
return new Promise((resolve, reject) => {
const req = key ? s.put(value, key) : s.put(value);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in put for store ${store}:`, error);
throw error;
}
}
export async function remove(store: string, key: string): Promise<void> {
try {
const s = await getStore(store, "readwrite");
return new Promise((resolve, reject) => {
const req = s.delete(key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in remove for store ${store}, key ${key}:`, error);
throw error;
}
}
export async function clear(store: string): Promise<void> {
try {
const s = await getStore(store, "readwrite");
return new Promise((resolve, reject) => {
const req = s.clear();
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch (error) {
console.error(`Error in clear for store ${store}:`, error);
throw error;
}
}
export async function resetDatabase(): Promise<void> {
if (cachedDb) {
cachedDb.close();
cachedDb = null;
}
if (dbPromise) {
try {
const db = await dbPromise;
db.close();
} catch (e) {}
dbPromise = null;
}
return new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(DB_NAME);
req.onsuccess = () => {
localStorage.removeItem(VERSION_KEY);
resolve();
};
req.onerror = () => reject(req.error);
});
}
@@ -0,0 +1,423 @@
import { clear, getAll, get, put, remove } from "./db";
import { jobs } from "./jobs";
import { renderComponentMap } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types";
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
import { loadDynamicItems } from "../utils/dynamicItems";
import { getVectorizedItemIds } from "./utils";
const META_STORE = "meta";
const LOCK_KEY = "bsq-indexer-lock";
const HEARTBEAT_INTERVAL = 10000;
const LOCK_TIMEOUT = 20000;
const LOCK_ACQUIRE_TIMEOUT = 5000;
/* ─────────── Progressmeta helpers ─────────── */
async function loadProgress<T = any>(jobId: string): Promise<T | undefined> {
const rec = await get(META_STORE, `progress:${jobId}`);
return rec?.progress as T | undefined;
}
async function saveProgress<T = any>(jobId: string, progress: T): Promise<void> {
await put(META_STORE, { progress }, `progress:${jobId}`);
}
/* ───────────────────────────────────────────── */
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let isIndexingActive = false;
function shouldRun(job: Job, lastRun?: number): boolean {
const now = Date.now();
if (job.frequency === "pageLoad") return true;
if (!lastRun) return true;
if (job.frequency.type === "interval") {
return now - lastRun >= job.frequency.ms;
}
if (job.frequency.type === "expiry") {
return now - lastRun >= job.frequency.afterMs;
}
return false;
}
function getLastRunMeta(jobId: string): Promise<number | undefined> {
return getAll(META_STORE).then((metaItems) => {
const match = metaItems.find((m: any) => m.jobId === jobId);
return match?.lastRun;
});
}
async function updateLastRunMeta(jobId: string): Promise<void> {
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
}
async function acquireLock(): Promise<boolean> {
if (isIndexingActive) {
console.debug("[Indexer] Already indexing in this tab");
return false;
}
const lockId = `${Date.now()}-${Math.random()}`;
const startTime = Date.now();
while (Date.now() - startTime < LOCK_ACQUIRE_TIMEOUT) {
const currentLock = localStorage.getItem(LOCK_KEY);
const currentTime = Date.now();
if (!currentLock) {
localStorage.setItem(LOCK_KEY, lockId);
await new Promise(resolve => setTimeout(resolve, 50));
if (localStorage.getItem(LOCK_KEY) === lockId) {
isIndexingActive = true;
return true;
}
} else {
try {
const [timestamp] = currentLock.split('-');
const lockTime = parseInt(timestamp, 10);
if (isNaN(lockTime) || currentTime - lockTime > LOCK_TIMEOUT) {
localStorage.setItem(LOCK_KEY, lockId);
await new Promise(resolve => setTimeout(resolve, 50));
if (localStorage.getItem(LOCK_KEY) === lockId) {
isIndexingActive = true;
return true;
}
}
} catch (e) {
console.warn("[Indexer] Error parsing lock:", e);
}
}
await new Promise(resolve => setTimeout(resolve, 100));
}
return false;
}
function startHeartbeat() {
const lockId = localStorage.getItem(LOCK_KEY);
if (!lockId) return;
heartbeatTimer = setInterval(() => {
if (localStorage.getItem(LOCK_KEY)?.endsWith(lockId.split('-')[1])) {
const newLockId = `${Date.now()}-${lockId.split('-')[1]}`;
localStorage.setItem(LOCK_KEY, newLockId);
}
}, HEARTBEAT_INTERVAL);
}
function stopHeartbeat() {
if (heartbeatTimer) clearInterval(heartbeatTimer);
localStorage.removeItem(LOCK_KEY);
isIndexingActive = false;
}
function dispatchProgress(
completed: number,
total: number,
indexing: boolean,
status?: string,
detail?: string,
) {
const event = new CustomEvent("indexing-progress", {
detail: { completed, total, indexing, status, detail },
});
window.dispatchEvent(event);
}
export async function loadAllStoredItems(): Promise<IndexItem[]> {
const all: IndexItem[] = [];
const jobIds = Object.keys(jobs);
for (const jobId of jobIds) {
try {
const items = (await getAll(jobId)) as IndexItem[];
const job = jobs[jobId];
for (const item of items) {
if (
item &&
item.id &&
item.text &&
item.category &&
item.actionId &&
job.renderComponentId // job might not be defined if store exists but job was removed
) {
all.push(item);
} else {
console.warn(`Skipping invalid item from job store ${jobId}:`, item);
}
}
} catch (error) {
console.error(`Error loading items for job store ${jobId}:`, error);
}
}
console.debug(
`[Indexer] Loaded ${all.length} items from all primary stores.`,
);
return all;
}
export async function runIndexing(): Promise<void> {
if (!(await acquireLock())) {
console.debug(
"%c[Indexer] Could not acquire lock - another tab is indexing or this tab is already indexing",
"color: gray",
);
return;
}
startHeartbeat();
console.debug("%c[Indexer] Starting indexing...", "color: green");
const jobIds = Object.keys(jobs);
let completedJobs = 0;
const totalSteps = jobIds.length + 1;
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs");
let hasStreamingJobs = false;
for (const jobId of jobIds) {
dispatchProgress(
completedJobs,
totalSteps,
true,
`Running job: ${jobs[jobId].label}`,
);
const job = jobs[jobId];
const lastRun = await getLastRunMeta(jobId);
if (!shouldRun(job, lastRun)) {
console.debug(
`%c[Indexer] Skipping job "${jobId}" (not due)`,
"color: gray",
);
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
true,
`Skipped job: ${job.label}`,
);
continue;
}
const getStoredItems = async (storeId?: string) =>
await getAll(storeId ?? jobId);
const setStoredItems = async (items: IndexItem[], storeId?: string) => {
const targetStore = storeId ?? jobId;
await clear(targetStore);
const validItems = items.filter((i) => i && i.id);
if (validItems.length !== items.length) {
console.warn(
`[Indexer Job ${jobId} -> Store ${targetStore}] Filtered out ${items.length - validItems.length} invalid items before storing.`,
);
}
await Promise.all(validItems.map((i) => put(targetStore, i, i.id)));
};
const addItem = async (item: IndexItem, storeId?: string) => {
const targetStore = storeId ?? jobId;
if (item && item.id) {
await put(targetStore, item, item.id);
} else {
console.warn(
`[Indexer Job ${jobId} -> Store ${targetStore}] Attempted to add invalid item:`,
item,
);
}
};
const removeItem = async (id: string, storeId?: string) => {
const targetStore = storeId ?? jobId;
await remove(targetStore, id);
};
const ctx: JobContext = {
getStoredItems,
setStoredItems,
addItem,
removeItem,
getProgress: () => loadProgress(jobId),
setProgress: (p) => saveProgress(jobId, p),
};
console.debug(`%c[Indexer] Running job "${jobId}"...`, "color: #4ea1ff");
try {
const newItemsRaw = await job.run(ctx);
const stored = await getStoredItems();
let merged = mergeItems(stored, newItemsRaw);
if (job.purge) merged = job.purge(merged);
await setStoredItems(merged);
await updateLastRunMeta(jobId);
if (jobId === 'messages' || jobId === 'notifications') {
hasStreamingJobs = true;
}
console.debug(
`%c[Indexer] ${job.label}: ${newItemsRaw.length} new items reported by run, ${merged.length} total items now in '${jobId}' store.`,
"color: #00c46f",
);
} catch (err) {
console.debug(`%c[Indexer] Job ${job.label} failed:`, "color: red");
console.error(err);
}
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
true,
`Finished job: ${job.label}`,
);
}
let allItemsInPrimaryStores = await loadAllStoredItems();
if (allItemsInPrimaryStores.length > 0) {
console.debug(
`%c[Indexer] Checking ${allItemsInPrimaryStores.length} items for vectorization...`,
"color: #4ea1ff",
);
// Pre-filter items to avoid initializing worker if nothing new
const vectorizedItemIds = await getVectorizedItemIds();
const newItemsToVectorize = allItemsInPrimaryStores.filter(item => !vectorizedItemIds.has(item.id));
if (newItemsToVectorize.length > 0) {
console.debug(
`%c[Indexer] Sending ${newItemsToVectorize.length} new items to worker for vectorization (${allItemsInPrimaryStores.length - newItemsToVectorize.length} already vectorized)`,
"color: #4ea1ff",
);
dispatchProgress(completedJobs, totalSteps, true, "Starting vectorization of new items");
try {
const workerManager = VectorWorkerManager.getInstance();
await workerManager.processItems(newItemsToVectorize, (progress) => {
let detailMessage = progress.message || "";
if (
progress.status === "processing" &&
progress.total &&
progress.processed !== undefined
) {
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
} else if (progress.status === "complete") {
detailMessage = "Vectorization complete";
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Indexing finished",
detailMessage
);
} else if (progress.status === "error") {
detailMessage = `Vectorization error: ${progress.message}`;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization failed",
detailMessage,
);
} else if (progress.status === "started") {
detailMessage = `Vectorization started for ${progress.total} items`;
} else if (progress.status === "cancelled") {
detailMessage = `Vectorization cancelled: ${progress.message}`;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization cancelled",
detailMessage,
);
}
if (progress.status !== "complete" && progress.status !== "error" && progress.status !== "cancelled") {
dispatchProgress(
completedJobs,
totalSteps,
true,
"Vectorization in progress",
detailMessage,
);
}
});
console.debug(
"%c[Indexer] Vectorization task for stored items sent to worker.",
"color: green",
);
} catch (error) {
console.error(
`%c[Indexer] ❌ Failed to send items to vector worker:`,
"color: red",
error,
);
dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization failed",
String(error),
);
}
} else {
console.debug(
`%c[Indexer] All ${allItemsInPrimaryStores.length} items are already vectorized, skipping worker initialization.`,
"color: gray",
);
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Indexing finished (all items already vectorized)",
);
}
} else {
console.debug(
"%c[Indexer] No items found in primary stores to send for vectorization.",
"color: gray",
);
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Indexing finished (no items for vectorization)",
);
}
stopHeartbeat();
allItemsInPrimaryStores = await loadAllStoredItems();
allItemsInPrimaryStores.forEach(item => {
const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId];
if (jobDef) {
const renderComponent = renderComponentMap[jobDef.renderComponentId];
if (renderComponent) {
item.renderComponent = renderComponent;
}
} else if (renderComponentMap[item.renderComponentId]) {
item.renderComponent = renderComponentMap[item.renderComponentId];
}
});
loadDynamicItems(allItemsInPrimaryStores);
window.dispatchEvent(new Event("dynamic-items-updated"));
}
function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] {
const map = new Map<string, IndexItem>();
for (const item of existing) {
if (item && item.id) map.set(item.id, item);
}
for (const item of incoming) {
if (item && item.id) map.set(item.id, item);
}
return Array.from(map.values());
}
@@ -0,0 +1,12 @@
import type { Job } from "./types";
import { messagesJob } from "./jobs/messages";
import { notificationsJob } from "./jobs/notifications";
import { forumsJob } from "./jobs/forums";
import { subjectsJob } from "./jobs/subjects";
export const jobs: Record<string, Job> = {
messages: messagesJob,
notifications: notificationsJob,
forums: forumsJob,
subjects: subjectsJob,
};
@@ -0,0 +1,69 @@
import type { Job, IndexItem } from "../types";
const fetchForums = async () => {
const res = await fetch(`${location.origin}/seqta/student/load/forums`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ mode: "list" }),
});
return res.json() as Promise<{
payload: { forums: any[] };
status: string;
}>;
};
export const forumsJob: Job = {
id: "forums",
label: "Forums",
renderComponentId: "forum",
frequency: { type: "expiry", afterMs: 30 * 24 * 60 * 60 * 1000 }, // 30 days
run: async (ctx) => {
const existingIds = new Set(
(await ctx.getStoredItems("forums")).map((i) => i.id),
);
let list;
try {
list = await fetchForums();
} catch (e) {
console.error("[Forums job] list fetch failed:", e);
return [];
}
if (list.status !== "200") return [];
const items: IndexItem[] = [];
for (const forum of list.payload.forums) {
const id = forum.id.toString();
if (existingIds.has(id)) continue;
items.push({
id,
text: forum.title,
category: "forums",
content: `${forum.title}`,
dateAdded: Date.now(),
metadata: {
forumId: forum.id,
owner: forum.owner,
title: forum.title,
closed: forum.closed,
},
actionId: "forum",
renderComponentId: "forum",
});
}
return items;
},
/** Keep only forums from the lastyear. */
purge: (items) => {
const oneYearAgo = Date.now() - 365 * 24 * 60 * 60 * 1000;
return items.filter((i) => i.dateAdded >= oneYearAgo);
},
};

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