Compare commits

...

138 Commits

Author SHA1 Message Date
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
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
184 changed files with 34864 additions and 20996 deletions
+94 -111
View File
@@ -2,87 +2,83 @@
module.exports = {
forbidden: [
{
name: 'no-circular',
severity: 'warn',
name: "no-circular",
severity: "warn",
comment:
'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) ',
"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) ",
from: {},
to: {
circular: true
}
circular: true,
},
},
{
name: 'no-orphans',
name: "no-orphans",
comment:
"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), " +
"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 " +
"files (.d.ts), tsconfig.json and some of the babel and webpack configs.",
severity: 'warn',
severity: "warn",
from: {
orphan: true,
pathNot: [
'(^|/)[.][^/]+[.](?:js|cjs|mjs|ts|cts|mts|json)$', // dot files
'[.]d[.]ts$', // TypeScript declaration files
'(^|/)tsconfig[.]json$', // TypeScript config
'(^|/)(?:babel|webpack)[.]config[.](?:js|cjs|mjs|ts|cts|mts|json)$' // other configs
]
"(^|/)[.][^/]+[.](?:js|cjs|mjs|ts|cts|mts|json)$", // dot files
"[.]d[.]ts$", // TypeScript declaration files
"(^|/)tsconfig[.]json$", // TypeScript config
"(^|/)(?:babel|webpack)[.]config[.](?:js|cjs|mjs|ts|cts|mts|json)$", // other configs
],
},
to: {},
},
{
name: 'no-deprecated-core',
name: "no-deprecated-core",
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.",
severity: 'warn',
severity: "warn",
from: {},
to: {
dependencyTypes: [
'core'
],
dependencyTypes: ["core"],
path: [
'^v8/tools/codemap$',
'^v8/tools/consarray$',
'^v8/tools/csvparser$',
'^v8/tools/logreader$',
'^v8/tools/profile_view$',
'^v8/tools/profile$',
'^v8/tools/SourceMap$',
'^v8/tools/splaytree$',
'^v8/tools/tickprocessor-driver$',
'^v8/tools/tickprocessor$',
'^node-inspect/lib/_inspect$',
'^node-inspect/lib/internal/inspect_client$',
'^node-inspect/lib/internal/inspect_repl$',
'^async_hooks$',
'^punycode$',
'^domain$',
'^constants$',
'^sys$',
'^_linklist$',
'^_stream_wrap$'
"^v8/tools/codemap$",
"^v8/tools/consarray$",
"^v8/tools/csvparser$",
"^v8/tools/logreader$",
"^v8/tools/profile_view$",
"^v8/tools/profile$",
"^v8/tools/SourceMap$",
"^v8/tools/splaytree$",
"^v8/tools/tickprocessor-driver$",
"^v8/tools/tickprocessor$",
"^node-inspect/lib/_inspect$",
"^node-inspect/lib/internal/inspect_client$",
"^node-inspect/lib/internal/inspect_repl$",
"^async_hooks$",
"^punycode$",
"^domain$",
"^constants$",
"^sys$",
"^_linklist$",
"^_stream_wrap$",
],
}
},
},
{
name: 'not-to-deprecated',
name: "not-to-deprecated",
comment:
'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.',
severity: 'warn',
"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.",
severity: "warn",
from: {},
to: {
dependencyTypes: [
'deprecated'
]
}
dependencyTypes: ["deprecated"],
},
},
{
name: 'no-non-package-json',
severity: 'error',
name: "no-non-package-json",
severity: "error",
comment:
"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 " +
@@ -90,84 +86,75 @@ module.exports = {
"in your package.json.",
from: {},
to: {
dependencyTypes: [
'npm-no-pkg',
'npm-unknown'
]
}
dependencyTypes: ["npm-no-pkg", "npm-unknown"],
},
},
{
name: 'not-to-unresolvable',
name: "not-to-unresolvable",
comment:
"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.',
severity: 'error',
"module: add it to your package.json. In all other cases you likely already know what to do.",
severity: "error",
from: {},
to: {
couldNotResolve: true
}
couldNotResolve: true,
},
},
{
name: 'no-duplicate-dep-types',
name: "no-duplicate-dep-types",
comment:
"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 " +
"maintenance problems later on.",
severity: 'warn',
severity: "warn",
from: {},
to: {
moreThanOneDependencyType: true,
// 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
// types for this rule
dependencyTypesNot: ["type-only"]
}
dependencyTypesNot: ["type-only"],
},
},
/* rules you might want to tweak for your specific situation: */
{
name: 'not-to-spec',
name: "not-to-spec",
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 " +
'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.',
severity: 'error',
"responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.",
severity: "error",
from: {},
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',
severity: 'error',
name: "not-to-dev-dep",
severity: "error",
comment:
"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'" +
'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',
"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: {
path: '^(src)',
pathNot: '[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$'
path: "^(src)",
pathNot: "[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$",
},
to: {
dependencyTypes: [
'npm-dev',
],
dependencyTypes: ["npm-dev"],
// type only dependencies are not a problem as they don't end up in the
// production code or are ignored by the runtime.
dependencyTypesNot: [
'type-only'
],
pathNot: [
'node_modules/@types/'
]
}
dependencyTypesNot: ["type-only"],
pathNot: ["node_modules/@types/"],
},
},
{
name: 'optional-deps-used',
severity: 'info',
name: "optional-deps-used",
severity: "info",
comment:
"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. " +
@@ -175,33 +162,28 @@ module.exports = {
"dependency-cruiser configuration.",
from: {},
to: {
dependencyTypes: [
'npm-optional'
]
}
dependencyTypes: ["npm-optional"],
},
},
{
name: 'peer-deps-used',
name: "peer-deps-used",
comment:
"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 " +
"other cases - maybe not so much. If the use of a peer dependency is intentional " +
"add an exception to your dependency-cruiser configuration.",
severity: 'warn',
severity: "warn",
from: {},
to: {
dependencyTypes: [
'npm-peer'
]
}
}
dependencyTypes: ["npm-peer"],
},
},
],
options: {
/* Which modules not to follow further when encountered */
doNotFollow: {
/* path: an array of regular expressions in strings to match against */
path: ['node_modules']
path: ["node_modules"],
},
/* Which modules to exclude */
@@ -274,7 +256,7 @@ module.exports = {
defaults to './tsconfig.json'.
*/
tsConfig: {
fileName: 'tsconfig.json'
fileName: "tsconfig.json",
},
/* Webpack configuration to use to get resolve options from.
@@ -364,8 +346,8 @@ module.exports = {
"bun:wrap",
"detect-libc",
"undici",
"ws"
]
"ws",
],
},
reporterOptions: {
@@ -375,7 +357,7 @@ module.exports = {
collapses everything in node_modules to one folder deep so you see
the external modules, but their innards.
*/
collapsePattern: 'node_modules/(?:@[^/]+/[^/]+|[^/]+)',
collapsePattern: "node_modules/(?:@[^/]+/[^/]+|[^/]+)",
/* Options to tweak the appearance of your graph.See
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
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
theme for 'archi' dependency-cruiser will use the one specified in the
@@ -405,10 +388,10 @@ module.exports = {
*/
// theme: { },
},
"text": {
"highlightFocused": true
text: {
highlightFocused: true,
},
},
},
}
}
};
// generated: dependency-cruiser@16.10.0 on 2025-02-16T22:32:01.621Z
+5 -2
View File
@@ -12,12 +12,15 @@
},
"rules": {
// allow importing ts extensions
"sort-imports": ["error", {
"sort-imports": [
"error",
{
"ignoreCase": true,
"ignoreDeclarationSort": true,
"ignoreMemberSort": false,
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
}],
}
],
"import/extensions": [
"error",
"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.
+56
View File
@@ -0,0 +1,56 @@
name: Bug report
description: Report an issue with the modpack in its unmodified state. For other issues, use Discord.
labels: bug
title: "[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,51 @@
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
+1 -1
View File
@@ -1,5 +1,5 @@
{
"tabWidth": 2,
"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
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
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
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
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
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
## Enforcement Responsibilities
+6 -7
View File
@@ -1,4 +1,3 @@
#
<a href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel">
@@ -65,8 +64,6 @@ Don't worry- if you get stuck feel free to ask around in the [discord](https://d
git clone https://github.com/BetterSEQTA/BetterSEQTA-Plus
```
1. Install dependencies
You may install the dependencies like below:
@@ -80,15 +77,15 @@ But it is recommended to do it like this:
```
npm install --legacy-peer-deps # Only NPM supported
```
### Running Development
2. Run the dev script (it updates as you save files)
```
npm run dev # or use your perferred package manager
```
### Building for production
2. Run the build script
@@ -102,6 +99,7 @@ npm run build # or use your perferred package manager
```
npm run zip # This REQUIRES 7-Zip to be installed in order to work. You can also use your perferred package manager
```
3. Load the extension into chrome
- Go to `chrome://extensions`
@@ -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 `src` folder contains source files that are compiled to the build directory.
-
- The `src/plugins` folder contains vital loaders required for BetterSEQTA+ functionality.
- The `src/interface` folder contains source React & Svelte files that are required for the Settings page.
@@ -130,9 +128,10 @@ The folder structure is as follows:
</a>
Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plus/blob/main/CONTRIBUTING.md)
## 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
+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.
| Version | Supported |
| ------- | ------------------ |
| ------- | --------- |
| 3.4.3 | ✅ |
| < 3.4.3 | :x: |
`*` May not work on other devices.
## 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
+2
View File
@@ -7,11 +7,13 @@ Welcome to the BetterSEQTA+ documentation! This documentation will help you unde
## Table of Contents
### Getting Started
- [Project Overview](./README.md) - This file
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+
- [Contributing Guide](../CONTRIBUTING.md) - How to contribute to BetterSEQTA+
### Plugin System
- [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
+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.
Key points:
- Be respectful and inclusive
- Focus on what is best for the community
- Show empathy towards other community members
@@ -105,6 +106,7 @@ git checkout -b feature/my-new-feature
2. **Write Clear Commit Messages**
Follow the conventional commits format:
```
feat: add new feature
fix: resolve bug with timetable
@@ -118,6 +120,7 @@ git checkout -b feature/my-new-feature
4. **Run Tests**
Make sure all tests pass before submitting your PR:
```bash
npm test
```
@@ -157,6 +160,7 @@ We follow TypeScript best practices and have a consistent code style:
5. **Use Linters**
We use ESLint and Prettier. Run them before submitting your PR:
```bash
npm run lint
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**
Fill in all sections of the bug report template:
- Description
- Steps to reproduce
- Expected behavior
@@ -195,6 +200,7 @@ We welcome feature suggestions! To suggest a new feature:
2. **Use the Feature Request Template**
Fill in all sections of the feature request template:
- Description
- Use case
- Potential implementation
+2
View File
@@ -132,6 +132,7 @@ bun install
#### Extension not appearing in SEQTA
Make sure:
- You're visiting a SEQTA Learn page
- The extension is enabled
- You've refreshed the page after installing the extension
@@ -139,6 +140,7 @@ Make sure:
#### Development build not updating
Try:
1. Stopping the development server
2. Clearing your browser cache
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?
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
- Adds new buttons or features
- 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:
```typescript
import type { Plugin } from '@/plugins/core/types';
import type { Plugin } from "@/plugins/core/types";
const myFirstPlugin: Plugin = {
// Every plugin needs these basic details
id: 'my-first-plugin',
name: 'My First Plugin',
description: 'Adds a friendly message to SEQTA',
version: '1.0.0',
id: "my-first-plugin",
name: "My First Plugin",
description: "Adds a friendly message to SEQTA",
version: "1.0.0",
// This tells BetterSEQTA+ that users can turn our plugin on/off
disableToggle: true,
// Optional: Mark your plugin as beta to show a "Beta" tag in settings
beta: true,
// This is where the magic happens!
run: async (api) => {
// Wait for the homepage to load
api.seqta.onMount('.home-page', (homePage) => {
api.seqta.onMount(".home-page", (homePage) => {
// Create our message
const message = document.createElement('div');
message.textContent = 'Hello from my first plugin! 🎉';
message.style.padding = '20px';
message.style.backgroundColor = '#e9f5ff';
message.style.borderRadius = '8px';
message.style.margin = '20px';
const message = document.createElement("div");
message.textContent = "Hello from my first plugin! 🎉";
message.style.padding = "20px";
message.style.backgroundColor = "#e9f5ff";
message.style.borderRadius = "8px";
message.style.margin = "20px";
// Add it to the page
homePage.prepend(message);
@@ -46,10 +50,10 @@ const myFirstPlugin: Plugin = {
// Return a cleanup function that removes our message when the plugin is disabled
return () => {
const message = document.querySelector('.home-page > div');
const message = document.querySelector(".home-page > div");
message?.remove();
};
}
},
};
export default myFirstPlugin;
@@ -64,10 +68,11 @@ Let's break down what's happening here:
- `description`: Explain what your plugin does
- `version`: Your plugin's version number
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
5. We use `api.seqta.onMount` to wait for the homepage to load
6. We create and style a message element
7. We return a cleanup function that removes our changes when the plugin is disabled
4. We set `beta: true` to mark the plugin as beta
5. The `run` function is where we put our plugin's code
6. We use `api.seqta.onMount` to wait for the homepage to load
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
@@ -79,13 +84,13 @@ This helps you interact with SEQTA's pages:
```typescript
// 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
});
// Know when the user changes pages
api.seqta.onPageChange((page) => {
console.log('User went to:', page);
console.log("User went to:", page);
});
// Get the current page
@@ -97,8 +102,12 @@ const currentPage = api.seqta.getCurrentPage();
Want to let users customize your plugin? Use settings!
```typescript
import { BasePlugin } from '@/plugins/core/settings';
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
// Define your settings
const settings = defineSettings({
@@ -106,7 +115,7 @@ const settings = defineSettings({
default: true,
title: "Show Welcome Message",
description: "Show a friendly message on the homepage",
})
}),
});
// Create a class for your plugin
@@ -129,14 +138,14 @@ const myPlugin: Plugin<typeof settings> = {
}
// Listen for setting changes
api.settings.onChange('showMessage', (newValue) => {
api.settings.onChange("showMessage", (newValue) => {
if (newValue) {
// Show the message
} else {
// Hide the message
}
});
}
},
};
```
@@ -146,14 +155,14 @@ Need to save some data? The storage API has got you covered:
```typescript
// Save some data
await api.storage.set('lastVisit', new Date().toISOString());
await api.storage.set("lastVisit", new Date().toISOString());
// Get it back later
const lastVisit = await api.storage.get('lastVisit');
const lastVisit = await api.storage.get("lastVisit");
// Listen for changes
api.storage.onChange('lastVisit', (newValue) => {
console.log('Last visit updated:', newValue);
api.storage.onChange("lastVisit", (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
// Listen for an event
api.events.on('myCustomEvent', (data) => {
console.log('Got event:', data);
api.events.on("myCustomEvent", (data) => {
console.log("Got event:", data);
});
// Send an event
api.events.emit('myCustomEvent', { some: 'data' });
api.events.emit("myCustomEvent", { some: "data" });
```
## Adding Styles
@@ -199,7 +208,7 @@ const myPlugin: Plugin = {
run: async (api) => {
// Your plugin code here
}
},
};
```
@@ -208,28 +217,31 @@ const myPlugin: Plugin = {
Here are some tips to make your plugin awesome:
1. **Always Clean Up**: When your plugin is disabled, clean up any changes you made:
```typescript
run: async (api) => {
// Add stuff to the page
const element = document.createElement('div');
const element = document.createElement("div");
document.body.appendChild(element);
// Return a cleanup function
return () => {
element.remove();
};
}
};
```
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:
- When SEQTA is loading
- When the user switches pages
- When the plugin is enabled/disabled
- When settings are changed
4. **Keep It Fast**: Don't slow down SEQTA:
- Use `onMount` instead of intervals or timeouts
- Clean up event listeners when they're not needed
- 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
- Use `disableToggle: true` so users can turn it off if needed
- 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
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
- [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
@@ -250,6 +289,7 @@ Want to see more examples? Check out our built-in plugins:
## Need Help?
Got stuck? No worries! Here's where you can get help:
- Join our [Discord server](https://discord.gg/YzmbnCDkat)
- 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)
+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:
```typescript
import type { Plugin } from '@/plugins/core/types';
import { BasePlugin } from '@/plugins/core/settings';
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
// First, define your settings
const settings = defineSettings({
@@ -17,7 +21,7 @@ const settings = defineSettings({
default: true,
title: "Enable Feature",
description: "Turn this feature on or off",
})
}),
});
// Create a class to handle your settings
@@ -31,59 +35,92 @@ const settingsInstance = new MyPluginClass();
// Create your plugin
const myPlugin: Plugin<typeof settings> = {
id: 'my-plugin',
name: 'My Plugin',
description: 'A cool plugin that does things',
version: '1.0.0',
id: "my-plugin",
name: "My Plugin",
description: "A cool plugin that does things",
version: "1.0.0",
settings: settingsInstance.settings,
disableToggle: true,
beta: true,
run: async (api) => {
console.log('Plugin is running!');
console.log("Plugin is running!");
// Do stuff when settings change
api.settings.onChange('enabled', (enabled) => {
api.settings.onChange("enabled", (enabled) => {
if (enabled) {
console.log('Feature enabled!');
console.log("Feature enabled!");
}
});
// Return a cleanup function
return () => {
console.log('Plugin cleanup');
console.log("Plugin cleanup");
};
}
},
};
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
The SEQTA API helps you interact with SEQTA's pages:
```typescript
import type { Plugin } from '@/plugins/core/types';
import type { Plugin } from "@/plugins/core/types";
const seqtaPlugin: Plugin<typeof settings> = {
id: 'seqta-example',
name: 'SEQTA Example',
description: 'Shows how to use the SEQTA API',
version: '1.0.0',
id: "seqta-example",
name: "SEQTA Example",
description: "Shows how to use the SEQTA API",
version: "1.0.0",
settings: {},
disableToggle: true,
run: async (api) => {
// Wait for elements to appear
const { unregister: timetableUnregister } = api.seqta.onMount('.timetable', (timetable) => {
const button = document.createElement('button');
button.textContent = 'Export';
const { unregister: timetableUnregister } = api.seqta.onMount(
".timetable",
(timetable) => {
const button = document.createElement("button");
button.textContent = "Export";
timetable.appendChild(button);
});
},
);
// Track page changes
const { unregister: pageUnregister } = api.seqta.onPageChange((page) => {
console.log('User went to:', page);
console.log("User went to:", page);
});
// Clean up when disabled
@@ -91,7 +128,7 @@ const seqtaPlugin: Plugin<typeof settings> = {
timetableUnregister();
pageUnregister();
};
}
},
};
export default seqtaPlugin;
@@ -102,22 +139,29 @@ export default seqtaPlugin;
Here's how to add settings to your plugin:
```typescript
import type { Plugin } from '@/plugins/core/types';
import { BasePlugin } from '@/plugins/core/settings';
import { booleanSetting, stringSetting, numberSetting, selectSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers';
import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
stringSetting,
numberSetting,
selectSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
// Define your settings
const settings = defineSettings({
darkMode: booleanSetting({
default: false,
title: "Dark Mode",
description: "Enable dark mode"
description: "Enable dark mode",
}),
userName: stringSetting({
default: "",
title: "User Name",
description: "Your display name",
placeholder: "Enter your name..."
placeholder: "Enter your name...",
}),
theme: selectSetting({
default: "light",
@@ -125,9 +169,9 @@ const settings = defineSettings({
description: "Choose your theme",
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" }
]
})
{ value: "dark", label: "Dark" },
],
}),
});
// Create your settings class
@@ -144,29 +188,29 @@ class ThemePluginClass extends BasePlugin<typeof settings> {
// Create the plugin
const themePlugin: Plugin<typeof settings> = {
id: 'theme-example',
name: 'Theme Example',
description: 'Shows how to use settings',
version: '1.0.0',
id: "theme-example",
name: "Theme Example",
description: "Shows how to use settings",
version: "1.0.0",
settings: new ThemePluginClass().settings,
disableToggle: true,
run: async (api) => {
// Apply initial settings
if (api.settings.darkMode) {
document.body.classList.add('dark');
document.body.classList.add("dark");
}
// Listen for changes
const { unregister } = api.settings.onChange('darkMode', (enabled) => {
document.body.classList.toggle('dark', enabled);
const { unregister } = api.settings.onChange("darkMode", (enabled) => {
document.body.classList.toggle("dark", enabled);
});
return () => {
unregister();
document.body.classList.remove('dark');
document.body.classList.remove("dark");
};
}
},
};
export default themePlugin;
@@ -177,13 +221,13 @@ export default themePlugin;
Here's how to use storage in your plugin:
```typescript
import type { Plugin } from '@/plugins/core/types';
import type { Plugin } from "@/plugins/core/types";
const storagePlugin: Plugin<typeof settings> = {
id: 'storage-example',
name: 'Storage Example',
description: 'Shows how to use storage',
version: '1.0.0',
id: "storage-example",
name: "Storage Example",
description: "Shows how to use storage",
version: "1.0.0",
settings: {},
disableToggle: true,
@@ -192,21 +236,21 @@ const storagePlugin: Plugin<typeof settings> = {
await api.storage.loaded;
// Save some data
await api.storage.set('lastVisit', new Date().toISOString());
await api.storage.set("lastVisit", new Date().toISOString());
// Get saved data
const lastVisit = await api.storage.get('lastVisit');
console.log('Last visit:', lastVisit);
const lastVisit = await api.storage.get("lastVisit");
console.log("Last visit:", lastVisit);
// Listen for changes
const { unregister } = api.storage.onChange('lastVisit', (newValue) => {
console.log('Last visit updated:', newValue);
const { unregister } = api.storage.onChange("lastVisit", (newValue) => {
console.log("Last visit updated:", newValue);
});
return () => {
unregister();
};
}
},
};
export default storagePlugin;
@@ -217,33 +261,39 @@ export default storagePlugin;
Here's how to use events in your plugin:
```typescript
import type { Plugin } from '@/plugins/core/types';
import type { Plugin } from "@/plugins/core/types";
const eventsPlugin: Plugin<typeof settings> = {
id: 'events-example',
name: 'Events Example',
description: 'Shows how to use events',
version: '1.0.0',
id: "events-example",
name: "Events Example",
description: "Shows how to use events",
version: "1.0.0",
settings: {},
disableToggle: true,
run: async (api) => {
// Listen for theme changes
const { unregister: themeListener } = api.events.on('theme.changed', (theme) => {
console.log('Theme changed to:', theme);
});
const { unregister: themeListener } = api.events.on(
"theme.changed",
(theme) => {
console.log("Theme changed to:", theme);
},
);
// Listen for notifications
const { unregister: notifyListener } = api.events.on('notification.new', (notification) => {
console.log('New notification:', notification);
});
const { unregister: notifyListener } = api.events.on(
"notification.new",
(notification) => {
console.log("New notification:", notification);
},
);
// Clean up listeners
return () => {
themeListener();
notifyListener();
};
}
},
};
export default eventsPlugin;
@@ -254,20 +304,20 @@ export default eventsPlugin;
Here's how to write efficient plugins:
```typescript
import type { Plugin } from '@/plugins/core/types';
import type { Plugin } from "@/plugins/core/types";
const efficientPlugin: Plugin<typeof settings> = {
id: 'efficient-example',
name: 'Efficient Example',
description: 'Shows performance best practices',
version: '1.0.0',
id: "efficient-example",
name: "Efficient Example",
description: "Shows performance best practices",
version: "1.0.0",
settings: {},
disableToggle: true,
run: async (api) => {
// ✅ Good: Use onMount
const { unregister } = api.seqta.onMount('.timetable', (el) => {
el.classList.add('enhanced');
const { unregister } = api.seqta.onMount(".timetable", (el) => {
el.classList.add("enhanced");
});
// ❌ Bad: Don't use intervals
@@ -277,7 +327,7 @@ const efficientPlugin: Plugin<typeof settings> = {
// }, 100);
// ✅ Good: Cache DOM elements
const header = document.querySelector('.header');
const header = document.querySelector(".header");
if (header) {
// Reuse header instead of querying again
}
@@ -285,7 +335,7 @@ const efficientPlugin: Plugin<typeof settings> = {
// ✅ Good: Batch DOM updates
const fragment = document.createDocumentFragment();
for (let i = 0; i < 10; i++) {
const div = document.createElement('div');
const div = document.createElement("div");
fragment.appendChild(div);
}
document.body.appendChild(fragment);
@@ -294,13 +344,14 @@ const efficientPlugin: Plugin<typeof settings> = {
unregister();
// clearInterval(interval); // If you used the bad approach
};
}
},
};
export default efficientPlugin;
```
Each plugin should be in its own file and exported as the default export. The plugin should:
1. Import necessary types and helpers
2. Define settings if needed
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
Remember to always:
- Use proper TypeScript types
- Clean up when your plugin is disabled
- 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',
],
};
+1 -1
View File
@@ -7,7 +7,7 @@ export const base64Loader = {
const [filePath, query] = id.split("?");
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 dataURL = `data:${mimeType};base64,${data}`;
+9 -9
View File
@@ -1,25 +1,25 @@
// ref: https://stackoverflow.com/a/76920975
import type { Plugin } from 'vite';
import type { Plugin } from "vite";
export default function ClosePlugin(): Plugin {
return {
name: 'ClosePlugin', // required, will show up in warnings and errors
name: "ClosePlugin", // required, will show up in warnings and errors
// use this to catch errors when building
buildEnd(error) {
if (error) {
console.error('Error bundling')
console.error(error)
process.exit(1)
console.error("Error bundling");
console.error(error);
process.exit(1);
} else {
console.log('Build ended')
console.log("Build ended");
}
},
// use this to catch the end of a build without errors
closeBundle() {
console.log('Bundle closed')
process.exit(0)
console.log("Bundle closed");
process.exit(0);
},
}
};
}
+4 -4
View File
@@ -1,5 +1,5 @@
import type { Browser, BuildTarget, Manifest } from './types'
import type { AnyCase } from './utils'
import type { Browser, BuildTarget, Manifest } from "./types";
import type { AnyCase } from "./utils";
/**
*
*
@@ -15,7 +15,7 @@ export function createManifest(
return {
manifest,
browser,
}
};
}
/**
@@ -29,5 +29,5 @@ export function createManifest(
* @return {*} {@link Manifest}
*/
export function createManifestBase(manifest: Manifest): Manifest {
return manifest
return manifest;
}
+37
View File
@@ -0,0 +1,37 @@
// vite-plugin-inline-worker-dev.ts
import { Plugin } from "vite";
import fs from "fs/promises";
import { build, transform } from "esbuild";
export default function InlineWorkerDevPlugin(): Plugin {
return {
name: "vite:inline-worker-dev",
async load(id) {
if (id.includes("?inlineWorker")) {
const [cleanPath] = id.split("?");
console.log("cleanPath", cleanPath);
const code = await fs.readFile(cleanPath, "utf-8");
const result = await build({
entryPoints: [cleanPath],
bundle: true,
write: false,
platform: "browser",
format: "iife",
target: "esnext",
});
const workerCode = result.outputFiles[0].text;
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;
},
};
}
-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;
}
+65 -42
View File
@@ -1,49 +1,63 @@
const glob = require('glob');
const semver = require('semver');
const { execSync } = require('child_process');
const path = require('path');
const glob = require("glob");
const semver = require("semver");
const { execSync } = require("child_process");
const path = require("path");
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\.]+)-/);
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;
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
const semverVersion = fullVersion.split(".").slice(0, 3).join("."); // Trim to 3.4.5
return { fullVersion, semverVersion };
}).filter(Boolean);
})
.filter(Boolean);
console.log('Extracted versions:', versions.map(v => v.semverVersion));
console.log(
"Extracted versions:",
versions.map((v) => v.semverVersion),
);
// Find latest version using the trimmed semver format
const latestSemver = semver.maxSatisfying(versions.map(v => v.semverVersion), '*');
console.log('Latest SemVer-compatible version:', latestSemver);
const latestSemver = semver.maxSatisfying(
versions.map((v) => v.semverVersion),
"*",
);
console.log("Latest SemVer-compatible version:", latestSemver);
// Get the full version that matches the latest SemVer version
const latestVersion = versions.find(v => v.semverVersion === latestSemver)?.fullVersion || null;
const latestVersion =
versions.find((v) => v.semverVersion === latestSemver)?.fullVersion || null;
console.log('Final selected latest version:', latestVersion);
console.log("Final selected latest version:", latestVersion);
return latestVersion;
}
function getLatestFiles(browser) {
const pattern = `dist/betterseqtaplus@*-*${browser}.zip`;
console.log('Glob pattern:', pattern);
console.log("Glob pattern:", pattern);
const files = glob.sync(pattern);
console.log('Files found for browser', browser, ':', files);
console.log("Files found for browser", browser, ":", files);
const latestVersion = getLatestVersion(files);
// Find the exact file by matching the original full version
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;
}
@@ -51,44 +65,53 @@ function zipSources() {
const zipFileName = `dist/betterseqtaplus@latest-sources.zip`;
const excludePatterns = [
'node_modules',
'dist',
'.env*',
'.git',
'.github',
'.vscode',
'LICENSE',
'package.json'
].map(pattern => `-x!${pattern}`).join(' ');
"node_modules",
"dist",
".env*",
".git",
".github",
".vscode",
"LICENSE",
"package.json",
]
.map((pattern) => `-x!${pattern}`)
.join(" ");
const zipCommand = `7z a ${zipFileName} . ${excludePatterns}`;
console.log('Zipping project sources with command:', zipCommand);
execSync(zipCommand, { stdio: 'inherit' });
console.log("Zipping project sources with command:", zipCommand);
execSync(zipCommand, { stdio: "inherit" });
return zipFileName;
}
function runPublishCommand(browsers) {
const chromeZip = browsers.includes('chrome') ? getLatestFiles('chrome') : null;
const firefoxZip = browsers.includes('firefox') ? getLatestFiles('firefox') : null;
const firefoxSourcesZip = browsers.includes('firefox') ? zipSources() : null;
const chromeZip = browsers.includes("chrome")
? getLatestFiles("chrome")
: null;
const firefoxZip = browsers.includes("firefox")
? getLatestFiles("firefox")
: null;
const firefoxSourcesZip = browsers.includes("firefox") ? zipSources() : null;
console.log('Chrome zip:', chromeZip);
console.log('Firefox zip:', firefoxZip);
console.log('Firefox sources zip:', firefoxSourcesZip);
console.log("Chrome zip:", chromeZip);
console.log("Firefox zip:", firefoxZip);
console.log("Firefox sources zip:", firefoxSourcesZip);
if (browsers.length === 0) {
console.log('No browsers specified. Exiting.');
console.log("No browsers specified. Exiting.");
process.exit(0);
}
if ((browsers.includes('chrome') && !chromeZip) || (browsers.includes('firefox') && (!firefoxZip || !firefoxSourcesZip))) {
console.error('Could not find required zip files for specified browsers.');
if (
(browsers.includes("chrome") && !chromeZip) ||
(browsers.includes("firefox") && (!firefoxZip || !firefoxSourcesZip))
) {
console.error("Could not find required zip files for specified browsers.");
process.exit(1);
}
let command = 'publish-extension';
let command = "publish-extension";
if (chromeZip) {
command += ` --chrome-zip ${chromeZip}`;
}
@@ -96,13 +119,13 @@ function runPublishCommand(browsers) {
command += ` --firefox-zip ${firefoxZip} --firefox-sources-zip ${firefoxSourcesZip}`;
}
console.log('Running command:', command);
execSync(command, { stdio: 'inherit' });
console.log("Running command:", command);
execSync(command, { stdio: "inherit" });
}
// Parse command-line arguments
const args = process.argv.slice(2);
const browserIndex = args.indexOf('--b');
const browserIndex = args.indexOf("--b");
const browsers = browserIndex !== -1 ? args.slice(browserIndex + 1) : [];
runPublishCommand(browsers);
+8 -8
View File
@@ -1,17 +1,17 @@
import fs from 'fs';
import fs from "fs";
export default function touchGlobalCSSPlugin() {
return {
name: 'touch-global-css',
name: "touch-global-css",
handleHotUpdate({ modules }) {
// log all of the staticImportedUrls
const importers = modules[0]._clientModule.importers
const importers = modules[0]._clientModule.importers;
importers.forEach((importer) => {
if (importer.file.includes('.css')) {
console.log("touching", importer.file)
fs.utimesSync(importer.file, new Date(), new Date())
}
})
if (importer.file.includes(".css")) {
console.log("touching", importer.file);
fs.utimesSync(importer.file, new Date(), new Date());
}
});
},
};
}
+67 -67
View File
@@ -1,104 +1,104 @@
import type { ManifestV3Export } from '@crxjs/vite-plugin'
import { type AnyCase, createEnum } from './utils'
import type { ManifestV3Export } from "@crxjs/vite-plugin";
import { type AnyCase, createEnum } from "./utils";
export const FrameworkEnum = {
React: 'React',
Vanilla: 'Vanilla',
Preact: 'Preact',
Lit: 'Lit',
Svelte: 'Svelte',
Vue: 'Vue',
} as const
React: "React",
Vanilla: "Vanilla",
Preact: "Preact",
Lit: "Lit",
Svelte: "Svelte",
Vue: "Vue",
} as const;
export const BrowserEnum = {
Chrome: 'Chrome',
Brave: 'Brave',
Opera: 'Opera',
Edge: 'Edge',
Firefox: 'Firefox',
Safari: 'Safari',
} as const
Chrome: "Chrome",
Brave: "Brave",
Opera: "Opera",
Edge: "Edge",
Firefox: "Firefox",
Safari: "Safari",
} as const;
const LanguageEnum = {
TypeScript: 'TypeScript',
JavaScript: 'JavaScript',
} as const
TypeScript: "TypeScript",
JavaScript: "JavaScript",
} as const;
export const StyleEnum = {
Tailwind: 'Tailwind',
} as const
Tailwind: "Tailwind",
} as const;
export const PackageManagerEnum = {
Bun: 'Bun',
PnPm: 'PnPm',
Npm: 'Npm',
Yarn: 'Yarn',
} as const
Bun: "Bun",
PnPm: "PnPm",
Npm: "Npm",
Yarn: "Yarn",
} as const;
// see: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/firefox-webext-browser/index.d.ts
export type BrowserSpecificSettings = {
browser_specific_settings?: {
gecko?: {
id: string
strict_min_version?: string
strict_max_version?: string
}
}
}
id: string;
strict_min_version?: string;
strict_max_version?: string;
};
};
};
export type Manifest = ManifestV3Export
export type ManifestIcons = chrome.runtime.ManifestIcons
export type ManifestBackground = chrome.runtime.ManifestV3['background']
export type Manifest = ManifestV3Export;
export type ManifestIcons = chrome.runtime.ManifestIcons;
export type ManifestBackground = chrome.runtime.ManifestV3["background"];
export type ManifestContentScripts =
chrome.runtime.ManifestV3['content_scripts']
chrome.runtime.ManifestV3["content_scripts"];
export type ManifestWebAccessibleResources =
chrome.runtime.ManifestV3['web_accessible_resources']
export type ManifestCommands = chrome.runtime.ManifestV3['commands']
export type ManifestAction = chrome.runtime.ManifestV3['action']
export type ManifestPermissions = chrome.runtime.ManifestV3['permissions']
export type ManifestOptionsUI = chrome.runtime.ManifestV3['options_ui']
chrome.runtime.ManifestV3["web_accessible_resources"];
export type ManifestCommands = chrome.runtime.ManifestV3["commands"];
export type ManifestAction = chrome.runtime.ManifestV3["action"];
export type ManifestPermissions = chrome.runtime.ManifestV3["permissions"];
export type ManifestOptionsUI = chrome.runtime.ManifestV3["options_ui"];
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>
export type BrowserName<T extends string> = Capitalize<T> | Lowercase<T>;
export type BrowserEnumType<T extends string> = {
[browser in BrowserName<T>]: BrowserName<T>
}
[browser in BrowserName<T>]: BrowserName<T>;
};
export type BuildMode = AnyCase<Browser>
export type BuildMode = AnyCase<Browser>;
export type BuildTarget = {
manifest: Manifest
browser: AnyCase<Browser>
}
manifest: Manifest;
browser: AnyCase<Browser>;
};
export type BuildConfig = {
command?: 'build' | 'serve'
mode?: AnyCase<Browser> | string | undefined
}
command?: "build" | "serve";
mode?: AnyCase<Browser> | string | undefined;
};
export interface Repository {
type: string
url?: string
bugs?: Bugs
type: string;
url?: string;
bugs?: Bugs;
}
export interface Bugs {
url?: string
email?: string
url?: string;
email?: string;
}
export type Browser = (typeof BrowserEnum)[keyof typeof BrowserEnum]
export const Browser: AnyCase<Browser> = createEnum(BrowserEnum)
export type Browser = (typeof BrowserEnum)[keyof typeof BrowserEnum];
export const Browser: AnyCase<Browser> = createEnum(BrowserEnum);
export type PackageManager =
(typeof PackageManagerEnum)[keyof typeof PackageManagerEnum]
(typeof PackageManagerEnum)[keyof typeof PackageManagerEnum];
export const PackageManager: AnyCase<PackageManager> =
createEnum(PackageManagerEnum)
createEnum(PackageManagerEnum);
export type Framework = (typeof FrameworkEnum)[keyof typeof FrameworkEnum]
export const Framework: AnyCase<Framework> = createEnum(FrameworkEnum)
export type Framework = (typeof FrameworkEnum)[keyof typeof FrameworkEnum];
export const Framework: AnyCase<Framework> = createEnum(FrameworkEnum);
export type Style = (typeof StyleEnum)[keyof typeof StyleEnum]
export const Style: AnyCase<Style> = createEnum(StyleEnum)
export type Style = (typeof StyleEnum)[keyof typeof StyleEnum];
export const Style: AnyCase<Style> = createEnum(StyleEnum);
export type Language = (typeof LanguageEnum)[keyof typeof LanguageEnum]
export const Language: AnyCase<Language> = createEnum(LanguageEnum)
export type Language = (typeof LanguageEnum)[keyof typeof LanguageEnum];
export const Language: AnyCase<Language> = createEnum(LanguageEnum);
+6 -6
View File
@@ -1,21 +1,21 @@
export type ObjectValues<T> = T[keyof T]
export type ObjectValues<T> = T[keyof 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>;
}
export type AnyCase<T extends string> =
| Uppercase<T>
| Lowercase<T>
| Capitalize<T>
| Uncapitalize<T>
| Uncapitalize<T>;
export type AnyCaseLanguage<T extends string, K extends string> =
| Uppercase<T | K>
| Lowercase<T | K>
| Capitalize<T | K>
| Uncapitalize<T | K>
| Uncapitalize<T | K>;
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];
};
+14 -3
View File
@@ -1,6 +1,6 @@
{
"name": "betterseqtaplus",
"version": "3.4.6",
"version": "3.4.6.1",
"type": "module",
"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",
@@ -16,7 +16,10 @@
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
"publish": "bun lib/publish.js --b",
"zip": "bedframe zip"
"zip": "bedframe zip",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"targets": {
"prod": {
@@ -36,7 +39,8 @@
"@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.91",
"@crxjs/vite-plugin": "2.0.0-beta.25",
"@crxjs/vite-plugin": "2.0.0-beta.32",
"@types/jest": "^29.5.14",
"@types/mime-types": "^2.1.4",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
@@ -44,6 +48,7 @@
"dependency-cruiser": "^16.10.0",
"eslint": "9.22.0",
"glob": "^11.0.1",
"jest": "^29.7.0",
"mime-types": "^2.1.35",
"prettier": "^3.5.3",
"process": "^0.11.10",
@@ -52,6 +57,7 @@
"sass-loader": "^16.0.5",
"semver": "^7.7.1",
"tailwindcss": "3",
"ts-jest": "^29.3.4",
"url": "^0.11.4"
},
"dependencies": {
@@ -78,12 +84,17 @@
"codemirror": "^6.0.1",
"color": "^5.0.0",
"dompurify": "^3.2.4",
"embeddia": "^1.2.1",
"embla-carousel-autoplay": "^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",
"idb": "^8.0.2",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"mathjs": "^14.4.0",
"million": "^3.1.11",
"motion": "^12.4.12",
"postcss": "^8.5.3",
+34 -26
View File
@@ -1,52 +1,60 @@
import {
initializeSettingsState,
settingsState,
} from "@/seqta/utils/listeners/SettingsState"
import documentLoadCSS from "@/css/documentload.scss?inline"
import icon48 from "@/resources/icons/icon-48.png?base64"
import browser from "webextension-polyfill"
} from "@/seqta/utils/listeners/SettingsState";
import documentLoadCSS from "@/css/documentload.scss?inline";
import icon48 from "@/resources/icons/icon-48.png?base64";
import browser from "webextension-polyfill";
import * as plugins from "@/plugins"
import { main } from "@/seqta/main"
import * as plugins from "@/plugins";
import { main } from "@/seqta/main";
import { delay } from "./seqta/utils/delay";
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)
if (document.childNodes[1]) {
hasSEQTAText =
document.childNodes[1].textContent?.includes(
"Copyright (c) SEQTA Software",
) ?? false
init()
) ?? false;
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
IsSEQTAPage = true
console.info("[BetterSEQTA+] Verified SEQTA Page")
if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) {
// Verify we are on a SEQTA page
IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page");
const documentLoadStyle = document.createElement("style")
documentLoadStyle.textContent = documentLoadCSS
document.head.appendChild(documentLoadStyle)
const documentLoadStyle = document.createElement("style");
documentLoadStyle.textContent = documentLoadCSS;
document.head.appendChild(documentLoadStyle);
const icon = document.querySelector('link[rel*="icon"]')! as HTMLLinkElement
icon.href = icon48 // Change the icon
const icon = document.querySelector(
'link[rel*="icon"]',
)! as HTMLLinkElement;
icon.href = icon48; // Change the icon
try {
await initializeSettingsState();
if (typeof settingsState.onoff === "undefined") {
browser.runtime.sendMessage({ type: "setDefaultStorage" })
await browser.runtime.sendMessage({ type: "setDefaultStorage" });
await delay(5);
}
await main()
await main();
if (settingsState.onoff) {
// Initialize legacy plugins
plugins.Monofile()
plugins.Monofile();
// Initialize new plugin system
await plugins.initializePlugins();
@@ -54,9 +62,9 @@ async function init() {
console.info(
"[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.",
)
);
} catch (error: any) {
console.error(error)
console.error(error);
}
}
}
+68 -83
View File
@@ -1,63 +1,68 @@
import browser from 'webextension-polyfill'
import browser from "webextension-polyfill";
import type { SettingsState } from "@/types/storage";
import { fetchNews } from './background/news';
import { fetchNews } from "./background/news";
function reloadSeqtaPages() {
const result = browser.tabs.query({})
const result = browser.tabs.query({});
function open(tabs: any) {
for (let tab of tabs) {
if (tab.title.includes('SEQTA Learn')) {
if (tab.title.includes("SEQTA Learn")) {
browser.tabs.reload(tab.id);
}
}
}
result.then(open, console.error)
result.then(open, console.error);
}
// @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) {
case 'reloadTabs':
case "reloadTabs":
reloadSeqtaPages();
break;
case 'extensionPages':
case "extensionPages":
browser.tabs.query({}).then(function (tabs) {
for (let tab of tabs) {
if (tab.url?.includes('chrome-extension://')) {
if (tab.url?.includes("chrome-extension://")) {
browser.tabs.sendMessage(tab.id!, request);
}
}
});
break;
case 'currentTab':
browser.tabs.query({ active: true, currentWindow: true }).then(function (tabs) {
browser.tabs.sendMessage(tabs[0].id!, request).then(function (response) {
case "currentTab":
browser.tabs
.query({ active: true, currentWindow: true })
.then(function (tabs) {
browser.tabs
.sendMessage(tabs[0].id!, request)
.then(function (response) {
sendResponse(response);
});
});
return true;
case 'githubTab':
browser.tabs.create({ url: 'github.com/BetterSEQTA/BetterSEQTA-Plus' });
case "githubTab":
browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
break;
case 'setDefaultStorage':
case "setDefaultStorage":
SetStorageValue(DefaultValues);
break;
case 'sendNews':
fetchNews(request.source ?? 'australia', sendResponse);
case "sendNews":
fetchNews(request.source ?? "australia", sendResponse);
return true;
default:
console.log('Unknown request type');
console.log("Unknown request type");
}
return false;
});
},
);
const DefaultValues: SettingsState = {
onoff: true,
@@ -86,66 +91,31 @@ const DefaultValues: SettingsState = {
},
menuorder: [],
subjectfilters: {},
selectedTheme: '',
selectedColor: 'linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)',
originalSelectedColor: '',
selectedTheme: "",
selectedColor:
"linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)",
originalSelectedColor: "",
DarkMode: true,
animations: true,
assessmentsAverage: true,
defaultPage: 'home',
defaultPage: "home",
shortcuts: [
{
name: 'YouTube',
enabled: false,
},
{
name: 'Outlook',
name: "Outlook",
enabled: true,
},
{
name: 'Office',
name: "Office",
enabled: true,
},
{
name: 'Spotify',
enabled: false,
},
{
name: 'Google',
name: "Google",
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: [],
lettergrade: false,
newsSource: 'australia',
newsSource: "australia",
};
function SetStorageValue(object: any) {
@@ -158,7 +128,8 @@ function convertBksliderToSpeed(bksliderinput: number): number {
const minBase = 50;
const maxBase = 150;
const scaledValue = 2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4;
const scaledValue =
2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4;
const baseSpeed = 3;
const speed = baseSpeed / scaledValue;
@@ -166,50 +137,64 @@ function convertBksliderToSpeed(bksliderinput: number): number {
}
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
if ('animatedbk' in storage || 'bksliderinput' in storage) {
if ("animatedbk" in storage || "bksliderinput" in storage) {
const animatedSettings = {
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
if ('assessmentsAverage' in storage || 'lettergrade' in storage) {
if ("assessmentsAverage" in storage || "lettergrade" in storage) {
const assessmentsSettings = {
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 };
await browser.storage.local.set({ 'plugin.themes.settings': themesSettings });
await browser.storage.local.set({
"plugin.themes.settings": themesSettings,
});
}
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 {
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: false } });
await browser.storage.local.set({
"plugin.notificationCollector.settings": { enabled: false },
});
}
const keysToRemove = [
'animatedbk',
'bksliderinput',
'assessmentsAverage',
'lettergrade'
"animatedbk",
"bksliderinput",
"assessmentsAverage",
"lettergrade",
];
await browser.storage.local.remove(keysToRemove);
}
browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.remove(['justupdated']);
browser.storage.local.remove(['data']);
browser.storage.local.remove(["justupdated"]);
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 });
migrateLegacySettings();
}
+24 -20
View File
@@ -1,11 +1,11 @@
import Parser from 'rss-parser';
import Parser from "rss-parser";
const fetchAustraliaNews = async (url: string, sendResponse: any) => {
fetch(url)
.then((result) => result.json())
.then((response) => {
if (response.code == 'rateLimited') {
fetchAustraliaNews(url += '%00', sendResponse);
if (response.code == "rateLimited") {
fetchAustraliaNews((url += "%00"), sendResponse);
} else {
sendResponse({ news: response });
}
@@ -19,20 +19,25 @@ const rssFeedsByCountry: Record<string, string[]> = {
"https://www.npr.org/rss/rss.php",
],
taiwan: [
"https://focustaiwan.tw/rss",
"https://www.taipeitimes.com/rss/all.xml",
"https://news.ltn.com.tw/rss/all.xml",
"https://www.taipeitimes.com/xml/index.rss",
"https://international.thenewslens.com/rss",
],
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",
],
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: [
"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: [
"https://www.straitstimes.com/news/singapore/rss.xml",
@@ -43,28 +48,21 @@ const rssFeedsByCountry: Record<string, string[]> = {
"https://www.theguardian.com/uk/rss",
],
japan: [
"https://www.japantimes.co.jp/feed/topstories.xml",
"https://www3.nhk.or.jp/nhkworld/en/news/feeds/",
"https://news.livedoor.com/topics/rss/int.xml",
],
netherlands: [
"https://www.dutchnews.nl/feed/",
"http://feeds.nos.nl/nosnieuwsalgemeen",
],
netherlands: ["https://www.dutchnews.nl/feed/", "https://www.nrc.nl/rss/"],
};
export async function fetchNews(source: string, sendResponse: any) {
const parser = new Parser();
let feeds: string[];
console.log('fetchNews', source)
if (source === "australia") {
const date = new Date();
const from =
date.getFullYear() +
'-' +
"-" +
(date.getMonth() + 1) +
'-' +
"-" +
(date.getDate() - 5);
const url = `https://newsapi.org/v2/everything?domains=abc.net.au&from=${from}&apiKey=17c0da766ba347c89d094449504e3080`;
@@ -73,6 +71,10 @@ export async function fetchNews(source: string, sendResponse: any) {
return;
}
const parser = new Parser();
let feeds: string[];
console.log("fetchNews", source);
if (rssFeedsByCountry[source.toLowerCase()]) {
// If the source is a country, fetch from predefined feeds
feeds = rssFeedsByCountry[source.toLowerCase()];
@@ -80,7 +82,9 @@ export async function fetchNews(source: string, sendResponse: any) {
// If the source is a URL, use it directly
feeds = [source];
} 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) => {
+4 -2
View File
@@ -15,7 +15,7 @@
* along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>.
*/
@use 'injected/popup.scss';
@use "injected/popup.scss";
html {
background: #161616 !important;
@@ -77,7 +77,9 @@ html {
transform-origin: top;
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);
}
.assessmenttooltip svg {
+1 -1
View File
@@ -1 +1 @@
import './documentload.scss';
import "./documentload.scss";
+10 -1
View File
@@ -25,7 +25,9 @@
span,
body {
color: white !important;
text-shadow: 1px 1px 2px #161616, 0 0 1em #161616;
text-shadow:
1px 1px 2px #161616,
0 0 1em #161616;
}
body {
@@ -112,3 +114,10 @@
transition: text-shadow 0.5s;
}
}
.cke_panel_listItem > a {
&:hover {
background: #3d3d3e !important;
}
}
+224 -39
View File
@@ -12,6 +12,13 @@
font-family: Rubik, sans-serif !important;
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
pointer-events: none;
}
.hidden {
display: none;
}
@@ -664,14 +671,12 @@ td.colourBar {
}
#toolbar span:has(.search) {
position: relative;
/* Makes sure the pseudo-element is positioned relative to this element */
}
#toolbar .search {
padding-left: 30px;
}
#toolbar span:has(.search)::before {
content: "\eca5";
/* Unicode for the search icon */
position: absolute;
left: 8px;
z-index: 10;
@@ -684,7 +689,7 @@ td.colourBar {
}
#container #content .search {
width: 100%;
border-radius: 16px;
border-radius: 8px;
background: var(--background-primary);
}
#container #content .uiButton {
@@ -946,6 +951,23 @@ div > ol:has(.uiFileHandlerWrapper) {
opacity: 0.8;
}
#content:has(#main > .course) #toolbar {
top: 72px;
left: 0px;
z-index: 10;
@media (min-width: 1401px) {
position: absolute;
left: 402px;
}
}
#main > .course .content {
@media (min-width: 1400px) {
padding-top: 2.5rem;
}
}
#main > .notices > .notice > .contents {
background: var(--background-primary);
}
@@ -1225,17 +1247,6 @@ div > ol:has(.uiFileHandlerWrapper) {
position: relative;
transition: 200ms;
}
.customshortcut::after {
content: "Custom Shortcut";
position: absolute;
top: -4px;
right: -15px;
font-size: 8px;
padding: 2px 5px;
background: var(--better-alert-highlight);
border-radius: 8px;
color: white;
}
.shortcut:hover {
background: var(--auto-background);
}
@@ -1375,10 +1386,13 @@ div > ol:has(.uiFileHandlerWrapper) {
margin: 20px auto 0px;
cursor: pointer;
}
.dark [class*="notifications__detailsBody___"] > [class*="notifications__subtitle___"] {
.dark
[class*="notifications__detailsBody___"]
> [class*="notifications__subtitle___"] {
color: #c1bcbc;
}
[class*="notifications__detailsBody___"] > [class*="notifications__subtitle___"] {
[class*="notifications__detailsBody___"]
> [class*="notifications__subtitle___"] {
font-size: 12px;
}
[class*="notifications__notifications___"] > button {
@@ -1394,7 +1408,9 @@ div > ol:has(.uiFileHandlerWrapper) {
height: 25px;
width: 24px;
}
[class*="notifications__notifications___"] > button > [class*="notifications__bubble___"] {
[class*="notifications__notifications___"]
> button
> [class*="notifications__bubble___"] {
background: var(--better-alert-highlight);
width: 25px;
height: 25px;
@@ -1523,9 +1539,9 @@ div > ol:has(.uiFileHandlerWrapper) {
background: var(--background-primary);
}
[class*="Input__Input___"]::before,
.navigator .bar.flat::before,
.ais-btnSearch::before {
content: "";
/* Unicode for the search icon */
transform: translateY(-50%);
color: currentColor;
font-size: 16px;
@@ -1614,6 +1630,13 @@ iframe.userHTML {
[class*="Thermoscore__Thermoscore___"] {
background-image: unset;
background: var(--auto-background);
border-radius: 8px;
}
.dark [class*="Thermoscore__Thermoscore___"] {
border: 2px solid rgba(255, 255, 255, 0.3);
}
[class*="AssessmentItem__meta___"] {
padding-bottom: 8px;
}
#toolbar {
color: var(--text-primary);
@@ -1628,9 +1651,6 @@ iframe.userHTML {
.dailycal > .times > .time {
padding: 0 !important;
}
.navigator {
border-top-right-radius: 16px;
}
.programmeNavigator > .navigator,
.programmeNavigator > .navigator > li > ul {
background: var(--background-primary);
@@ -1639,10 +1659,132 @@ iframe.userHTML {
.programmeNavigator > .navigator > .week > .lessons > .lesson:hover {
background: var(--auto-background);
}
.programmeNavigator > .navigator > .week > .lessons > .lesson.selected,
.programmeNavigator > .navigator > .cover.selected {
background: transparent;
.pane .navigator::after {
content: unset !important;
}
.programmeNavigator {
box-shadow: 0 0 40px 0px rgba(0,0,0,0.05);
.navigator {
padding: 6px !important;
.bar.flat::before {
z-index: 10;
left: 8px;
margin: 0;
position: absolute;
top: 50%;
}
&::after {
content: "";
position: fixed;
z-index: 1;
top: 70px;
width: 390px;
height: 60px;
background: linear-gradient(to bottom, var(--background-primary) 50%, rgba(0, 0, 0, 0));
pointer-events: none;
}
.search {
padding: 10px;
padding-left: 30px;
top: 8px;
margin-top: -50px;
z-index: 2;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1);
}
.bar {
top: 13px;
padding-right: 4px;
position: sticky;
pointer-events: none;
button {
position: unset;
pointer-events: auto;
}
}
.meta {
border-radius: 8px;
}
.cover {
padding: 12px;
}
.cover,
.lesson {
border-radius: 8px;
transition: background 0.1s ease-out;
position: relative;
color: var(--text-primary) !important;
z-index: 1;
&:hover {
background: transparent !important;
}
&.selected {
background: transparent !important;
}
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 8px;
background: var(--auto-background);
opacity: 0;
scale: 0.95;
transition: opacity 0.2s ease-out, scale 0.1s ease-out;
z-index: -1;
pointer-events: none;
}
&:hover::before {
opacity: 0.5;
scale: 1;
}
&.selected::before {
opacity: 1;
scale: 1;
}
}
}
}
.pane {
.content:has(.programmeNavigator) {
margin: 0;
}
.programmeNavigator .navigator {
.search {
border-radius: 8px;
}
&::before {
top: 83px;
}
}
}
.dark .programmeNavigator .navigator {
.search {
background: var(--background-secondary) !important;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1), inset 0px 0px 15px 0px rgba(0, 0, 0, 0.1) !important;
}
}
.dark #main > .course > .content > h1 {
text-shadow: 0 0 10px black;
@@ -1669,7 +1811,7 @@ body,
div,
ol,
ul {
scrollbar-width: thin !important;
scrollbar-width: thin;
scrollbar-color: #babac0 #fff !important;
}
@@ -1678,7 +1820,7 @@ ul {
div,
ol,
ul {
scrollbar-width: thin !important;
scrollbar-width: thin;
scrollbar-color: #333 #111 !important;
}
}
@@ -1692,12 +1834,19 @@ ul {
.programmeNavigator {
width: 400px;
background: var(--background-primary);
border-top-right-radius: 16px;
position: relative;
}
#userActions > .details > .code {
text-transform: initial;
}
div:has(> [class*="AssessmentDetails__AssessmentDetails___"]) {
padding: 4px;
}
[class*="SelectedAssessment__due___"] {
border-radius: 8px !important;
background: var(--background-primary) !important;
}
[class*="SelectedAssessment__SelectedAssessment___"] {
color: var(--text-primary);
}
@@ -1710,7 +1859,9 @@ ul {
> [class*="SelectedAssessment__meta___"] {
border-bottom: 1px solid var(--better-main);
}
[class*="TabSet__TabSet___"] > ol[class*="TabSet__tabs___"] > li[class*="TabSet__selected___"] {
[class*="TabSet__TabSet___"]
> ol[class*="TabSet__tabs___"]
> li[class*="TabSet__selected___"] {
border-bottom-color: var(--better-main);
}
[class*="TabSet__TabSet___"] > ol[class*="TabSet__tabs___"] {
@@ -2059,6 +2210,10 @@ div.bar.flat {
}
}
.dark .cke_combo_button {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="white" viewBox="0 0 24 24"><path d="M6.984 9.984h10.031l-5.016 5.016z"/></svg>') !important;
}
.quicktable {
border-radius: 12px;
}
@@ -2147,6 +2302,23 @@ body {
}
[class*="Viewer__Viewer___"] {
background: unset;
[class*="ReadingPane__ReadingPane___"] {
> [class*="Message__unread___"] > header {
box-shadow: none !important;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 4px;
background: var(--better-main);
}
}
}
}
.weekend {
display: none !important;
@@ -2181,7 +2353,9 @@ body {
border-radius: 1600px;
}
[class*="MessageList__MessageList___"] > ol > li[class*="MessageList__selected___"]
[class*="MessageList__MessageList___"]
> ol
> li[class*="MessageList__selected___"]
[class*="MessageList__unread___"] {
box-shadow: none;
}
@@ -2190,7 +2364,9 @@ body {
box-shadow: none;
}
[class*="MessageList__MessageList___"] > ol > li[class*="MessageList__unread___"]::before,
[class*="MessageList__MessageList___"]
> ol
> li[class*="MessageList__unread___"]::before,
[class*="MessageList__MessageList___"] > ol > li::before {
content: "";
position: absolute;
@@ -2202,7 +2378,9 @@ body {
transition: width 0.1s;
}
[class*="MessageList__MessageList___"] > ol > li[class*="MessageList__unread___"]::before {
[class*="MessageList__MessageList___"]
> ol
> li[class*="MessageList__unread___"]::before {
width: 3px;
}
.connectedNotificationsWrapper > div > button {
@@ -2283,9 +2461,13 @@ body {
background: var(--background-secondary);
}
[class*="MessageList__MessageList___"] > ol > li[class*="MessageList__selected___"] {
[class*="MessageList__MessageList___"]
> ol
> li[class*="MessageList__selected___"] {
background: rgb(228 225 225);
color: var(--text-primary);
box-shadow: none !important;
position: relative;
}
.NewsArticle {
border-radius: 16px !important;
@@ -2429,21 +2611,17 @@ body {
}
[data-label="inbox"] > [class*="LabelList__name___"]::before {
content: "\eb70";
/* Unicode for the search icon */
color: currentColor;
font-size: 16px;
margin-right: 8px;
/* Adjusted to margin-right for the icon to be on the left */
font-family: "IconFamily";
pointer-events: none;
}
[data-label="outbox"] > [class*="LabelList__name___"]::before {
content: "\eca6";
/* Unicode for the search icon */
color: currentColor;
font-size: 16px;
margin-right: 8px;
/* Adjusted to margin-right for the icon to be on the left */
font-family: "IconFamily";
pointer-events: none;
}
@@ -2452,17 +2630,14 @@ body {
color: currentColor;
font-size: 16px;
margin-right: 8px;
/* Adjusted to margin-right for the icon to be on the left */
font-family: "IconFamily";
pointer-events: none;
}
[data-label="trash"] > [class*="LabelList__name___"]::before {
content: "\ed2c";
/* Unicode for the search icon */
color: currentColor;
font-size: 16px;
margin-right: 8px;
/* Adjusted to margin-right for the icon to be on the left */
font-family: "IconFamily";
pointer-events: none;
}
@@ -3350,3 +3525,13 @@ body {
}
}
}
.noscroll * {
-ms-overflow-style: none;
scrollbar-width: none !important;
}
#menu ul {
-ms-overflow-style: none;
scrollbar-width: none !important;
}
+3 -1
View File
@@ -36,5 +36,7 @@
transform-origin: 70% 0;
will-change: opacity, transform;
transform: translateZ(0); // promotes GPU rendering
transition: opacity 0.05s, transform 0.05s;
transition:
opacity 0.05s,
transform 0.05s;
}
+2 -2
View File
@@ -8,7 +8,6 @@ html.transparencyEffects:not(.dark) {
--background-secondary: rgba(229, 231, 235, 0.6);
}
html.transparencyEffects {
/* Background Fixes */
[class*="notifications__item___"],
@@ -37,7 +36,8 @@ html.transparencyEffects {
[class*="LabelList__selected___"],
.buttonChecklist,
.pane,
.legacy-root button, .legacy-root a,
.legacy-root button,
.legacy-root a,
[class*="MessageList__MessageList___"] {
backdrop-filter: blur(80px);
}
+11 -6
View File
@@ -1,9 +1,14 @@
declare module '*.mp4';
declare module '*.woff';
declare module '*.scss';
declare module '*.png';
declare module '*.html';
declare module '*.svelte';
declare module "*.mp4";
declare module "*.woff";
declare module "*.scss";
declare module "*.png";
declare module "*.html";
declare module "*.svelte";
declare module "*?inlineWorker" {
const value: () => Worker;
export default value;
}
declare module "*.png?base64" {
const value: string;
+1 -1
View File
@@ -2,6 +2,6 @@
let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>();
</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}
</button>
+3 -3
View File
@@ -81,20 +81,20 @@
</script>
{#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} />
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
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}
onkeydown={(e) => { e.key === 'Enter' && handleBackgroundClick }}
>
<div
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} />
</div>
+42 -27
View File
@@ -1,6 +1,6 @@
import ColorPicker from "react-best-gradient-color-picker"
import { useEffect, useRef, useState } from "react"
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import ColorPicker from "react-best-gradient-color-picker";
import { useEffect, useRef, useState } from "react";
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts";
const defaultPresets = [
"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(134, 25, 143, 1)",
"rgba(14, 165, 233, 0.9)",
]
];
interface PickerProps {
customOnChange?: (color: string) => void
customState?: string
savePresets?: boolean
customOnChange?: (color: string) => void;
customState?: string;
savePresets?: boolean;
}
export default function Picker({
@@ -35,32 +35,44 @@ export default function Picker({
customState,
savePresets = true,
}: PickerProps) {
const [customThemeColor, setCustomThemeColor] = useState<string | null>()
const [presets, setPresets] = useState<string[]>()
const [customThemeColor, setCustomThemeColor] = useState<string | null>();
const [presets, setPresets] = useState<string[]>();
const latestValuesRef = useRef({ customThemeColor, customOnChange, savePresets, presets });
const latestValuesRef = useRef({
customThemeColor,
customOnChange,
savePresets,
presets,
});
useEffect(() => {
if (customState !== undefined && customState !== null) {
setCustomThemeColor(customState)
setCustomThemeColor(customState);
} else {
setCustomThemeColor(settingsState.selectedColor ?? null)
setCustomThemeColor(settingsState.selectedColor ?? null);
}
if (presets === undefined) {
const savedPresets = localStorage.getItem("colorPickerPresets")
setPresets(savedPresets ? JSON.parse(savedPresets) : defaultPresets)
const savedPresets = localStorage.getItem("colorPickerPresets");
setPresets(savedPresets ? JSON.parse(savedPresets) : defaultPresets);
}
}, [])
}, []);
useEffect(() => {
latestValuesRef.current = { customThemeColor, customOnChange, savePresets, presets };
latestValuesRef.current = {
customThemeColor,
customOnChange,
savePresets,
presets,
};
}, [customThemeColor, customOnChange, savePresets, presets]);
useEffect(() => {
return () => {
const { customThemeColor, customOnChange, savePresets, presets } = latestValuesRef.current;
if (!(customThemeColor && !customOnChange && savePresets && presets)) return;
const { customThemeColor, customOnChange, savePresets, presets } =
latestValuesRef.current;
if (!(customThemeColor && !customOnChange && savePresets && presets))
return;
// Only proceed if presets are different (avoid unnecessary updates)
const existingIndex = presets.indexOf(customThemeColor);
@@ -79,15 +91,18 @@ export default function Picker({
updatedPresets = [customThemeColor, ...presets].slice(0, 18);
}
localStorage.setItem("colorPickerPresets", JSON.stringify(updatedPresets));
}
}, [])
localStorage.setItem(
"colorPickerPresets",
JSON.stringify(updatedPresets),
);
};
}, []);
useEffect(() => {
if (customThemeColor && !customOnChange) {
settingsState.selectedColor = customThemeColor
settingsState.selectedColor = customThemeColor;
}
}, [customThemeColor, customOnChange])
}, [customThemeColor, customOnChange]);
return (
<ColorPicker
@@ -97,12 +112,12 @@ export default function Picker({
value={customThemeColor ?? ""}
onChange={(color: string) => {
if (customOnChange) {
customOnChange(color)
setCustomThemeColor(color)
customOnChange(color);
setCustomThemeColor(color);
} 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>
<button
aria-label="Color Picker Swatch"
onclick={onClick}
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>
+3 -1
View File
@@ -8,11 +8,12 @@
let select: HTMLSelectElement;
</script>
<div class="border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 shadow-2xl rounded-lg w-full overflow-clip">
<select
bind:this={select}
value={state}
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}
<option value={option.value}>
@@ -20,3 +21,4 @@
</option>
{/each}
</select>
</div>
+3 -2
View File
@@ -16,9 +16,9 @@
max={max}
step={step}
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))}
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>
@@ -38,6 +38,7 @@
height: 24px;
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.3);
background: white;
color: #30d259ad;
cursor: pointer;
border-radius: 50%;
}
+1 -1
View File
@@ -1,4 +1,4 @@
.dark .switch[data-ison="true"],
.switch[data-ison="true"] {
background-color: #30D259;
background-color: #30d259;
}
+1 -8
View File
@@ -30,8 +30,7 @@
</script>
<div
class="flex w-14 p-1 cursor-pointer transition-all duration-150 rounded-full dark:bg-[#38373D] bg-[#DDDDDD] switch select-none"
data-ison={state}
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]'}"
onclick={() => onChange(!state)}
onkeydown={(e) => e.key === "Enter" && onChange(!state)}
role="switch"
@@ -43,9 +42,3 @@
class="w-6 h-6 bg-white dark:bg-[#FEFEFE] rounded-full drop-shadow-md"
></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 bind:this={containerRef} class="flex relative">
<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) }}
transition={springTransition}
/>
@@ -65,8 +65,9 @@
>
<div class="flex">
{#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}%;">
<div style="left: {index * 100}%;" class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
<Content {...props} />
</div>
{/each}
@@ -2,7 +2,7 @@
import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader';
import Spinner from '../Spinner.svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import Fuse from 'fuse.js';
import { Index } from 'flexsearch';
import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
@@ -20,19 +20,12 @@
let savedBackgrounds = $state<string[]>([]);
let installingBackgrounds = $state<Set<string>>(new Set());
let debugInfo = $state<string>('');
let searchIndex = $state<Index | null>(null);
// New state variables
let activeTab = $state<'all' | 'installed' | 'photos' | 'videos'>('all');
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
const loadStore = async () => {
try {
@@ -43,7 +36,19 @@
}
const data = await response.json();
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`;
await loadSavedBackgrounds();
} catch (e) {
@@ -74,14 +79,10 @@
let filteredBackgrounds = $derived((() => {
let filtered = backgrounds;
// Use Fuse.js search if there's a search term
if (searchTerm.trim()) {
// @ts-ignore
if (fuse) {
filtered = fuse.search(searchTerm).map((result: any) => result.item) ?? [];
} else {
filtered = backgrounds.filter(bg => bg.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
// Use FlexSearch if there's a search term
if (searchTerm.trim() && searchIndex) {
const results = searchIndex.search(searchTerm) as number[];
filtered = results.map(i => backgrounds[i]);
}
// Apply category filtering
@@ -14,12 +14,14 @@
let isDragging = $state(false);
let tempTheme = $state(null);
const handleThemeClick = async (theme: CustomTheme) => {
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
if (isEditMode) return;
if (theme.id === themes?.selectedTheme) {
themeManager.setTransitionPoint(e.clientX, e.clientY);
await themeManager.disableTheme();
themes.selectedTheme = '';
} else {
themeManager.setTransitionPoint(e.clientX, e.clientY);
await themeManager.setTheme(theme.id);
if (!themes) return;
themes.selectedTheme = theme.id;
@@ -127,7 +129,7 @@
{#each themes.themes as theme (theme.id)}
<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'}"
onclick={() => handleThemeClick(theme)}
onclick={(e) => handleThemeClick(theme, e)}
>
{#if isEditMode}
<div
+25 -15
View File
@@ -1,4 +1,4 @@
import { type DBSchema, type IDBPDatabase, openDB } from 'idb';
import { type DBSchema, type IDBPDatabase, openDB } from "idb";
interface BackgroundDB extends DBSchema {
backgrounds: {
@@ -16,38 +16,46 @@ let db: IDBPDatabase<BackgroundDB> | null = null;
export async function openDatabase(): Promise<IDBPDatabase<BackgroundDB>> {
if (db) return db;
db = await openDB<BackgroundDB>('BackgroundDB', 1, {
db = await openDB<BackgroundDB>("BackgroundDB", 1, {
upgrade(db: IDBPDatabase<BackgroundDB>) {
db.createObjectStore('backgrounds', { keyPath: 'id' });
db.createObjectStore("backgrounds", { keyPath: "id" });
},
});
return db;
}
export async function readAllData(): Promise<Array<{ id: string; type: string; blob: Blob }>> {
export async function readAllData(): Promise<
Array<{ id: string; type: string; blob: Blob }>
> {
const db = await openDatabase();
return db.getAll('backgrounds');
return db.getAll("backgrounds");
}
export async function writeData(id: string, type: string, blob: Blob): Promise<void> {
export async function writeData(
id: string,
type: string,
blob: Blob,
): Promise<void> {
const db = await openDatabase();
await db.put('backgrounds', { id, type, blob });
await db.put("backgrounds", { id, type, blob });
}
export async function deleteData(id: string): Promise<void> {
const db = await openDatabase();
await db.delete('backgrounds', id);
await db.delete("backgrounds", id);
}
export async function clearAllData(): Promise<void> {
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> {
export async function getDataById(
id: string,
): Promise<{ id: string; type: string; blob: Blob } | undefined> {
const db = await openDatabase();
return db.get('backgrounds', id);
return db.get("backgrounds", id);
}
export function closeDatabase(): void {
@@ -59,15 +67,17 @@ export function closeDatabase(): void {
// Helper function to check if IndexedDB is supported
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> {
if ('storage' in navigator && 'estimate' in navigator.storage) {
export async function hasEnoughStorageSpace(
requiredSpace: number,
): Promise<boolean> {
if ("storage" in navigator && "estimate" in navigator.storage) {
const { quota, usage } = await navigator.storage.estimate();
if (quota !== undefined && usage !== undefined) {
return (quota - usage) > requiredSpace;
return quota - usage > requiredSpace;
}
}
// If we can't determine, assume there's enough space
+1 -1
View File
@@ -22,7 +22,7 @@ class BackgroundUpdates {
}
public triggerUpdate(): void {
this.listeners.forEach(callback => callback());
this.listeners.forEach((callback) => callback());
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ class SettingsPopup {
}
public triggerClose(): void {
this.listeners.forEach(callback => callback());
this.listeners.forEach((callback) => callback());
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ class ThemeUpdates {
}
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 components;
+1 -1
View File
@@ -6,7 +6,7 @@
<title>BetterSEQTA+ Settings</title>
</head>
<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>
</body>
</html>
+15 -15
View File
@@ -1,29 +1,29 @@
import "./index.css"
import Settings from "./pages/settings.svelte"
import IconFamily from '@/resources/fonts/IconFamily.woff'
import browser from "webextension-polyfill"
import renderSvelte from "./main"
import "./index.css";
import Settings from "./pages/settings.svelte";
import IconFamily from "@/resources/fonts/IconFamily.woff";
import browser from "webextension-polyfill";
import renderSvelte from "./main";
function InjectCustomIcons() {
console.info('[BetterSEQTA+] Injecting Icons')
console.info("[BetterSEQTA+] Injecting Icons");
const style = document.createElement('style')
style.setAttribute('type', 'text/css')
const style = document.createElement("style");
style.setAttribute("type", "text/css");
style.innerHTML = `
@font-face {
font-family: 'IconFamily';
src: url('${browser.runtime.getURL(IconFamily)}') format('woff');
font-weight: normal;
font-style: normal;
}`
document.head.appendChild(style)
}`;
document.head.appendChild(style);
}
const mountPoint = document.getElementById('app')
const mountPoint = document.getElementById("app");
if (!mountPoint) {
console.error('Mount point #app not found')
throw new Error('Mount point #app not found')
console.error("Mount point #app not found");
throw new Error("Mount point #app not found");
}
InjectCustomIcons()
renderSvelte(Settings, mountPoint, { standalone: true })
InjectCustomIcons();
renderSvelte(Settings, mountPoint, { standalone: true });
+1 -1
View File
@@ -1,4 +1,4 @@
import './index.css';
import "./index.css";
declare module "*.png";
declare module "*.svg";
+9 -9
View File
@@ -1,9 +1,9 @@
import { mount } from "svelte"
import type { ComponentType } from "svelte"
import style from './index.css?inline'
import { mount } from "svelte";
import type { SvelteComponent } from "svelte";
import style from "./index.css?inline";
export default function renderSvelte(
Component: ComponentType | any,
Component: SvelteComponent | any,
mountPoint: ShadowRoot | HTMLElement,
props: Record<string, any> = {},
) {
@@ -13,11 +13,11 @@ export default function renderSvelte(
standalone: false,
...props,
},
})
});
const styleElement = document.createElement('style')
styleElement.textContent = style
mountPoint.appendChild(styleElement)
const styleElement = document.createElement("style");
styleElement.textContent = style;
mountPoint.appendChild(styleElement);
return app
return app;
}
+1 -2
View File
@@ -59,14 +59,13 @@
if (!standalone) return;
initializeSettingsState();
console.log('settingsState', $settingsState);
StandaloneStore.setStandalone(true);
});
</script>
<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="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_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} />
+39 -7
View File
@@ -3,6 +3,7 @@
import Button from "../../components/Button.svelte"
import Slider from "../../components/Slider.svelte"
import Select from "@/interface/components/Select.svelte"
import HotkeyInput from "@/interface/components/HotkeyInput.svelte"
import browser from "webextension-polyfill"
@@ -12,7 +13,7 @@
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent"
import { getAllPluginSettings } from "@/plugins"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting } from "@/plugins/core/types"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting } from "@/plugins/core/types"
// Union type representing all possible settings
type SettingType =
@@ -23,12 +24,21 @@
type: 'select',
id: string,
options: string[]
}) |
(Omit<ButtonSetting, 'type'> & {
type: 'button',
id: string
}) |
(Omit<HotkeySetting, 'type'> & {
type: 'hotkey',
id: string
});
interface Plugin {
pluginId: string;
name: string;
description: string;
beta?: boolean;
settings: Record<string, SettingType>;
}
@@ -45,7 +55,7 @@
pluginSettingsValues[plugin.pluginId] = stored[storageKey] || {};
for (const [key, setting] of Object.entries(plugin.settings)) {
if (pluginSettingsValues[plugin.pluginId][key] === undefined) {
if (pluginSettingsValues[plugin.pluginId][key] === undefined && setting.type !== 'button') {
pluginSettingsValues[plugin.pluginId][key] = setting.default;
}
}
@@ -184,12 +194,20 @@
{/each}
{#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 -->
{#if (plugin as any).disableToggle}
<div class="flex justify-between items-center px-4 py-3">
<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>
</div>
<div>
@@ -201,7 +219,6 @@
</div>
{/if}
<!-- Only show other settings if plugin is enabled or has no disableToggle -->
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]}
<!-- Skip the 'enabled' setting if it's part of the settings object -->
@@ -228,7 +245,7 @@
{:else if setting.type === 'string'}
<input
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}
oninput={(e) => updatePluginSetting(plugin.pluginId, key, e.currentTarget.value)}
/>
@@ -241,6 +258,16 @@
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)}
/>
{/if}
</div>
</div>
@@ -248,8 +275,11 @@
{/each}
{/if}
</div>
</div>
{/each}
<div class="p-1 border-none"></div>
{@render Setting({
title: "BetterSEQTA+",
description: "Enables BetterSEQTA+ features",
@@ -262,7 +292,8 @@
})}
{#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">
<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>
@@ -283,5 +314,6 @@
/>
</div>
</div>
</div>
{/if}
</div>
+21 -19
View File
@@ -3,6 +3,7 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import Switch from "@/interface/components/Switch.svelte"
import { onMount } from 'svelte';
import Shortcuts from "@/seqta/content/links.json"
let isLoaded = $state(false);
@@ -21,10 +22,14 @@
});
});
const switchChange = (index: number) => {
const updatedShortcuts = [...settingsState.shortcuts];
updatedShortcuts[index].enabled = !updatedShortcuts[index].enabled;
settingsState.shortcuts = updatedShortcuts;
const switchChange = (shortcut: any) => {
const value = $settingsState.shortcuts.find(s => s.name === shortcut);
if (value) {
value.enabled = !value.enabled;
settingsState.shortcuts = settingsState.shortcuts;
} else {
settingsState.shortcuts = [...settingsState.shortcuts, { name: shortcut, enabled: true }];
}
}
let isFormVisible = $state(false);
@@ -65,15 +70,6 @@
};
</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">
{#if isLoaded}
<div>
@@ -95,7 +91,7 @@
class="w-full"
>
<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"
placeholder="Shortcut Name"
bind:value={newTitle}
@@ -108,7 +104,7 @@
class="w-full"
>
<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"
placeholder="URL eg. https://google.com"
bind:value={newURL}
@@ -136,15 +132,21 @@
</MotionDiv>
</div>
{#each Object.entries($settingsState.shortcuts) as shortcut}
{@render Shortcuts(shortcut)}
{#each Object.entries(Shortcuts) as shortcut}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<!-- Use DisplayName if it exists, otherwise use the key (shortcut[0]) as a fallback -->
<h2 class="text-sm">{shortcut[1].DisplayName || shortcut[0]}</h2>
</div>
<Switch state={$settingsState.shortcuts.find(s => s.name === shortcut[0])?.enabled ?? false} onChange={() => switchChange(shortcut[0])} />
</div>
{/each}
<!-- Custom Shortcuts Section -->
{#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}
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
+1 -1
View File
@@ -2,6 +2,6 @@ export interface SettingsList {
title: string;
id: number;
description: string;
Component: any; /* TODO: Give this a type */
Component: any /* TODO: Give this a type */;
props?: any;
}
+1 -1
View File
@@ -16,7 +16,7 @@ export class Standalone {
public setStandalone(value: boolean) {
this._standalone = value;
this.subscribers.forEach(subscriber => subscriber(value));
this.subscribers.forEach((subscriber) => subscriber(value));
}
public get standalone() {
+31 -11
View File
@@ -1,23 +1,31 @@
import type { LoadedCustomTheme } from '@/types/CustomThemes';
import type { LoadedCustomTheme } from "@/types/CustomThemes";
export function generateImageId(): string {
return Math.random().toString(36).substr(2, 9);
}
export function handleImageUpload(event: Event, theme: LoadedCustomTheme): Promise<LoadedCustomTheme> | LoadedCustomTheme {
export function handleImageUpload(
event: Event,
theme: LoadedCustomTheme,
): Promise<LoadedCustomTheme> | LoadedCustomTheme {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
input.value = "";
if (file) {
return new Promise((resolve) => {
const reader = new FileReader();
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 variableName = `custom-image-${theme.CustomImages.length}`;
resolve({
...theme,
CustomImages: [...theme.CustomImages, { id: imageId, blob: imageBlob, variableName, url: null }],
CustomImages: [
...theme.CustomImages,
{ id: imageId, blob: imageBlob, variableName, url: null },
],
});
};
reader.readAsDataURL(file);
@@ -26,31 +34,43 @@ export function handleImageUpload(event: Event, theme: LoadedCustomTheme): Promi
return theme;
}
export function handleRemoveImage(imageId: string, theme: LoadedCustomTheme): LoadedCustomTheme {
export function handleRemoveImage(
imageId: string,
theme: LoadedCustomTheme,
): LoadedCustomTheme {
return {
...theme,
CustomImages: theme.CustomImages.filter((image) => image.id !== imageId),
} as LoadedCustomTheme;
}
export function handleImageVariableChange(imageId: string, variableName: string, theme: LoadedCustomTheme): LoadedCustomTheme {
export function handleImageVariableChange(
imageId: string,
variableName: string,
theme: LoadedCustomTheme,
): LoadedCustomTheme {
return {
...theme,
CustomImages: theme.CustomImages.map((image) =>
image.id === imageId ? { ...image, variableName } : image
image.id === imageId ? { ...image, variableName } : image,
),
} as LoadedCustomTheme;
}
export function handleCoverImageUpload(event: Event, theme: LoadedCustomTheme): Promise<LoadedCustomTheme> {
export function handleCoverImageUpload(
event: Event,
theme: LoadedCustomTheme,
): Promise<LoadedCustomTheme> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
input.value = "";
if (file) {
return new Promise((resolve) => {
const reader = new FileReader();
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 });
};
reader.readAsDataURL(file);
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
export const brave = createManifest({
export const brave = createManifest(
{
...baseManifest,
version: pkg.version,
description: pkg.description,
}, 'brave')
},
"brave",
);
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
export const chrome = createManifest({
export const chrome = createManifest(
{
...baseManifest,
version: pkg.version,
description: pkg.description,
}, 'chrome')
},
"chrome",
);
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
export const edge = createManifest({
export const edge = createManifest(
{
...baseManifest,
version: pkg.version,
description: pkg.description,
}, 'edge')
},
"edge",
);
+7 -7
View File
@@ -1,6 +1,6 @@
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
const updatedFirefoxManifest = {
...baseManifest,
@@ -10,13 +10,13 @@ const updatedFirefoxManifest = {
scripts: [baseManifest.background.service_worker],
},
action: {
"default_popup": "interface/index.html#settings",
default_popup: "interface/index.html#settings",
},
browser_specific_settings: {
gecko: {
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": [
{
"resources": ["*/*"],
"matches": ["*://*/*"]
},
{
"resources": ["resources/*"],
"matches": ["*://*/*"]
},
{
"resources": ["seqta/utils/migration/migrate.html"],
"resources": ["resources/icons/*"],
"matches": ["*://*/*"]
}
]
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
export const opera = createManifest({
export const opera = createManifest(
{
...baseManifest,
version: pkg.version,
description: pkg.description,
}, 'opera')
},
"opera",
);
+7 -7
View File
@@ -1,6 +1,6 @@
import { createManifest } from '../../lib/createManifest'
import baseManifest from './manifest.json'
import pkg from '../../package.json'
import { createManifest } from "../../lib/createManifest";
import baseManifest from "./manifest.json";
import pkg from "../../package.json";
const updatedSafariManifest = {
...baseManifest,
@@ -8,12 +8,12 @@ const updatedSafariManifest = {
description: pkg.description,
browser_specific_settings: {
safari: {
strict_min_version: '15.4',
strict_max_version: '*',
strict_min_version: "15.4",
strict_max_version: "*",
},
// ^^^ 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
},
}
};
export const safari = createManifest(updatedSafariManifest, 'safari')
export const safari = createManifest(updatedSafariManifest, "safari");
+57 -35
View File
@@ -3,8 +3,8 @@ class ReactFiber {
this.selector = selector;
this.debug = options.debug || false;
this.nodes = [...document.querySelectorAll(selector)]; // Support multiple elements
this.fibers = this.nodes.map(node => this.getFiberNode(node));
this.components = this.fibers.map(fiber => this.getOwnerComponent(fiber));
this.fibers = this.nodes.map((node) => this.getFiberNode(node));
this.components = this.fibers.map((fiber) => this.getOwnerComponent(fiber));
if (this.debug) {
console.log("Selected Nodes:", this.nodes);
@@ -19,8 +19,10 @@ class ReactFiber {
getFiberNode(node) {
if (!node) return null;
const fiberKey = Object.getOwnPropertyNames(node).find(name =>
name.startsWith('__reactFiber') || name.startsWith('__reactInternalInstance')
const fiberKey = Object.getOwnPropertyNames(node).find(
(name) =>
name.startsWith("__reactFiber") ||
name.startsWith("__reactInternalInstance"),
);
return fiberKey ? node[fiberKey] : null;
}
@@ -28,7 +30,10 @@ class ReactFiber {
getOwnerComponent(fiberNode) {
let current = fiberNode;
while (current) {
if (current.stateNode && (current.stateNode.setState || current.stateNode.forceUpdate)) {
if (
current.stateNode &&
(current.stateNode.setState || current.stateNode.forceUpdate)
) {
return current.stateNode;
}
current = current.return;
@@ -42,7 +47,7 @@ class ReactFiber {
if (key === undefined) {
return state;
} else if (typeof key === 'string') {
} else if (typeof key === "string") {
return state?.[key];
} else if (Array.isArray(key)) {
const filteredState = {};
@@ -57,23 +62,25 @@ class ReactFiber {
}
setState(update) {
this.components.forEach(component => {
this.components.forEach((component) => {
if (component?.setState) {
if (typeof update === 'function') {
if (typeof update === "function") {
// Functional update
component.setState(prevState => {
component.setState((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;
});
} else {
// Object update (merge with existing state)
component.setState(prevState => {
component.setState((prevState) => {
const newState = {
...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;
});
}
@@ -93,7 +100,7 @@ class ReactFiber {
}
setProp(propName) {
this.fibers.forEach(fiber => {
this.fibers.forEach((fiber) => {
if (fiber?.memoizedProps) {
fiber.memoizedProps[propName] = value;
}
@@ -102,7 +109,7 @@ class ReactFiber {
}
forceUpdate() {
this.components.forEach(component => {
this.components.forEach((component) => {
if (component?.forceUpdate) {
component.forceUpdate();
if (this.debug) console.log("🔄 Forced React Re-render");
@@ -113,12 +120,12 @@ class ReactFiber {
}
function makeSerializable(obj) {
if (typeof obj !== 'object' || obj === null) {
if (typeof obj !== "object" || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(item => makeSerializable(item));
return obj.map((item) => makeSerializable(item));
}
const serializableObj = {};
@@ -126,17 +133,17 @@ function makeSerializable(obj) {
if (Object.hasOwn(obj, key)) {
let value = obj[key];
if (typeof value === 'function') {
value = '[Function]';
if (typeof value === "function") {
value = "[Function]";
} else if (value instanceof HTMLElement) {
value = {
type: 'HTMLElement',
type: "HTMLElement",
id: value.id,
tagName: value.tagName
tagName: value.tagName,
}; // Replace DOM node with ID/tag info
} else if (typeof value === 'symbol') {
} else if (typeof value === "symbol") {
value = value.toString();
} else if (typeof value === 'object' && value !== null) {
} else if (typeof value === "object" && value !== null) {
value = makeSerializable(value);
}
@@ -146,17 +153,11 @@ function makeSerializable(obj) {
return serializableObj;
}
window.addEventListener('message', (event) => {
window.addEventListener("message", (event) => {
if (event.data.type === "reactFiberRequest") {
const {
selector,
action,
payload,
debug,
messageId
} = event.data;
const { selector, action, payload, debug, messageId } = event.data;
const fiberInstance = ReactFiber.find(selector, {
debug
debug,
});
let response;
@@ -191,14 +192,35 @@ window.addEventListener('message', (event) => {
response = null;
}
if (response !== null && typeof response === 'object') {
if (response !== null && typeof response === "object") {
response = makeSerializable(response);
}
window.postMessage({
window.postMessage(
{
type: "reactFiberResponse",
response,
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 { type Plugin } from '@/plugins/core/types';
import { defineSettings, numberSetting, Setting } from '@/plugins/core/settingsHelpers';
import styles from './styles.css?inline';
import { BasePlugin } from "../../core/settings";
import { type Plugin } from "@/plugins/core/types";
import {
defineSettings,
numberSetting,
Setting,
} from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline";
const settings = defineSettings({
speed: numberSetting({
@@ -10,8 +14,8 @@ const settings = defineSettings({
description: "Controls how fast the background moves",
min: 0.1,
max: 2,
step: 0.05
})
step: 0.05,
}),
});
class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
@@ -22,10 +26,10 @@ class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
const instance = new AnimatedBackgroundPluginClass();
const animatedBackgroundPlugin: Plugin<typeof settings> = {
id: 'animated-background',
name: 'Animated Background',
description: 'Adds an animated background to BetterSEQTA+',
version: '1.0.0',
id: "animated-background",
name: "Animated Background",
description: "Adds an animated background to BetterSEQTA+",
version: "1.0.0",
disableToggle: true,
styles: styles,
settings: instance.settings,
@@ -42,12 +46,12 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
const backgrounds = [
{ classes: ["bg"] },
{ classes: ["bg", "bg2"] },
{ classes: ["bg", "bg3"] }
{ classes: ["bg", "bg3"] },
];
backgrounds.forEach(({ classes }) => {
const bk = document.createElement("div");
classes.forEach(cls => bk.classList.add(cls));
classes.forEach((cls) => bk.classList.add(cls));
container.insertBefore(bk, menu);
});
@@ -55,20 +59,23 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
updateAnimationSpeed(api.settings.speed);
// Listen for speed changes
const speedUnregister = api.settings.onChange('speed', updateAnimationSpeed);
const speedUnregister = api.settings.onChange(
"speed",
updateAnimationSpeed,
);
// Return cleanup function
return () => {
speedUnregister.unregister();
// Remove background elements
const backgrounds = document.getElementsByClassName('bg');
Array.from(backgrounds).forEach(element => element.remove());
const backgrounds = document.getElementsByClassName("bg");
Array.from(backgrounds).forEach((element) => element.remove());
};
}
},
};
function updateAnimationSpeed(speed: number) {
const bgElements = document.getElementsByClassName('bg');
const bgElements = document.getElementsByClassName("bg");
Array.from(bgElements).forEach((element, index) => {
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`;
@@ -13,12 +13,12 @@ export function CreateBackground() {
const backgrounds = [
{ classes: ["bg"] },
{ classes: ["bg", "bg2"] },
{ classes: ["bg", "bg3"] }
{ classes: ["bg", "bg3"] },
];
backgrounds.forEach(({ classes }) => {
const bk = document.createElement("div");
classes.forEach(cls => bk.classList.add(cls));
classes.forEach((cls) => bk.classList.add(cls));
container.insertBefore(bk, menu);
});
}
@@ -2,5 +2,5 @@ export function RemoveBackground() {
const backgrounds = document.getElementsByClassName("bg");
// 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 { booleanSetting, defineSettings, Setting } from "@/plugins/core/settingsHelpers";
import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm";
@@ -8,7 +12,7 @@ const settings = defineSettings({
lettergrade: booleanSetting({
default: false,
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___']",
true,
10,
1000
1000,
);
// 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
const classes = Array.from(element.querySelectorAll('*'))
.flatMap(el => Array.from(el.classList))
.filter(className => className.startsWith(basePattern));
const classes = Array.from(element.querySelectorAll("*"))
.flatMap((el) => Array.from(el.classList))
.filter((className) => className.startsWith(basePattern));
return classes.length ? classes[0] : '';
return classes.length ? classes[0] : "";
};
// Find actual class names from the DOM
const sampleAssessmentItem = document.querySelector("[class*='AssessmentItem__AssessmentItem___']");
const sampleAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___']",
);
if (!sampleAssessmentItem) return;
// Extract all necessary class patterns from a sample assessment item
const assessmentItemClass = Array.from(sampleAssessmentItem.classList)
.find(c => c.startsWith('AssessmentItem__AssessmentItem___')) || '';
const assessmentItemClass =
Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"),
) || "";
const metaContainerClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__metaContainer___');
const metaClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__meta___');
const simpleResultClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__simpleResult___');
const titleClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__title___');
const metaContainerClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__metaContainer___",
);
const metaClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__meta___",
);
const simpleResultClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__simpleResult___",
);
const titleClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__title___",
);
// Get Thermoscore classes
const thermoscoreElement = document.querySelector("[class*='Thermoscore__Thermoscore___']");
const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
if (!thermoscoreElement) return;
const thermoscoreClass = Array.from(thermoscoreElement.classList)
.find(c => c.startsWith('Thermoscore__Thermoscore___')) || '';
const fillClass = getClassByPattern(thermoscoreElement, 'Thermoscore__fill___');
const textClass = getClassByPattern(thermoscoreElement, 'Thermoscore__text___');
const thermoscoreClass =
Array.from(thermoscoreElement.classList).find((c) =>
c.startsWith("Thermoscore__Thermoscore___"),
) || "";
const fillClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__fill___",
);
const textClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__text___",
);
// 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;
const gradeElements = document.querySelectorAll("[class*='Thermoscore__text___']");
const gradeElements = document.querySelectorAll(
"[class*='Thermoscore__text___']",
);
if (!gradeElements.length) return;
// Parse and average grades
const letterToNumber: Record<string, number> = {
"A+": 100, A: 95, "A-": 90,
"B+": 85, B: 80, "B-": 75,
"C+": 70, C: 65, "C-": 60,
"D+": 55, D: 50, "D-": 45,
"E+": 40, E: 35, "E-": 30,
"A+": 100,
A: 95,
"A-": 90,
"B+": 85,
B: 80,
"B-": 75,
"C+": 70,
C: 65,
"C-": 60,
"D+": 55,
D: 50,
"D-": 45,
"E+": 40,
E: 35,
"E-": 30,
F: 0,
};
function parseGrade(text: string): number {
const str = text.trim().toUpperCase();
if (str.includes("/")) {
const [raw, max] = str.split("/").map(n => parseFloat(n));
const [raw, max] = str.split("/").map((n) => parseFloat(n));
return (raw / max) * 100;
}
if (str.includes("%")) {
@@ -112,16 +159,23 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
const avg = total / count;
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;
return acc;
}, {} as Record<number, string>);
},
{} as Record<number, string>,
);
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
const existing = assessmentsList.querySelector(`[class*='AssessmentItem__title___']`);
const existing = assessmentsList.querySelector(
`[class*='AssessmentItem__title___']`,
);
if (existing?.textContent === "Subject Average") return;
// Use the dynamic class names in the HTML template
@@ -144,7 +198,7 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild);
});
}
},
};
export default assessmentsAveragePlugin;
@@ -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,423 @@
<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
}>();
// Make searchHotkey reactive to setting changes
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, 10);
$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,184 @@
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();
// 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,99 @@
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 },
) {
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);
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 }) {
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();
// Remove storage listener
browser.storage.onChanged.removeListener(() => {});
}
@@ -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,202 @@
const DB_NAME = "betterseqta-index";
const META_STORE = "meta";
const VERSION_KEY = "betterseqta-index-version";
let dbPromise: Promise<IDBDatabase> | null = null;
// Get the current version from localStorage or start at 1
function getCurrentVersion(): number {
const storedVersion = localStorage.getItem(VERSION_KEY);
return storedVersion ? parseInt(storedVersion, 10) : 1;
}
// Update the version in localStorage
function updateVersion(version: number) {
localStorage.setItem(VERSION_KEY, version.toString());
}
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
const currentVersion = getCurrentVersion();
dbPromise = new Promise((resolve, reject) => {
let request: IDBOpenDBRequest;
try {
request = indexedDB.open(DB_NAME, currentVersion);
} catch (e) {
// If there's a version error, try to delete the database and start fresh
console.warn("Database version conflict, recreating database...");
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);
// Always ensure META_STORE exists
if (!existingStores.includes(META_STORE)) {
db.createObjectStore(META_STORE);
}
// Update version in localStorage to match the database
updateVersion(event.newVersion || 1);
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => {
console.error("Error opening database:", request.error);
// If there's an error, try to recover by deleting and recreating
indexedDB.deleteDatabase(DB_NAME);
localStorage.removeItem(VERSION_KEY);
reject(request.error);
};
});
return dbPromise;
}
async function getStore(store: string, mode: IDBTransactionMode = "readonly") {
const db = await openDB();
// Create store dynamically if needed
if (!db.objectStoreNames.contains(store)) {
db.close();
await upgradeDB(store);
return getStore(store, mode);
}
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;
// Close any existing connections
if (dbPromise) {
dbPromise.then((db) => db.close());
dbPromise = null;
}
const request = indexedDB.open(DB_NAME, newVersion);
request.onupgradeneeded = (event) => {
const db = request.result;
if (!db.objectStoreNames.contains(newStore)) {
db.createObjectStore(newStore);
}
// Update version in localStorage
updateVersion(event.newVersion || newVersion);
};
request.onsuccess = () => {
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;
}
}
// Helper function to reset the database if needed
export async function resetDatabase(): Promise<void> {
if (dbPromise) {
const db = await dbPromise;
db.close();
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,413 @@
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";
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}`,
);
}
if (!hasStreamingJobs) {
const allItemsInPrimaryStores = await loadAllStoredItems();
if (allItemsInPrimaryStores.length > 0) {
console.debug(
`%c[Indexer] Sending ${allItemsInPrimaryStores.length} items from primary stores to worker for vectorization check...`,
"color: #4ea1ff",
);
dispatchProgress(completedJobs, totalSteps, true, "Starting vectorization of stored items");
try {
const workerManager = VectorWorkerManager.getInstance();
await workerManager.processItems(allItemsInPrimaryStores, (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] No items found in primary stores to send for vectorization.",
"color: gray",
);
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Indexing finished (no items for vectorization)",
);
}
} else {
console.debug(
"%c[Indexer] Skipping bulk vectorization - streaming jobs will handle vectorization",
"color: #4ea1ff",
);
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Indexing finished (streaming vectorization active)",
);
}
stopHeartbeat();
const 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);
},
};
@@ -0,0 +1,612 @@
import type { Job, IndexItem } from "../types";
import { htmlToPlainText } from "../utils";
import { delay } from "@/seqta/utils/delay";
import { VectorWorkerManager } from "../worker/vectorWorkerManager";
import { loadDynamicItems } from "../../utils/dynamicItems";
import { loadAllStoredItems } from "../indexer";
import { renderComponentMap } from "../renderComponents";
import { jobs } from "../jobs";
const RATE_LIMIT_CONFIG = {
minDelay: 50,
maxDelay: 5000,
baseDelay: 200,
backoffMultiplier: 1.5,
maxRetries: 3,
adaptiveBatchSize: true,
minBatchSize: 10,
maxBatchSize: 100,
baseBatchSize: 50,
vectorBatchSize: 5,
parallelRequests: 5,
parallelDelay: 100,
};
interface MessagesProgress {
offset: number;
done: boolean;
currentBatchSize: number;
currentDelay: number;
failedRequests: number;
lastSuccessTime: number;
retryQueue: number[];
processedIds: string[];
streamingStarted: boolean;
totalEstimated: number;
}
const fetchMessages = async (offset = 0, limit = 100) => {
const res = await fetch(`${location.origin}/seqta/student/load/message`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
searchValue: "",
sortBy: "date",
sortOrder: "desc",
action: "list",
label: "inbox",
offset,
limit,
datetimeUntil: null,
}),
});
return res.json() as Promise<{
payload: { hasMore: boolean; messages: any[]; ts: string };
status: string;
}>;
};
export const fetchMessageContent = async (
id: number,
retryCount = 0,
): Promise<{
payload: { contents: string };
status: string;
} | null> => {
try {
const res = await fetch(`${location.origin}/seqta/student/load/message`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ action: "message", id }),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
} catch (error) {
console.warn(
`[Messages job] Failed to fetch content for message ${id} (attempt ${retryCount + 1}):`,
error,
);
if (retryCount < RATE_LIMIT_CONFIG.maxRetries) {
const retryDelay =
RATE_LIMIT_CONFIG.baseDelay *
Math.pow(RATE_LIMIT_CONFIG.backoffMultiplier, retryCount);
await delay(Math.min(retryDelay, RATE_LIMIT_CONFIG.maxDelay));
return fetchMessageContent(id, retryCount + 1);
}
return null;
}
};
function calculateAdaptiveDelay(
progress: MessagesProgress,
responseTime: number,
): number {
const { currentDelay, failedRequests, lastSuccessTime } = progress;
const timeSinceLastSuccess = Date.now() - lastSuccessTime;
if (failedRequests > 0 || responseTime > 2000) {
return Math.min(
currentDelay * RATE_LIMIT_CONFIG.backoffMultiplier,
RATE_LIMIT_CONFIG.maxDelay,
);
}
if (responseTime < 500 && timeSinceLastSuccess > 10000) {
return Math.max(currentDelay * 0.8, RATE_LIMIT_CONFIG.minDelay);
}
return currentDelay;
}
function calculateAdaptiveBatchSize(
progress: MessagesProgress,
responseTime: number,
): number {
if (!RATE_LIMIT_CONFIG.adaptiveBatchSize) {
return progress.currentBatchSize;
}
const { currentBatchSize, failedRequests } = progress;
if (failedRequests > 2 || responseTime > 3000) {
return Math.max(
Math.floor(currentBatchSize * 0.7),
RATE_LIMIT_CONFIG.minBatchSize,
);
}
if (failedRequests === 0 && responseTime < 1000) {
return Math.min(
Math.floor(currentBatchSize * 1.2),
RATE_LIMIT_CONFIG.maxBatchSize,
);
}
return currentBatchSize;
}
async function estimateMessageCount(): Promise<number> {
try {
const firstBatch = await fetchMessages(0, 100);
if (firstBatch.status !== "200" || !firstBatch.payload.hasMore) {
return firstBatch.payload.messages.length;
}
return Math.min(firstBatch.payload.messages.length * 20, 2000);
} catch (error) {
console.warn("[Messages job] Failed to estimate message count:", error);
return 500;
}
}
async function processMessagesInParallel(
messages: any[],
existingIds: Set<string>,
processedIdsSet: Set<string>,
progress: MessagesProgress,
ctx: any,
): Promise<{
processedItems: IndexItem[];
consecutiveExisting: number;
updatedProgress: MessagesProgress;
shouldStop: boolean;
}> {
const processedItems: IndexItem[] = [];
let consecutiveExisting = 0;
const updatedProgress = { ...progress };
// Filter out messages older than 2 years
const twoYearsAgo = Date.now() - 2 * 365 * 24 * 60 * 60 * 1000;
let shouldStop = false;
const messagesToProcess = messages.filter((msg) => {
const id = msg.id.toString();
const messageDate = new Date(msg.date).getTime();
// If we encounter a message older than 2 years, we should stop processing
// since messages are sorted by date descending
if (messageDate < twoYearsAgo) {
shouldStop = true;
return false;
}
if (existingIds.has(id) || processedIdsSet.has(id)) {
consecutiveExisting++;
return false;
}
consecutiveExisting = 0;
return true;
});
if (messagesToProcess.length === 0) {
return { processedItems, consecutiveExisting, updatedProgress, shouldStop };
}
for (
let i = 0;
i < messagesToProcess.length;
i += RATE_LIMIT_CONFIG.parallelRequests
) {
const batch = messagesToProcess.slice(
i,
i + RATE_LIMIT_CONFIG.parallelRequests,
);
if (i > 0) {
await delay(
Math.max(updatedProgress.currentDelay, RATE_LIMIT_CONFIG.parallelDelay),
);
}
const batchStartTime = Date.now();
const batchPromises = batch.map(async (msg) => {
const id = msg.id.toString();
try {
const full = await fetchMessageContent(msg.id);
const responseTime = Date.now() - batchStartTime;
if (full && full.status === "200") {
const item: IndexItem = {
id,
text: msg.subject,
category: "messages",
content: `${htmlToPlainText(full.payload.contents)}\nFrom: ${msg.sender}`,
dateAdded: new Date(msg.date).getTime(),
metadata: {
messageId: msg.id,
author: msg.sender,
senderId: msg.sender_id,
senderType: msg.sender_type,
timestamp: msg.date,
hasAttachments: msg.attachments,
attachmentCount: msg.attachmentCount,
read: msg.read === 1,
},
actionId: "message",
renderComponentId: "message",
};
return { success: true, item, id, responseTime };
} else {
return { success: false, id, messageId: msg.id, responseTime };
}
} catch (error) {
console.error(`[Messages job] content fetch failed (id ${id}):`, error);
return { success: false, id, messageId: msg.id, error };
}
});
const batchResults = await Promise.all(batchPromises);
const batchResponseTime = Date.now() - batchStartTime;
let batchSuccesses = 0;
let batchFailures = 0;
for (const result of batchResults) {
if (result.success && result.item) {
await ctx.addItem(result.item);
existingIds.add(result.id);
processedIdsSet.add(result.id);
processedItems.push(result.item);
batchSuccesses++;
} else {
if (updatedProgress.retryQueue.length < 50 && result.messageId) {
updatedProgress.retryQueue.push(result.messageId);
}
batchFailures++;
}
}
if (batchSuccesses > 0) {
updatedProgress.lastSuccessTime = Date.now();
updatedProgress.failedRequests = Math.max(
0,
updatedProgress.failedRequests - batchSuccesses,
);
}
if (batchFailures > 0) {
updatedProgress.failedRequests += batchFailures;
}
updatedProgress.currentDelay = calculateAdaptiveDelay(
updatedProgress,
batchResponseTime,
);
console.log(
`[Messages job] Processed parallel batch: ${batchSuccesses} successes, ${batchFailures} failures, ${batchResponseTime}ms total time`,
);
}
return { processedItems, consecutiveExisting, updatedProgress, shouldStop };
}
export const messagesJob: Job = {
id: "messages",
label: "Messages",
renderComponentId: "message",
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 },
run: async (ctx) => {
const progress = (await ctx.getProgress<MessagesProgress>()) ?? {
offset: 0,
done: false,
currentBatchSize: RATE_LIMIT_CONFIG.baseBatchSize,
currentDelay: RATE_LIMIT_CONFIG.baseDelay,
failedRequests: 0,
lastSuccessTime: Date.now(),
retryQueue: [],
processedIds: [],
streamingStarted: false,
totalEstimated: 0,
};
const existingIds = new Set((await ctx.getStoredItems()).map((i) => i.id));
const processedIdsSet = new Set(progress.processedIds);
existingIds.forEach((id) => processedIdsSet.add(id));
const vectorWorker = VectorWorkerManager.getInstance();
if (!progress.streamingStarted) {
progress.totalEstimated = await estimateMessageCount();
try {
await vectorWorker.startStreamingSession(
progress.totalEstimated,
(progressData) => {
console.log(
`[Messages job] Vector streaming progress: ${progressData.processed}/${progressData.total} (${progressData.status})`,
);
},
RATE_LIMIT_CONFIG.vectorBatchSize,
"messages",
);
progress.streamingStarted = true;
console.log(
`[Messages job] Started streaming vectorization session for ~${progress.totalEstimated} items`,
);
} catch (error) {
console.warn(
"[Messages job] Failed to start streaming session:",
error,
);
}
}
let consecutiveExisting = 0;
let requestStartTime = 0;
let progressUpdateCounter = 0;
let itemsStreamedToVector = 0;
if (progress.retryQueue.length > 0) {
console.log(
`[Messages job] Processing ${Math.min(progress.retryQueue.length, 10)} items from retry queue`,
);
const retryBatch = progress.retryQueue.slice(0, 10);
const retryBatches = [];
for (
let i = 0;
i < retryBatch.length;
i += RATE_LIMIT_CONFIG.parallelRequests
) {
retryBatches.push(
retryBatch.slice(i, i + RATE_LIMIT_CONFIG.parallelRequests),
);
}
for (const batch of retryBatches) {
await delay(progress.currentDelay);
const batchStartTime = Date.now();
const retryPromises = batch.map(async (messageId) => {
const id = messageId.toString();
if (processedIdsSet.has(id)) {
return { success: true, messageId, alreadyProcessed: true };
}
try {
const full = await fetchMessageContent(messageId);
const responseTime = Date.now() - batchStartTime;
if (full && full.status === "200") {
return { success: true, messageId, responseTime };
} else {
return { success: false, messageId, responseTime };
}
} catch (error) {
console.error(
`[Messages job] Retry failed for message ${messageId}:`,
error,
);
return { success: false, messageId, error };
}
});
const retryResults = await Promise.all(retryPromises);
const batchResponseTime = Date.now() - batchStartTime;
let retrySuccesses = 0;
let retryFailures = 0;
for (const result of retryResults) {
if (result.success) {
if (!result.alreadyProcessed) {
processedIdsSet.add(result.messageId.toString());
retrySuccesses++;
}
progress.retryQueue = progress.retryQueue.filter(
(mid) => mid !== result.messageId,
);
} else {
retryFailures++;
}
}
if (retrySuccesses > 0) {
progress.lastSuccessTime = Date.now();
progress.failedRequests = Math.max(
0,
progress.failedRequests - retrySuccesses,
);
}
if (retryFailures > 0) {
progress.failedRequests += retryFailures;
}
progress.currentDelay = calculateAdaptiveDelay(
progress,
batchResponseTime,
);
console.log(
`[Messages job] Processed retry batch: ${retrySuccesses} successes, ${retryFailures} failures`,
);
}
}
while (!progress.done) {
await delay(progress.currentDelay);
requestStartTime = Date.now();
let list;
try {
list = await fetchMessages(progress.offset, progress.currentBatchSize);
const responseTime = Date.now() - requestStartTime;
progress.currentDelay = calculateAdaptiveDelay(progress, responseTime);
progress.currentBatchSize = calculateAdaptiveBatchSize(
progress,
responseTime,
);
} catch (e) {
console.error("[Messages job] list fetch failed:", e);
progress.failedRequests++;
progress.currentDelay = Math.min(
progress.currentDelay * RATE_LIMIT_CONFIG.backoffMultiplier,
RATE_LIMIT_CONFIG.maxDelay,
);
progress.processedIds = Array.from(processedIdsSet);
await ctx.setProgress(progress);
break;
}
if (list.status !== "200") {
progress.failedRequests++;
progress.processedIds = Array.from(processedIdsSet);
await ctx.setProgress(progress);
break;
}
const itemsToStream: IndexItem[] = [];
const {
processedItems,
consecutiveExisting: newConsecutiveExisting,
updatedProgress,
shouldStop,
} = await processMessagesInParallel(
list.payload.messages,
existingIds,
processedIdsSet,
progress,
ctx,
);
progress.currentDelay = updatedProgress.currentDelay;
progress.failedRequests = updatedProgress.failedRequests;
progress.lastSuccessTime = updatedProgress.lastSuccessTime;
progress.retryQueue = updatedProgress.retryQueue;
itemsToStream.push(...processedItems);
// Update consecutive existing counter
consecutiveExisting = newConsecutiveExisting;
if (consecutiveExisting >= 20) {
progress.done = true;
}
// Stream items to vector worker if we have any
if (itemsToStream.length > 0 && progress.streamingStarted) {
try {
await vectorWorker.streamItems(itemsToStream);
itemsStreamedToVector += itemsToStream.length;
console.log(
`[Messages job] Streamed ${itemsToStream.length} items to vector worker (total: ${itemsStreamedToVector})`,
);
} catch (error) {
console.warn(
"[Messages job] Failed to stream items to vector worker:",
error,
);
}
}
// Dispatch incremental search update if we processed new items
if (processedItems.length > 0) {
try {
const currentItems = await loadAllStoredItems();
currentItems.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(currentItems);
window.dispatchEvent(new CustomEvent("dynamic-items-updated", {
detail: { incremental: true, jobId: "messages", newItemCount: processedItems.length, streaming: true }
}));
} catch (error) {
console.warn("[Messages job] Failed to dispatch incremental search update:", error);
}
}
if (!list.payload.hasMore) progress.done = true;
progress.offset += progress.currentBatchSize;
progressUpdateCounter++;
if (progressUpdateCounter >= 10 || progress.done) {
progress.processedIds = Array.from(processedIdsSet);
await ctx.setProgress(progress);
progressUpdateCounter = 0;
console.log(
`[Messages job] Progress: offset=${progress.offset}, batchSize=${progress.currentBatchSize}, delay=${progress.currentDelay}ms, failures=${progress.failedRequests}, retryQueue=${progress.retryQueue.length}, vectorStreamed=${itemsStreamedToVector}, parallelRequests=${RATE_LIMIT_CONFIG.parallelRequests}`,
);
}
if (shouldStop) {
progress.done = true;
break;
}
}
if (progress.streamingStarted) {
try {
await vectorWorker.endStreamingSession();
console.log(
`[Messages job] Ended streaming session. Total items streamed: ${itemsStreamedToVector}`,
);
} catch (error) {
console.warn("[Messages job] Failed to end streaming session:", error);
}
}
if (progress.done) {
await ctx.setProgress({
offset: 0,
done: false,
currentBatchSize: RATE_LIMIT_CONFIG.baseBatchSize,
currentDelay: RATE_LIMIT_CONFIG.baseDelay,
failedRequests: 0,
lastSuccessTime: Date.now(),
retryQueue: progress.retryQueue.slice(0, 20),
processedIds: [],
streamingStarted: false,
totalEstimated: 0,
});
} else {
progress.processedIds = Array.from(processedIdsSet);
await ctx.setProgress(progress);
}
return [];
},
purge: (items) => {
const twoYears = Date.now() - 2 * 365 * 24 * 60 * 60 * 1000;
return items.filter((i) => i.dateAdded >= twoYears);
},
};
@@ -0,0 +1,522 @@
import type { Job, IndexItem } from "../types";
import { htmlToPlainText } from "../utils";
import { fetchMessageContent } from "./messages";
import { delay } from "@/seqta/utils/delay";
import { VectorWorkerManager } from "../worker/vectorWorkerManager";
import { loadDynamicItems } from "../../utils/dynamicItems";
import { loadAllStoredItems } from "../indexer";
import { renderComponentMap } from "../renderComponents";
import { jobs } from "../jobs";
const NOTIFICATIONS_RATE_LIMIT = {
baseDelay: 150,
maxDelay: 3000,
backoffMultiplier: 1.4,
maxRetries: 2,
batchDelay: 100,
vectorBatchSize: 3,
};
interface MessageNotification {
notificationID: number;
type: "message";
message: { subtitle: string; messageID: number; title: string };
timestamp: string;
}
interface AssessmentNotification {
notificationID: number;
type: "coneqtassessments";
coneqtAssessments: {
programmeID: number;
metaclassID: number;
subtitle: string;
term: string;
title: string;
assessmentID: number;
subjectCode: string;
};
timestamp: string;
}
type Notification = MessageNotification | AssessmentNotification;
interface NotificationsProgress {
lastTs: number;
failedRequests: number;
currentDelay: number;
retryQueue: number[];
streamingStarted: boolean;
}
const fetchNotifications = async () => {
const res = await fetch(`${location.origin}/seqta/student/heartbeat?`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({
timestamp: "1970-01-01 00:00:00.0",
hash: "#?page=/notifications",
}),
});
const json = await res.json();
return (json.notifications ?? []) as Notification[];
};
const fetchAssessmentName = async (
assessmentId: number,
metaclassId: number,
programmeId: number,
retryCount = 0,
): Promise<string> => {
const searchAssessment = (data: any): string | null => {
for (const item of data.syllabus || []) {
const found = (item.assessments || []).find(
(a: any) => a.id === assessmentId,
);
if (found) return found.title;
}
const foundPending = (data.pending || []).find(
(a: any) => a.id === assessmentId,
);
if (foundPending) return foundPending.title;
const foundTask = (data.tasks || []).find(
(a: any) => a.id === assessmentId,
);
if (foundTask) return foundTask.title;
return null;
};
const fetchAssessments = async (endpoint: string) => {
try {
const res = await fetch(`${location.origin}${endpoint}`, {
method: "POST",
credentials: "include",
body: JSON.stringify({
metaclass: metaclassId,
programme: programmeId,
}),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const json = await res.json();
return json.payload;
} catch (error) {
console.warn(
`[Notifications job] Failed to fetch assessments from ${endpoint} (attempt ${retryCount + 1}):`,
error,
);
if (retryCount < NOTIFICATIONS_RATE_LIMIT.maxRetries) {
const retryDelay =
NOTIFICATIONS_RATE_LIMIT.baseDelay *
Math.pow(NOTIFICATIONS_RATE_LIMIT.backoffMultiplier, retryCount);
await delay(Math.min(retryDelay, NOTIFICATIONS_RATE_LIMIT.maxDelay));
return fetchAssessments(endpoint);
}
throw error;
}
};
try {
let payload = await fetchAssessments("/seqta/student/assessment/list/past");
let title = searchAssessment(payload);
if (title) return title;
await delay(NOTIFICATIONS_RATE_LIMIT.baseDelay);
const upcomingPayload = await fetchAssessments(
"/seqta/student/assessment/list/upcoming",
);
const foundUpcoming = (upcomingPayload || []).find(
(a: any) => a.id === assessmentId,
);
if (foundUpcoming) return foundUpcoming.title;
throw new Error(
`Assessment with ID ${assessmentId} not found in past or upcoming.`,
);
} catch (error) {
if (retryCount < NOTIFICATIONS_RATE_LIMIT.maxRetries) {
const retryDelay =
NOTIFICATIONS_RATE_LIMIT.baseDelay *
Math.pow(NOTIFICATIONS_RATE_LIMIT.backoffMultiplier, retryCount);
await delay(Math.min(retryDelay, NOTIFICATIONS_RATE_LIMIT.maxDelay));
return fetchAssessmentName(
assessmentId,
metaclassId,
programmeId,
retryCount + 1,
);
}
console.error(
`[Notifications job] Failed to fetch assessment name for ID ${assessmentId} after ${retryCount + 1} attempts:`,
error,
);
return `Assessment ${assessmentId}`;
}
};
export const notificationsJob: Job = {
id: "notifications",
label: "Notifications",
renderComponentId: "notifications",
frequency: { type: "expiry", afterMs: 15 * 60 * 1000 },
run: async (ctx) => {
const progress = (await ctx.getProgress<NotificationsProgress>()) ?? {
lastTs: 0,
failedRequests: 0,
currentDelay: NOTIFICATIONS_RATE_LIMIT.baseDelay,
retryQueue: [],
streamingStarted: false,
};
let notifications: Notification[];
try {
notifications = await fetchNotifications();
} catch (e) {
console.error("[Notifications job] fetch failed:", e);
progress.failedRequests++;
progress.currentDelay = Math.min(
progress.currentDelay * NOTIFICATIONS_RATE_LIMIT.backoffMultiplier,
NOTIFICATIONS_RATE_LIMIT.maxDelay,
);
await ctx.setProgress(progress);
return [];
}
const vectorWorker = VectorWorkerManager.getInstance();
if (!progress.streamingStarted && notifications.length > 0) {
const estimatedTotal = Math.min(notifications.length * 1.2, 100);
try {
await vectorWorker.startStreamingSession(
estimatedTotal,
(progressData) => {
console.log(
`[Notifications job] Vector streaming progress: ${progressData.processed}/${progressData.total} (${progressData.status})`,
);
},
NOTIFICATIONS_RATE_LIMIT.vectorBatchSize,
"notifications",
);
progress.streamingStarted = true;
console.log(
`[Notifications job] Started streaming vectorization session for ~${estimatedTotal} items`,
);
} catch (error) {
console.warn(
"[Notifications job] Failed to start streaming session:",
error,
);
}
}
const notificationIsIndexed = async (id: string): Promise<boolean> => {
try {
const [inAssessments, inMessages] = await Promise.all([
ctx
.getStoredItems("notifications")
.then((items) => items.some((i) => i.id === id)),
ctx
.getStoredItems("messages")
.then((items) => items.some((i) => i.id === id)),
]);
return inAssessments || inMessages;
} catch (error) {
console.warn(
`[Notifications job] Error checking if notification ${id} is indexed:`,
error,
);
return false;
}
};
const items: IndexItem[] = [];
const itemsToStream: IndexItem[] = [];
let processedCount = 0;
let progressUpdateCounter = 0;
let itemsStreamedToVector = 0;
if (progress.retryQueue.length > 0) {
console.log(
`[Notifications job] Processing ${Math.min(progress.retryQueue.length, 3)} items from retry queue`,
);
const retryBatch = progress.retryQueue.slice(0, 3);
for (const notificationId of retryBatch) {
const notification = notifications.find(
(n) => n.notificationID === notificationId,
);
if (!notification) {
progress.retryQueue = progress.retryQueue.filter(
(id) => id !== notificationId,
);
continue;
}
await delay(progress.currentDelay);
try {
const { success, item } = await processNotification(
notification,
ctx,
);
if (success) {
progress.retryQueue = progress.retryQueue.filter(
(id) => id !== notificationId,
);
progress.failedRequests = Math.max(0, progress.failedRequests - 1);
progress.currentDelay = Math.max(
progress.currentDelay * 0.9,
NOTIFICATIONS_RATE_LIMIT.baseDelay,
);
if (item) {
items.push(item);
itemsToStream.push(item);
}
}
} catch (error) {
console.error(
`[Notifications job] Retry failed for notification ${notificationId}:`,
error,
);
progress.failedRequests++;
}
}
}
const notificationsToProcess = notifications.slice(0, 20);
for (const notif of notificationsToProcess) {
const id = notif.notificationID.toString();
try {
if (await notificationIsIndexed(id)) continue;
if (progress.retryQueue.includes(notif.notificationID)) continue;
if (processedCount > 0) {
await delay(NOTIFICATIONS_RATE_LIMIT.batchDelay);
}
const { success, item } = await processNotification(
notif,
ctx,
);
if (!success) {
if (progress.retryQueue.length < 10) {
progress.retryQueue.push(notif.notificationID);
}
progress.failedRequests++;
} else {
progress.failedRequests = Math.max(0, progress.failedRequests - 1);
progress.currentDelay = Math.max(
progress.currentDelay * 0.95,
NOTIFICATIONS_RATE_LIMIT.baseDelay,
);
if (item) {
items.push(item);
itemsToStream.push(item);
}
}
} catch (error) {
console.error(
`[Notifications job] Failed to process notification ${id}:`,
error,
);
if (progress.retryQueue.length < 10) {
progress.retryQueue.push(notif.notificationID);
}
progress.failedRequests++;
progress.currentDelay = Math.min(
progress.currentDelay * NOTIFICATIONS_RATE_LIMIT.backoffMultiplier,
NOTIFICATIONS_RATE_LIMIT.maxDelay,
);
}
processedCount++;
if (
itemsToStream.length >= NOTIFICATIONS_RATE_LIMIT.vectorBatchSize &&
progress.streamingStarted
) {
try {
await vectorWorker.streamItems([...itemsToStream]);
itemsStreamedToVector += itemsToStream.length;
console.log(
`[Notifications job] Streamed ${itemsToStream.length} items to vector worker (total: ${itemsStreamedToVector})`,
);
itemsToStream.length = 0;
} catch (error) {
console.warn(
"[Notifications job] Failed to stream items to vector worker:",
error,
);
}
}
progressUpdateCounter++;
if (progressUpdateCounter >= 5) {
await ctx.setProgress(progress);
progressUpdateCounter = 0;
if (items.length > 0) {
try {
const currentItems = await loadAllStoredItems();
currentItems.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(currentItems);
window.dispatchEvent(new CustomEvent("dynamic-items-updated", {
detail: { incremental: true, jobId: "notifications", newItemCount: items.length, streaming: true }
}));
} catch (error) {
console.warn("[Notifications job] Failed to dispatch incremental search update:", error);
}
}
}
}
if (itemsToStream.length > 0 && progress.streamingStarted) {
try {
await vectorWorker.streamItems([...itemsToStream]);
itemsStreamedToVector += itemsToStream.length;
console.log(
`[Notifications job] Streamed final ${itemsToStream.length} items to vector worker (total: ${itemsStreamedToVector})`,
);
} catch (error) {
console.warn(
"[Notifications job] Failed to stream final items to vector worker:",
error,
);
}
}
if (progress.streamingStarted) {
try {
await vectorWorker.endStreamingSession();
console.log(
`[Notifications job] Ended streaming session. Total items streamed: ${itemsStreamedToVector}`,
);
progress.streamingStarted = false;
} catch (error) {
console.warn(
"[Notifications job] Failed to end streaming session:",
error,
);
}
}
if (items.length) {
const latest = Math.max(
...items.map((i) => i.dateAdded),
progress.lastTs,
);
progress.lastTs = latest;
}
await ctx.setProgress(progress);
console.log(
`[Notifications job] Processed ${processedCount} notifications, ${progress.retryQueue.length} in retry queue, ${progress.failedRequests} failures, ${itemsStreamedToVector} items streamed to vector worker`,
);
return items;
},
purge: (items) => {
const date = new Date();
date.setMonth(0, 1);
date.setHours(0, 0, 0, 0);
return items.filter((i) => i.dateAdded >= date.getTime());
},
};
async function processNotification(
notif: Notification,
ctx: any,
): Promise<{ success: boolean; item?: IndexItem }> {
const id = notif.notificationID.toString();
try {
if (notif.type === "coneqtassessments") {
const a = notif.coneqtAssessments;
const content = await fetchAssessmentName(
a.assessmentID,
a.metaclassID,
a.programmeID,
);
const item: IndexItem = {
id,
text: a.title,
category: "assessments",
content: content,
dateAdded: new Date(notif.timestamp).getTime(),
metadata: {
assessmentId: a.assessmentID,
subject: a.subjectCode,
term: a.term,
programmeId: a.programmeID,
metaclassId: a.metaclassID,
timestamp: notif.timestamp,
},
actionId: "assessment",
renderComponentId: "assessment",
};
return { success: true, item };
} else if (notif.type === "message") {
const content = await fetchMessageContent(notif.message.messageID);
if (content && content.payload) {
const item: IndexItem = {
id,
text: notif.message.title,
category: "messages",
content: `${htmlToPlainText(content.payload.contents)}\nFrom: ${notif.message.subtitle}`,
dateAdded: new Date(notif.timestamp).getTime(),
metadata: {
messageId: notif.message.messageID,
author: notif.message.subtitle,
timestamp: notif.timestamp,
isAssessmentNotification: true,
},
actionId: "message",
renderComponentId: "message",
};
await ctx.addItem(item, "messages");
return { success: true, item };
}
}
return { success: false };
} catch (error) {
console.error(
`[Notifications job] Error processing notification ${id}:`,
error,
);
return { success: false };
}
}
@@ -0,0 +1,140 @@
import type { IndexItem, Job } from "../types";
const fetchSubjects = async () => {
const res = await fetch(`${location.origin}/seqta/student/load/subjects`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ mode: "list" }),
});
const data = await res.json();
return data;
};
export const subjectsJob: Job = {
id: "subjects",
label: "Subjects",
renderComponentId: "subject",
frequency: {
type: "expiry",
afterMs: 1000 * 60 * 60 * 24 * 30,
},
boostCriteria: (item, searchTerm) => {
if (searchTerm == "") {
return -100;
}
let score = 0;
if (item.metadata.isActive) {
score += 0.01; // Boost for active subjects
} else {
score -= 50; // Penalty for inactive subjects
}
return score;
},
run: async (ctx) => {
const existingIds = new Set(
(await ctx.getStoredItems("subjects")).map((i) => i.id),
);
let list;
try {
list = await fetchSubjects();
} catch (e) {
console.error("[Subjects job] list fetch failed:", e);
return [];
}
if (list.status !== "200") {
console.error("[Subjects job] API returned non-200 status:", list.status);
return [];
}
// Check if we have the expected data structure
if (!list.payload || !Array.isArray(list.payload)) {
console.error("[Subjects job] Unexpected API response structure:", list);
return [];
}
const items: IndexItem[] = [];
// Process each semester
for (const semester of list.payload) {
if (!semester.subjects || !Array.isArray(semester.subjects)) {
console.warn("[Subjects job] Skipping invalid semester:", semester);
continue;
}
// Process each subject in the semester
for (const subject of semester.subjects) {
// Skip if subject doesn't have required fields
if (!subject || !subject.code || !subject.title) {
console.warn("[Subjects job] Skipping invalid subject:", subject);
continue;
}
const id = `${semester.code}-${subject.code}-${subject.metaclass}`;
if (existingIds.has(id)) continue;
const isActive = semester.active === 1;
// Create two items for each subject - one for assessments and one for course
const assessmentsItem = {
id: `${id}-assessments`,
text: `${subject.title} Assessments`,
category: "subjects",
content: `View assessments for ${subject.title} (${semester.description})`,
dateAdded: Date.now(),
metadata: {
subjectId: subject.metaclass,
subjectName: subject.title,
subjectCode: subject.code,
programme: subject.programme,
semesterCode: semester.code,
semesterDescription: semester.description,
type: "assessments",
isActive
},
actionId: "subjectassessment",
renderComponentId: "subject",
};
const courseItem = {
id: `${id}-course`,
text: `${subject.title}`,
category: "subjects",
content: `View course content for ${subject.title} (${semester.description})`,
dateAdded: Date.now(),
metadata: {
subjectId: subject.metaclass,
subjectName: subject.title,
subjectCode: subject.code,
programme: subject.programme,
semesterCode: semester.code,
semesterDescription: semester.description,
type: "course",
isActive
},
actionId: "subjectcourse",
renderComponentId: "subject",
};
items.push(
assessmentsItem,
courseItem
);
}
}
console.debug(`[Subjects job] Indexed ${items.length} subject items`);
return items;
},
purge: (items) => {
// Keep all subjects as they are relatively static
return items;
},
};
@@ -0,0 +1,11 @@
import type { SvelteComponent } from "svelte";
import AssessmentItem from "../components/items/AssessmentItem.svelte";
import ForumItem from "../components/items/ForumItem.svelte";
import SubjectItem from "../components/items/SubjectItem.svelte";
export const renderComponentMap: Record<string, typeof SvelteComponent> = {
assessment: AssessmentItem as unknown as typeof SvelteComponent,
message: AssessmentItem as unknown as typeof SvelteComponent,
forum: ForumItem as unknown as typeof SvelteComponent,
subject: SubjectItem as unknown as typeof SvelteComponent,
};
@@ -0,0 +1,37 @@
import type { SvelteComponent } from "svelte";
export interface IndexItem {
id: string;
text: string;
category: string;
content: string;
dateAdded: number;
metadata: Record<string, any>;
actionId: string;
renderComponentId: string;
renderComponent?: typeof SvelteComponent;
}
export type Frequency =
| "pageLoad"
| { type: "interval"; ms: number }
| { type: "expiry"; afterMs: number };
export interface JobContext {
getStoredItems: (storeId?: string) => Promise<IndexItem[]>;
setStoredItems: (items: IndexItem[], storeId?: string) => Promise<void>;
addItem: (item: IndexItem, storeId?: string) => Promise<void>;
removeItem: (id: string, storeId?: string) => Promise<void>;
getProgress: <T = any>() => Promise<T | undefined>;
setProgress: <T = any>(progress: T) => Promise<void>;
}
export interface Job {
id: string;
label: string;
frequency: Frequency;
renderComponentId: string;
run: (ctx: JobContext) => Promise<IndexItem[]>;
purge?: (items: IndexItem[]) => IndexItem[];
boostCriteria?: (item: IndexItem, searchTerm: string) => number;
}
@@ -0,0 +1,34 @@
export function htmlToPlainText(rawHtml: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(rawHtml, "text/html");
const { body } = doc;
body
.querySelectorAll("script,style,template,noscript,meta,link")
.forEach((el) => el.remove());
body.querySelectorAll(".forward").forEach((el) => {
let n: ChildNode | null = el;
while (n) {
const next = n.nextSibling as ChildNode | null;
n.remove();
n = next;
}
});
let text = body.innerText || "";
text = text
.replace(/\u00A0/g, " ")
.replace(/[ \t]{2,}/g, " ")
.replace(/\r\n|\r/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/^[.\w#][^{]{0,100}\{[^}]*\}$/gm, "")
.split("\n")
.map((line) => line.trimEnd())
.filter((line) => line.trim().length > 0 || line === "")
.join("\n")
.trim();
return text;
}
@@ -0,0 +1,549 @@
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
import type { IndexItem } from "../types";
let vectorIndex: EmbeddingIndex | null = null;
let isInitialized = false;
let currentAbortController: AbortController | null = null;
let loadedItemIds = new Set<string>(); // Track loaded items to prevent duplicates
let streamingSession: {
isActive: boolean;
totalExpected: number;
totalReceived: number;
totalProcessed: number;
batchSize: number;
pendingItems: IndexItem[];
processingPromise: Promise<void> | null;
} | null = null;
async function initWorker() {
if (isInitialized) {
console.debug("Vector worker already initialized.");
return;
}
console.debug("Initializing vector worker...");
try {
await initializeModel();
vectorIndex = new EmbeddingIndex([]);
// Load existing items but track them to prevent duplicates
const stored = await vectorIndex.getAllObjectsFromIndexedDB();
if (stored.length > 0) {
console.debug(`Found ${stored.length} existing items in IndexedDB`);
// Clear any existing items from memory first
loadedItemIds.clear();
// Add items and track their IDs
stored.forEach((item) => {
if (item.id && !loadedItemIds.has(item.id)) {
vectorIndex!.add(item);
loadedItemIds.add(item.id);
}
});
console.debug(
`Vector index loaded ${loadedItemIds.size} unique items from IndexedDB.`,
);
} else {
console.debug("No existing vector index found in IndexedDB.");
}
isInitialized = true;
console.debug("Vector worker initialized successfully.");
} catch (e) {
console.error("Failed to initialize vector worker:", e);
isInitialized = true;
vectorIndex = null;
}
}
async function vectorizeItem(
item: IndexItem,
): Promise<(IndexItem & { embedding: number[] }) | null> {
try {
const textToEmbed = [
item.text,
item.content,
item.category,
item.metadata?.author,
item.metadata?.subject,
]
.filter(Boolean)
.join(" ");
const embedding = await getEmbedding(textToEmbed);
return { ...item, embedding };
} catch (error) {
console.error(`Error vectorizing item ${item.id}:`, error);
return null;
}
}
async function startStreamingSession(
totalExpected: number,
batchSize: number = 5,
) {
if (!vectorIndex) {
console.warn(
"Streaming requested but vector index not ready. Attempting init.",
);
await initWorker();
if (!vectorIndex) {
self.postMessage({
type: "progress",
data: {
status: "error",
message:
"Vector index not available for streaming after init attempt.",
},
});
return;
}
}
if (streamingSession?.isActive) {
await endStreamingSession();
}
streamingSession = {
isActive: true,
totalExpected,
totalReceived: 0,
totalProcessed: 0,
batchSize,
pendingItems: [],
processingPromise: null,
};
console.debug(
`Started streaming session for ${totalExpected} items with batch size ${batchSize}`,
);
self.postMessage({
type: "streamingProgress",
data: {
processed: 0,
total: totalExpected,
message: "Streaming session started",
},
});
}
async function processStreamingBatch(
items: IndexItem[],
isLast: boolean = false,
) {
if (!streamingSession?.isActive) {
console.warn("Received streaming batch but no active session");
return;
}
streamingSession.totalReceived += items.length;
streamingSession.pendingItems.push(...items);
console.debug(
`Received streaming batch: ${items.length} items (${streamingSession.totalReceived}/${streamingSession.totalExpected})`,
);
const shouldProcess =
streamingSession.pendingItems.length >= streamingSession.batchSize ||
isLast;
if (shouldProcess && !streamingSession.processingPromise) {
streamingSession.processingPromise = processStreamingItems();
}
}
async function processStreamingItems() {
if (!streamingSession?.isActive || !vectorIndex) {
return;
}
while (
streamingSession.pendingItems.length > 0 &&
streamingSession.isActive
) {
const batchToProcess = streamingSession.pendingItems.splice(
0,
streamingSession.batchSize,
);
// Use our tracking set for more efficient deduplication
const unprocessedItems = batchToProcess.filter((item) => {
return item.id && !loadedItemIds.has(item.id);
});
if (unprocessedItems.length === 0) {
streamingSession.totalProcessed += batchToProcess.length;
console.debug(`Skipped ${batchToProcess.length} already processed items`);
continue;
}
const vectorizationResults = await Promise.all(
unprocessedItems.map(vectorizeItem),
);
const successfullyVectorized = vectorizationResults.filter(
(result) => result !== null,
) as (IndexItem & { embedding: number[] })[];
if (successfullyVectorized.length > 0) {
try {
successfullyVectorized.forEach((item) => {
vectorIndex!.add(item);
loadedItemIds.add(item.id); // Track the added item
});
if (
streamingSession.totalProcessed % (streamingSession.batchSize * 15) ===
0
) {
await vectorIndex!.saveIndex("indexedDB");
console.debug(
`Saved streaming index at ${streamingSession.totalProcessed} processed items (${loadedItemIds.size} total unique items)`,
);
}
} catch (e) {
console.error("Error processing streaming batch:", e);
}
}
streamingSession.totalProcessed += batchToProcess.length;
self.postMessage({
type: "streamingProgress",
data: {
processed: streamingSession.totalProcessed,
total: streamingSession.totalExpected,
message: `Processed ${streamingSession.totalProcessed}/${streamingSession.totalExpected} items (${loadedItemIds.size} unique)`,
},
});
await new Promise((resolve) => setTimeout(resolve, 10));
}
streamingSession.processingPromise = null;
if (
streamingSession.totalReceived >= streamingSession.totalExpected &&
streamingSession.pendingItems.length === 0
) {
await finalizeStreamingSession();
}
}
async function finalizeStreamingSession() {
if (!streamingSession?.isActive) {
return;
}
try {
if (vectorIndex) {
await vectorIndex.saveIndex("indexedDB");
console.debug("Final save of streaming index completed");
}
} catch (e) {
console.error("Error in final streaming save:", e);
}
const totalProcessed = streamingSession.totalProcessed;
const totalExpected = streamingSession.totalExpected;
streamingSession.isActive = false;
self.postMessage({
type: "progress",
data: {
status: "complete",
total: totalExpected,
processed: totalProcessed,
message: `Streaming vectorization complete: ${totalProcessed}/${totalExpected} items processed`,
},
});
console.debug(
`Streaming session completed: ${totalProcessed}/${totalExpected} items processed`,
);
}
async function endStreamingSession() {
if (!streamingSession?.isActive) {
return;
}
console.debug("Ending streaming session...");
if (streamingSession.processingPromise) {
await streamingSession.processingPromise;
}
if (streamingSession.pendingItems.length > 0) {
console.debug(
`Processing ${streamingSession.pendingItems.length} remaining items before ending session`,
);
streamingSession.processingPromise = processStreamingItems();
await streamingSession.processingPromise;
}
try {
if (vectorIndex) {
await vectorIndex.saveIndex("indexedDB");
console.debug("Final save before ending streaming session");
}
} catch (e) {
console.error("Error in final save before ending session:", e);
}
const wasActive = streamingSession.isActive;
streamingSession.isActive = false;
if (wasActive) {
self.postMessage({
type: "progress",
data: {
status: "cancelled",
message: "Streaming session ended early",
},
});
}
}
async function processItems(items: IndexItem[], signal: AbortSignal) {
console.debug("Worker received process request.");
if (!vectorIndex) {
console.warn(
"Processing requested but vector index not ready. Attempting init.",
);
await initWorker();
if (!vectorIndex) {
self.postMessage({
type: "progress",
data: {
status: "error",
message:
"Vector index not available for processing after init attempt.",
},
});
return;
}
}
// Use our tracking set for more efficient deduplication
const unprocessedItems = items.filter((item) => {
if (signal.aborted) return false;
return item.id && !loadedItemIds.has(item.id);
});
if (signal.aborted) {
console.debug("Processing cancelled before starting.");
self.postMessage({
type: "progress",
data: {
status: "cancelled",
message: "Processing cancelled before start",
},
});
return;
}
if (unprocessedItems.length === 0) {
console.debug(`No new items to process. ${loadedItemIds.size} items already in index.`);
self.postMessage({
type: "progress",
data: { status: "complete", message: `No new items to process (${loadedItemIds.size} items already indexed)` },
});
return;
}
console.debug(`Starting processing of ${unprocessedItems.length} items (${items.length - unprocessedItems.length} already processed).`);
self.postMessage({
type: "progress",
data: {
status: "started",
total: unprocessedItems.length,
processed: 0,
},
});
const BATCH_SIZE = 5;
let processedCount = 0;
for (let i = 0; i < unprocessedItems.length; i += BATCH_SIZE) {
if (signal.aborted) {
console.debug("Processing cancelled during batching.");
self.postMessage({
type: "progress",
data: {
status: "cancelled",
message: "Processing cancelled during batching",
},
});
return;
}
const batch = unprocessedItems.slice(i, i + BATCH_SIZE);
const vectorizationResults = await Promise.all(batch.map(vectorizeItem));
const successfullyVectorized = vectorizationResults.filter(
(result) => result !== null,
) as (IndexItem & { embedding: number[] })[];
if (signal.aborted) {
console.debug("Processing cancelled after vectorization batch.");
self.postMessage({
type: "progress",
data: {
status: "cancelled",
message: "Processing cancelled after vectorization",
},
});
return;
}
if (successfullyVectorized.length > 0) {
try {
successfullyVectorized.forEach((item) => {
vectorIndex!.add(item);
loadedItemIds.add(item.id); // Track the added item
});
} catch (e) {
console.error("Error adding batch to index:", e);
self.postMessage({
type: "progress",
data: { status: "error", message: `Error adding to index: ${e}` },
});
}
}
if (signal.aborted) {
console.debug("Processing cancelled before saving batch.");
self.postMessage({
type: "progress",
data: {
status: "cancelled",
message: "Processing cancelled before saving",
},
});
return;
}
try {
await vectorIndex!.saveIndex("indexedDB");
console.debug(`Saved index after processing batch ${i / BATCH_SIZE + 1} (${loadedItemIds.size} total unique items)`);
} catch (e) {
console.error("Error saving index batch:", e);
self.postMessage({
type: "progress",
data: { status: "error", message: `Error saving index batch: ${e}` },
});
}
processedCount += batch.length;
self.postMessage({
type: "progress",
data: {
status: "processing",
total: unprocessedItems.length,
processed: processedCount,
message: `Processed ${processedCount}/${unprocessedItems.length} items (${loadedItemIds.size} total unique)`,
},
});
}
console.debug(`Processing complete. Total unique items in index: ${loadedItemIds.size}`);
self.postMessage({
type: "progress",
data: {
status: "complete",
total: unprocessedItems.length,
processed: processedCount,
message: `Processing complete: ${processedCount} new items processed (${loadedItemIds.size} total unique items)`,
},
});
}
async function resetWorker() {
console.debug("Resetting vector worker state...");
// Clear tracking
loadedItemIds.clear();
// Reset streaming session
if (streamingSession?.isActive) {
streamingSession.isActive = false;
streamingSession = null;
}
// Reset vector index
if (vectorIndex) {
try {
// Save current state before reset
await vectorIndex.saveIndex("indexedDB");
console.debug("Saved index before reset");
} catch (e) {
console.warn("Error saving index before reset:", e);
}
}
// Reinitialize
isInitialized = false;
vectorIndex = null;
await initWorker();
console.debug(`Vector worker reset complete. Loaded ${loadedItemIds.size} items.`);
self.postMessage({
type: "progress",
data: {
status: "complete",
message: `Worker reset complete. ${loadedItemIds.size} items loaded.`,
},
});
}
self.addEventListener("message", async (e) => {
const { type, data } = e.data;
switch (type) {
case "init":
await initWorker();
self.postMessage({ type: "ready" });
break;
case "process":
if (currentAbortController) {
currentAbortController.abort();
}
currentAbortController = new AbortController();
await processItems(data.items, currentAbortController.signal);
break;
case "startStreaming":
await startStreamingSession(data.totalExpected, data.batchSize);
break;
case "streamBatch":
await processStreamingBatch(data.items, data.isLast);
break;
case "endStreaming":
await endStreamingSession();
break;
case "reset":
await resetWorker();
break;
default:
console.warn("Unknown message type:", type);
}
});
initWorker()
.then(() => {
self.postMessage({ type: "ready" });
})
.catch((err) => {
console.error("Initial worker initialization failed:", err);
self.postMessage({ type: "ready" });
});
@@ -0,0 +1,378 @@
import { refreshVectorCache } from "../../search/vector/vectorSearch";
import type { IndexItem } from "../types";
import vectorWorker from "./vectorWorker.ts?inlineWorker";
export type ProgressCallback = (data: {
status: "started" | "processing" | "complete" | "error" | "cancelled";
total?: number;
processed?: number;
message?: string;
}) => void;
export class VectorWorkerManager {
private static instance: VectorWorkerManager;
private worker: Worker | null = null;
private isInitialized = false;
private readyPromise: Promise<void> | null = null;
private progressCallback: ProgressCallback | null = null;
private streamingSession: {
isActive: boolean;
totalExpected: number;
totalSent: number;
batchBuffer: IndexItem[];
batchSize: number;
flushTimer: NodeJS.Timeout | null;
jobId?: string; // Track which job owns the session
} | null = null;
private constructor() {}
static getInstance(): VectorWorkerManager {
if (!VectorWorkerManager.instance) {
console.debug("Creating new VectorWorkerManager instance");
VectorWorkerManager.instance = new VectorWorkerManager();
}
return VectorWorkerManager.instance;
}
private async initWorker(): Promise<void> {
if (this.isInitialized) return Promise.resolve();
if (this.readyPromise) return this.readyPromise;
console.debug("Lazy-loading vector worker...");
return new Promise<void>((resolve, reject) => {
// Terminate any existing worker before creating a new one
if (this.worker) {
console.debug("Terminating existing worker before creating new one");
this.worker.terminate();
this.worker = null;
}
console.debug("Creating new vector worker instance");
this.worker = vectorWorker();
console.log("Worker initialized", this.worker);
const timeout = setTimeout(() => {
console.error("Vector worker initialization timed out");
if (this.worker) {
this.worker.terminate();
this.worker = null;
}
this.isInitialized = false;
// Don't reset readyPromise here to prevent race conditions
// It will be reset when a new initialization is attempted
reject(new Error("Worker initialization timed out"));
}, 10000);
this.worker!.addEventListener("message", (e) => {
const { type, data } = e.data;
console.debug("Message from vector worker:", type, data);
switch (type) {
case "ready":
this.isInitialized = true;
clearTimeout(timeout);
console.debug("Vector worker initialized and ready.");
resolve();
break;
case "progress":
if (this.progressCallback) {
this.progressCallback(data);
if (data.status === "complete") {
refreshVectorCache();
if (this.streamingSession?.isActive) {
this.endStreamingSession();
}
// Dispatch search update when vectorization completes
window.dispatchEvent(new CustomEvent("dynamic-items-updated", {
detail: { incremental: true, jobId: "vectorization", vectorUpdate: true }
}));
}
}
break;
case "streamingProgress":
if (this.progressCallback && this.streamingSession?.isActive) {
const { processed } = data;
this.progressCallback({
status: "processing",
processed,
total: this.streamingSession.totalExpected,
message: `Streaming vectorization: ${processed}/${this.streamingSession.totalExpected} items`,
});
}
break;
default:
console.warn("Unknown message from worker:", type, data);
}
});
this.worker!.postMessage({ type: "init" });
});
}
private resetWorkerState() {
console.debug("Resetting vector worker state");
if (this.worker) {
this.worker.terminate();
this.worker = null;
}
this.isInitialized = false;
this.readyPromise = null;
this.progressCallback = null;
if (this.streamingSession?.isActive) {
this.endStreamingSession();
}
}
private async ensureReady() {
// If we already have a ready promise, wait for it regardless of outcome
if (this.readyPromise) {
try {
await this.readyPromise;
} catch (error) {
// If the previous initialization failed, reset state and try again
console.warn("Previous worker initialization failed, resetting state and retrying...", error);
this.resetWorkerState();
}
}
// Double-check if we're actually ready after waiting
if (this.isInitialized && this.worker) {
return;
}
// If we're not ready and there's no active promise, create one
if (!this.readyPromise) {
console.warn("Worker not initialized, attempting init...");
this.readyPromise = this.initWorker();
}
await this.readyPromise;
if (!this.isInitialized || !this.worker) {
throw new Error(
"Vector Worker is not available after initialization attempt.",
);
}
}
async processItems(items: IndexItem[], onProgress?: ProgressCallback) {
await this.ensureReady();
// Don't allow regular processing if streaming is active
if (this.streamingSession?.isActive) {
console.warn("Cannot process items while streaming session is active");
if (onProgress) {
onProgress({
status: "error",
message: "Cannot process items while streaming session is active"
});
}
return;
}
this.progressCallback = onProgress || null;
console.debug(`Sending ${items.length} items to worker for processing.`);
this.worker!.postMessage({
type: "process",
data: { items: items },
});
}
async startStreamingSession(
totalExpectedItems: number,
onProgress?: ProgressCallback,
batchSize: number = 10,
jobId?: string,
): Promise<void> {
await this.ensureReady();
// Check if another job already has an active streaming session
if (this.streamingSession?.isActive) {
if (this.streamingSession.jobId !== jobId) {
console.warn(`Cannot start streaming session for job ${jobId} - job ${this.streamingSession.jobId} already has an active session`);
if (onProgress) {
onProgress({
status: "error",
message: `Another job (${this.streamingSession.jobId}) already has an active streaming session`
});
}
return;
} else {
console.debug(`Streaming session for job ${jobId} already active`);
return;
}
}
this.progressCallback = onProgress || null;
this.streamingSession = {
isActive: true,
totalExpected: totalExpectedItems,
totalSent: 0,
batchBuffer: [],
batchSize,
flushTimer: null,
jobId,
};
console.debug(
`Starting streaming session for job ${jobId} with ${totalExpectedItems} items (batch size ${batchSize})`,
);
this.worker!.postMessage({
type: "startStreaming",
data: { totalExpected: totalExpectedItems, batchSize },
});
if (this.progressCallback) {
this.progressCallback({
status: "started",
total: totalExpectedItems,
processed: 0,
message: `Starting streaming vectorization for ${jobId}`,
});
}
}
async streamItems(items: IndexItem[]): Promise<void> {
if (!this.streamingSession?.isActive) {
throw new Error(
"No active streaming session. Call startStreamingSession first.",
);
}
this.streamingSession.batchBuffer.push(...items);
if (
this.streamingSession.batchBuffer.length >=
this.streamingSession.batchSize
) {
await this.flushBatch();
} else {
if (this.streamingSession.flushTimer) {
clearTimeout(this.streamingSession.flushTimer);
}
this.streamingSession.flushTimer = setTimeout(() => {
this.flushBatch();
}, 1000);
}
}
private async flushBatch(): Promise<void> {
if (
!this.streamingSession?.isActive ||
this.streamingSession.batchBuffer.length === 0
) {
return;
}
const batch = [...this.streamingSession.batchBuffer];
this.streamingSession.batchBuffer = [];
this.streamingSession.totalSent += batch.length;
if (this.streamingSession.flushTimer) {
clearTimeout(this.streamingSession.flushTimer);
this.streamingSession.flushTimer = null;
}
console.debug(
`Streaming batch of ${batch.length} items to worker (${this.streamingSession.totalSent}/${this.streamingSession.totalExpected})`,
);
this.worker!.postMessage({
type: "streamBatch",
data: {
items: batch,
isLast:
this.streamingSession.totalSent >=
this.streamingSession.totalExpected,
},
});
}
async endStreamingSession(): Promise<void> {
if (!this.streamingSession?.isActive) {
return;
}
await this.flushBatch();
if (this.streamingSession.flushTimer) {
clearTimeout(this.streamingSession.flushTimer);
}
this.streamingSession.isActive = false;
this.worker!.postMessage({
type: "endStreaming",
});
console.debug("Streaming session ended");
if (this.progressCallback) {
this.progressCallback({
status: "complete",
total: this.streamingSession.totalExpected,
processed: this.streamingSession.totalSent,
message: "Streaming vectorization complete",
});
}
this.streamingSession = null;
}
async streamItem(item: IndexItem): Promise<void> {
return this.streamItems([item]);
}
isStreamingActive(): boolean {
return this.streamingSession?.isActive ?? false;
}
getStreamingProgress(): {
sent: number;
expected: number;
buffered: number;
} | null {
if (!this.streamingSession?.isActive) {
return null;
}
return {
sent: this.streamingSession.totalSent,
expected: this.streamingSession.totalExpected,
buffered: this.streamingSession.batchBuffer.length,
};
}
terminate() {
console.debug("Terminating Vector Worker Manager...");
this.resetWorkerState();
}
async resetWorker(): Promise<void> {
console.debug("Resetting vector worker...");
if (this.streamingSession?.isActive) {
await this.endStreamingSession();
}
await this.ensureReady();
this.worker!.postMessage({ type: "reset" });
console.debug("Reset command sent to worker");
}
}
@@ -0,0 +1,184 @@
import Fuse, { type FuseResult } from "fuse.js";
import { getStaticCommands, type StaticCommandItem } from "../core/commands";
import { getDynamicItems } from "../utils/dynamicItems";
import type { CombinedResult } from "../core/types";
import type { IndexItem } from "../indexing/types";
import { searchVectors } from "./vector/vectorSearch";
import type { VectorSearchResult } from "./vector/vectorTypes";
import { jobs } from "../indexing/jobs";
export function createSearchIndexes() {
const commands = getStaticCommands();
const dynamicItems = getDynamicItems();
const commandOptions = {
keys: ["text", "category", "keywords"],
includeScore: true,
includeMatches: true,
threshold: 0.4,
minMatchCharLength: 2,
useExtendedSearch: false,
};
const dynamicOptions = {
keys: [
{ name: "text", weight: 2 },
{ name: "content", weight: 1 },
{ name: "category", weight: 1 },
],
includeScore: true,
includeMatches: true,
threshold: 0.4,
minMatchCharLength: 2,
distance: 100,
useExtendedSearch: true,
};
return {
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
dynamicContentFuse: new Fuse(
dynamicItems,
dynamicOptions,
) as Fuse<IndexItem>,
commands,
dynamicItems,
};
}
export function searchCommands(
commandsFuse: Fuse<StaticCommandItem>,
query: string,
commandIdToItemMap: Map<string, StaticCommandItem>,
limit = 10,
): CombinedResult[] {
if (!commandsFuse) return [];
if (!query.trim()) {
return Array.from(commandIdToItemMap.values())
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) // Sort by priority when no query
.slice(0, limit) // Limit results even when no query
.map((item) => ({
id: item.id,
type: "command" as const,
score: 100 + (item.priority ?? 0),
item,
}));
}
const searchResults = commandsFuse.search(query, { limit });
return searchResults.map((result: FuseResult<StaticCommandItem>) => {
const item = result.item;
const fuseScore = 15 * (1 - (result.score || 0.5));
const score = fuseScore + (item.priority ?? 0);
return {
id: item.id,
type: "command" as const,
score,
item,
matches: result.matches,
};
});
}
export function searchDynamicItems(
dynamicContentFuse: Fuse<IndexItem>,
query: string,
dynamicIdToItemMap: Map<string, IndexItem>,
limit = 10,
sortByRecent: boolean = true,
): CombinedResult[] {
if (!dynamicContentFuse) return [];
if (!query.trim()) {
let items = Array.from(dynamicIdToItemMap.values());
if (sortByRecent) {
items = items.sort((a, b) => b.dateAdded - a.dateAdded);
}
return items.slice(0, limit).map((item) => ({
id: item.id,
type: "dynamic" as const,
score: 80,
item,
}));
}
const now = Date.now();
const searchResults = dynamicContentFuse.search(query, { limit });
return searchResults.map((result: FuseResult<IndexItem>) => {
const item = result.item;
const fuseScore = 10 * (1 - (result.score || 0.5));
let score = fuseScore;
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
score += recencyBoost;
return {
id: item.id,
type: "dynamic" as const,
score,
item,
matches: result.matches,
};
});
}
export async function performSearch(
query: string,
commandsFuse: Fuse<StaticCommandItem>,
commandIdToItemMap: Map<string, StaticCommandItem>,
): Promise<CombinedResult[]> {
// Get all results first
const commandResults = searchCommands(
commandsFuse,
query,
commandIdToItemMap,
);
// Get vector results in parallel
let vectorResults: VectorSearchResult[] = [];
try {
vectorResults = await searchVectors(query);
} catch (e) {}
// Create a map to store our final results, using ID as key to avoid duplicates
const resultMap = new Map<string, CombinedResult>();
// Add command results first (they keep their original scores)
commandResults.forEach((r) => resultMap.set(r.id, r));
// Process dynamic results and vector results together
const seenIds = new Set<string>();
vectorResults.forEach((v) => {
const id = v.object.id;
if (!seenIds.has(id)) {
// This is a semantic match that Fuse missed - add it with the vector similarity as score
let score = v.similarity * 0.5; // High base score for semantic matches
const job = jobs[v.object.category];
if (job && typeof job.boostCriteria === 'function') {
const boost = job.boostCriteria(v.object, query);
if (boost) {
score += boost;
}
}
resultMap.set(id, {
id,
type: "dynamic" as const,
score,
item: v.object,
});
}
});
// Convert to array and sort by score
const results = Array.from(resultMap.values());
results.sort((a, b) => b.score - a.score);
return results;
}
@@ -0,0 +1,45 @@
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
import type { IndexItem } from "../../indexing/types";
import type { SearchResult } from "embeddia";
let vectorIndex: EmbeddingIndex | null = null;
export async function initVectorSearch() {
try {
await initializeModel();
vectorIndex = new EmbeddingIndex([]);
vectorIndex.preloadIndexedDB();
} catch (e) {
console.error("Error initializing vector search", e);
}
}
export interface VectorSearchResult extends SearchResult {
object: IndexItem & { embedding: number[] };
}
export async function searchVectors(
query: string,
topK: number = 20,
): Promise<VectorSearchResult[]> {
if (!vectorIndex) await initVectorSearch();
const queryEmbedding = await getEmbedding(query.slice(0, 100));
const results = await vectorIndex!.search(queryEmbedding, {
topK,
useStorage: "indexedDB",
dedupeEntries: true,
});
// filter results with a similarity below 0.81
const filteredResults = results.filter((r) => r.similarity > 0.81);
return filteredResults as VectorSearchResult[];
}
export async function refreshVectorCache() {
if (!vectorIndex) await initVectorSearch();
vectorIndex!.clearIndexedDBCache();
vectorIndex!.preloadIndexedDB();
}

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