Compare commits

...

72 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
181 changed files with 32938 additions and 23062 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",
+6 -6
View File
@@ -3,19 +3,19 @@ description: Report an issue with the modpack in its unmodified state. For other
labels: bug
title: "[BUG]"
body:
- type: markdown
- 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
- 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
- type: input
attributes:
label: Extension version
description: What version of the extension are you using?
@@ -23,7 +23,7 @@ body:
validations:
required: true
- type: dropdown
- type: dropdown
attributes:
label: Browser
description: Which Browser are you using?
@@ -39,14 +39,14 @@ body:
validations:
required: true
- type: checkboxes
- 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
- type: textarea
attributes:
label: Additional context
description: Screenshots, video or any other information. Include photos of the console if possible
+9 -12
View File
@@ -3,15 +3,14 @@ description: Suggest a new Feature to be added or replaced in BetterSeqtaPLUS
labels: enhancement
title: "[FR]"
body:
- type: checkboxes
- type: checkboxes
attributes:
label: Confirm
options:
- label: "Is this feature request related to a Bug report?"
required: false
- type: input
- 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"
@@ -19,14 +18,13 @@ body:
validations:
required: false
- type: markdown
- 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
- type: dropdown
attributes:
label: Feature type
multiple: false
@@ -37,18 +35,17 @@ body:
validations:
required: true
- type: input
- type: input
attributes:
label: Feature Details
description: Please write, with as much detail as possible, what you would like to see from this mod.
placeholder: I would like to see...
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
- type: textarea
attributes:
label: Additional details
description: Anything else you want to add?
description: Anything else that would help describe your vision (reference images, descriptions, etc)
validations:
required: false
+2 -2
View File
@@ -2,9 +2,9 @@ name: NodeJS Build
on:
push:
branches: [ "main" ]
branches: ["main"]
pull_request:
branches: [ "main" ]
branches: ["main"]
jobs:
build:
+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}`;
+10 -10
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)
if (error) {
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;
}
+18 -18
View File
@@ -1,26 +1,26 @@
// vite-plugin-inline-worker-dev.ts
import { Plugin } from 'vite'
import fs from 'fs/promises'
import { build, transform } from 'esbuild'
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',
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')
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',
})
platform: "browser",
format: "iife",
target: "esnext",
});
const workerCode = result.outputFiles[0].text
const workerCode = result.outputFiles[0].text;
const workerBlobCode = `
const code = ${JSON.stringify(workerCode)};
@@ -28,10 +28,10 @@ export default function InlineWorkerDevPlugin(): Plugin {
const blob = new Blob([code], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob), { type: 'module' });
}
`
return workerBlobCode
}
return null
}
`;
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];
};
+10 -3
View File
@@ -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": {
@@ -75,12 +81,13 @@
"@uiw/codemirror-extensions-color": "^4.23.10",
"@uiw/codemirror-theme-github": "^4.23.10",
"autoprefixer": "^10.4.21",
"client-vector-search": "../client-vector-search",
"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",
+32 -27
View File
@@ -1,55 +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()
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();
@@ -57,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);
}
}
}
+69 -84
View File
@@ -1,63 +1,68 @@
import browser from 'webextension-polyfill'
import browser from "webextension-polyfill";
import type { SettingsState } from "@/types/storage";
import { fetchNews } from './background/news';
import { fetchNews } from "./background/news";
function reloadSeqtaPages() {
const result = browser.tabs.query({})
function open (tabs: any) {
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();
}
+13 -14
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 });
}
@@ -31,13 +31,13 @@ const rssFeedsByCountry: Record<string, string[]> = {
"https://critica.com.pa/rss.xml",
"https://www.panamaamerica.com.pa/rss.xml",
"https://noticiassin.com/feed/",
"https://elcapitalfinanciero.com/feed/"
"https://elcapitalfinanciero.com/feed/",
],
canada: [
"https://www.cbc.ca/cmlink/rss-topstories",
"https://calgaryherald.com/feed",
"https://ottawacitizen.com/feed",
"https://www.montrealgazette.com/feed"
"https://www.montrealgazette.com/feed",
],
singapore: [
"https://www.straitstimes.com/news/singapore/rss.xml",
@@ -49,12 +49,9 @@ const rssFeedsByCountry: Record<string, string[]> = {
],
japan: [
"https://www3.nhk.or.jp/nhkworld/en/news/feeds/",
"https://news.livedoor.com/topics/rss/int.xml"
],
netherlands: [
"https://www.dutchnews.nl/feed/",
"https://www.nrc.nl/rss/"
"https://news.livedoor.com/topics/rss/int.xml",
],
netherlands: ["https://www.dutchnews.nl/feed/", "https://www.nrc.nl/rss/"],
};
export async function fetchNews(source: string, sendResponse: any) {
@@ -63,9 +60,9 @@ export async function fetchNews(source: string, sendResponse: any) {
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`;
@@ -76,7 +73,7 @@ export async function fetchNews(source: string, sendResponse: any) {
const parser = new Parser();
let feeds: string[];
console.log('fetchNews', source)
console.log("fetchNews", source);
if (rssFeedsByCountry[source.toLowerCase()]) {
// If the source is a country, fetch from predefined feeds
@@ -85,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";
+11 -2
View File
@@ -15,7 +15,7 @@
* along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>.
*/
body {
body {
background: transparent;
}
@@ -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;
}
}
+217 -37
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;
@@ -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;
}
@@ -3355,3 +3530,8 @@ body {
-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;
}
+1 -1
View File
@@ -25,7 +25,7 @@
padding-top: 2px;
}
.sub:has(ul>li.hasChildren.active) > .nav > .back {
.sub:has(ul > li.hasChildren.active) > .nav > .back {
display: none !important;
}
+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);
}
+7 -7
View File
@@ -1,11 +1,11 @@
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' {
declare module "*?inlineWorker" {
const value: () => Worker;
export default value;
}
+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>
+1 -1
View File
@@ -8,5 +8,5 @@
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>
+6 -4
View File
@@ -8,15 +8,17 @@
let select: HTMLSelectElement;
</script>
<select
<div class="border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 shadow-2xl rounded-lg w-full overflow-clip">
<select
bind:this={select}
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}>
{option.label}
</option>
{/each}
</select>
</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}
@@ -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());
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ type SettingsPopupCallback = () => void;
* settingsPopup.addListener(() => {
* console.log('Settings popup closed');
* });
*/
*/
class SettingsPopup {
private static instance: SettingsPopup;
private listeners: Set<SettingsPopupCallback> = new Set();
@@ -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";
+8 -8
View File
@@ -1,6 +1,6 @@
import { mount } from "svelte"
import type { SvelteComponent } 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: SvelteComponent | 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>
+17 -15
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 justify-between items-center 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>
@@ -136,8 +132,14 @@
</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 -->
+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 -1
View File
@@ -32,7 +32,7 @@
],
"web_accessible_resources": [
{
"resources": ["*/*", "resources/*", "seqta/utils/migration/migrate.html", "plugins/built-in/globalSearch/*"],
"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;
@@ -1,626 +0,0 @@
# client-vector-search
A client side vector search library that can embed, search, and cache. Works on the browser and server side.
It outperforms OpenAI's text-embedding-ada-002 and is way faster than Pinecone and other VectorDBs.
I'm the founder of [searchbase.app](https://searchbase.app) and we needed this for our product and customers. We'll be using this library in production. You can be sure it'll be maintained and improved.
- Embed documents using transformers by default: gte-small (~30mb).
- Calculate cosine similarity between embeddings.
- Create an index and search on the client side
- Cache vectors with browser caching support.
Lots of improvements are coming!
## Roadmap
Our goal is to build a super simple, fast vector search that works with couple hundred to thousands vectors. ~1k vectors per user covers 99% of the use cases.
We'll initially keep things super simple and sub 100ms
### TODOs
- [ ] add HNSW index that works on node and browser env, don't rely on hnsw binder libs
- [ ] add a proper testing suite and ci/cd for the lib
- [ ] simple health tests
- [ ] mock the @xenova/transformers for jest, it's not happy with it
- [ ] performance tests, recall, memory usage, cpu usage etc.
## Installation
```bash
npm i client-vector-search
```
## Quickstart
This library provides a plug-and-play solution for embedding and vector search. It's designed to be easy to use, efficient, and versatile. Here's a quick start guide:
```ts
import { getEmbedding, EmbeddingIndex } from "client-vector-search";
// getEmbedding is an async function, so you need to use 'await' or '.then()' to get the result
const embedding = await getEmbedding("Apple"); // Returns embedding as number[]
// Each object should have an 'embedding' property of type number[]
const initialObjects = [
{ id: 1, name: "Apple", embedding: embedding },
{ id: 2, name: "Banana", embedding: await getEmbedding("Banana") },
{ id: 3, name: "Cheddar", embedding: await getEmbedding("Cheddar") },
{ id: 4, name: "Space", embedding: await getEmbedding("Space") },
{ id: 5, name: "database", embedding: await getEmbedding("database") },
];
const index = new EmbeddingIndex(initialObjects); // Creates an index
// The query should be an embedding of type number[]
const queryEmbedding = await getEmbedding("Fruit"); // Query embedding
const results = await index.search(queryEmbedding, { topK: 5 }); // Returns top similar objects
// specify the storage type
await index.saveIndex("indexedDB");
const results = await index.search([1, 2, 3], {
topK: 5,
useStorage: "indexedDB",
// storageOptions: { // use only if you overrode the defaults
// indexedDBName: 'clientVectorDB',
// indexedDBObjectStoreName: 'ClientEmbeddingStore',
// },
});
console.log(results);
await index.deleteIndexedDB(); // if you overrode default, specify db name
```
## Trouble-shooting
### NextJS
To use it inside NextJS projects you'll need to update the `next.config.js` file to include the following:
```js
module.exports = {
// Override the default webpack configuration
webpack: (config) => {
// See https://webpack.js.org/configuration/resolve/#resolvealias
config.resolve.alias = {
...config.resolve.alias,
sharp$: false,
"onnxruntime-node$": false,
};
return config;
},
};
```
#### Model load after page is loaded
You can initialize the model before using it to generate embeddings. This will ensure that the model is loaded before you use it and provide a better UX.
```js
import { initializeModel } from "client-vector-search"
...
useEffect(() => {
try {
initializeModel();
} catch (e) {
console.log(e);
}
}, []);
```
## Usage Guide
This guide provides a step-by-step walkthrough of the library's main features. It covers everything from generating embeddings for a string to performing operations on the index such as adding, updating, and removing objects. It also includes instructions on how to save the index to a database and perform search operations within it.
Until we have a reference documentation, you can find all the methods and their usage in this guide. Each step is accompanied by a code snippet to illustrate the usage of the method in question. Make sure to follow along and try out the examples in your own environment to get a better understanding of how everything works.
Let's get started!
### Step 1: Generate Embeddings for String
Generate embeddings for a given string using the `getEmbedding` method.
```ts
const embedding = await getEmbedding("Apple"); // Returns embedding as number[]
```
> **Note**: `getEmbedding` is asynchronous; make sure to use `await`.
---
### Step 2: Calculate Cosine Similarity
Calculate the cosine similarity between two embeddings.
```ts
const similarity = cosineSimilarity(embedding1, embedding2, 6);
```
> **Note**: Both embeddings should be of the same length.
---
### Step 3: Create an Index
Create an index with an initial array of objects. Each object must have an 'embedding' property.
```ts
const initialObjects = [...];
const index = new EmbeddingIndex(initialObjects);
```
---
### Step 4: Add to Index
Add an object to the index.
```ts
const objectToAdd = {
id: 6,
name: "Cat",
embedding: await getEmbedding("Cat"),
};
index.add(objectToAdd);
```
---
### Step 5: Update Index
Update an existing object in the index.
```ts
const vectorToUpdate = {
id: 6,
name: "Dog",
embedding: await getEmbedding("Dog"),
};
index.update({ id: 6 }, vectorToUpdate);
```
---
### Step 6: Remove from Index
Remove an object from the index.
```ts
index.remove({ id: 6 });
```
---
### Step 7: Retrieve from Index
Retrieve an object from the index.
```ts
const vector = index.get({ id: 1 });
```
---
### Step 8: Search the Index
Search the index with a query embedding.
```ts
const queryEmbedding = await getEmbedding("Fruit");
const results = await index.search(queryEmbedding, { topK: 5 });
```
---
### Step 9: Print the Index
Print the entire index to the console.
```ts
index.printIndex();
```
---
### Step 10: Save Index to IndexedDB (for browser)
Save the index to a persistent IndexedDB database. Note
```ts
await index.saveIndex("indexedDB", {
DBName: "clientVectorDB",
objectStoreName: "ClientEmbeddingStore",
});
```
---
### Important: Search in indexedDB
Perform a search operation in the IndexedDB.
````ts
const results = await index.search(queryEmbedding, {
topK: 5,
useStorage: "indexedDB",
storageOptions: { // only if you want to override the default options, defaults are below
indexedDBName: 'clientVectorDB',
indexedDBObjectStoreName: 'ClientEmbeddingStore'
}
});
---
### Delete Database
To delete an entire database.
```ts
await IndexedDbManager.deleteIndexedDB("clientVectorDB");
````
---
### Delete Object Store
To delete an object store from a database.
```ts
await IndexedDbManager.deleteIndexedDBObjectStore(
"clientVectorDB",
"ClientEmbeddingStore",
);
```
---
### Retrieve All Objects
To retrieve all objects from a specific object store.
```ts
const allObjects = await IndexedDbManager.getAllObjectsFromIndexedDB(
"clientVectorDB",
"ClientEmbeddingStore",
);
```
# THE MAIN INDEX.TS FILE THAT YOU ARE IMPORTING FROM
```index.ts
const DEFAULT_TOP_K = 3;
interface Filter {
[key: string]: any;
}
import Cache from './cache';
import { IndexedDbManager } from './indexedDB';
import { cosineSimilarity } from './utils';
export { ExperimentalHNSWIndex } from './hnsw';
// uncomment if you want to test indexedDB implementation in node env for faster dev cycle
// import { IDBFactory } from 'fake-indexeddb';
// const indexedDB = new IDBFactory();
export interface SearchResult {
similarity: number;
object: any;
}
type StorageOptions = 'indexedDB' | 'localStorage' | 'none';
/**
* Interface for search options in the EmbeddingIndex class.
* topK: The number of top similar items to return.
* filter: An optional filter to apply to the objects before searching.
* useStorage: A flag to indicate whether to use storage options like indexedDB or localStorage.
*/
interface SearchOptions {
topK?: number;
filter?: Filter;
useStorage?: StorageOptions;
storageOptions?: { indexedDBName: string; indexedDBObjectStoreName: string }; // TODO: generalize it to localStorage as well
}
const cacheInstance = Cache.getInstance();
let pipe: any;
let currentModel: string;
export const initializeModel = async (
model: string = 'Xenova/gte-small',
): Promise<void> => {
if (model !== currentModel) {
const transformersModule = await import('@xenova/transformers');
const pipeline = transformersModule.pipeline;
pipe = await pipeline('feature-extraction', model);
currentModel = model;
}
};
export const getEmbedding = async (
text: string,
precision: number = 7,
options = { pooling: 'mean', normalize: false },
model = 'Xenova/gte-small',
): Promise<number[]> => {
const cachedEmbedding = cacheInstance.get(text);
if (cachedEmbedding) {
return Promise.resolve(cachedEmbedding);
}
if (model !== currentModel) {
await initializeModel(model);
}
const output = await pipe(text, options);
const roundedOutput = Array.from(output.data as number[]).map(
(value: number) => parseFloat(value.toFixed(precision)),
);
cacheInstance.set(text, roundedOutput);
return Array.from(roundedOutput);
};
export class EmbeddingIndex {
private objects: Filter[];
private keys: string[];
constructor(initialObjects?: Filter[]) {
// TODO: add support for options while creating index such as {... indexedDB: true, ...}
this.objects = [];
this.keys = [];
if (initialObjects && initialObjects.length > 0) {
initialObjects.forEach((obj) => this.validateAndAdd(obj));
if (initialObjects[0]) {
this.keys = Object.keys(initialObjects[0]);
}
}
}
private findVectorIndex(filter: Filter): number {
return this.objects.findIndex((object) =>
Object.keys(filter).every((key) => object[key] === filter[key]),
);
}
private validateAndAdd(obj: Filter) {
if (!Array.isArray(obj.embedding) || obj.embedding.some(isNaN)) {
throw new Error(
'Object must have an embedding property of type number[]',
);
}
if (this.keys.length === 0) {
this.keys = Object.keys(obj);
} else if (!this.keys.every((key) => key in obj)) {
throw new Error(
'Object must have the same properties as the initial objects',
);
}
this.objects.push(obj);
}
add(obj: Filter) {
this.validateAndAdd(obj);
}
// Method to update an existing vector in the index
update(filter: Filter, vector: Filter) {
const index = this.findVectorIndex(filter);
if (index === -1) {
throw new Error('Vector not found');
}
if (vector.hasOwnProperty('embedding')) {
// Validate and add the new vector
this.validateAndAdd(vector);
}
// Replace the old vector with the new one
this.objects[index] = Object.assign(this.objects[index] as Filter, vector);
}
// Method to remove a vector from the index
remove(filter: Filter) {
const index = this.findVectorIndex(filter);
if (index === -1) {
throw new Error('Vector not found');
}
// Remove the vector from the index
this.objects.splice(index, 1);
}
// Method to remove multiple vectors from the index
removeBatch(filters: Filter[]) {
filters.forEach((filter) => {
const index = this.findVectorIndex(filter);
if (index !== -1) {
// Remove the vector from the index
this.objects.splice(index, 1);
}
});
}
// Method to retrieve a vector from the index
get(filter: Filter) {
const vector = this.objects[this.findVectorIndex(filter)];
return vector || null;
}
size(): number {
// Returns the size of the index
return this.objects.length;
}
clear() {
this.objects = [];
}
async search(
queryEmbedding: number[],
options: SearchOptions = {
topK: 3,
useStorage: 'none',
storageOptions: {
indexedDBName: 'clientVectorDB',
indexedDBObjectStoreName: 'ClientEmbeddingStore',
},
},
): Promise<SearchResult[]> {
const topK = options.topK || DEFAULT_TOP_K;
const filter = options.filter || {};
const useStorage = options.useStorage || 'none';
if (useStorage === 'indexedDB') {
const DBname = options.storageOptions?.indexedDBName || 'clientVectorDB';
const objectStoreName =
options.storageOptions?.indexedDBObjectStoreName ||
'ClientEmbeddingStore';
if (typeof indexedDB === 'undefined') {
console.error('IndexedDB is not supported');
throw new Error('IndexedDB is not supported');
}
const results = await this.loadAndSearchFromIndexedDB(
DBname,
objectStoreName,
queryEmbedding,
topK,
filter,
);
return results;
} else {
// Compute similarities
const similarities = this.objects
.filter((object) =>
Object.keys(filter).every((key) => object[key] === filter[key]),
)
.map((obj) => ({
similarity: cosineSimilarity(queryEmbedding, obj.embedding),
object: obj,
}));
// Sort by similarity and return topK results
return similarities
.sort((a, b) => b.similarity - a.similarity)
.slice(0, topK);
}
}
printIndex() {
console.log('Index Content:');
this.objects.forEach((obj, idx) => {
console.log(`Item ${idx + 1}:`, obj);
});
}
async saveIndex(
storageType: string,
options: { DBName: string; objectStoreName: string } = {
DBName: 'clientVectorDB',
objectStoreName: 'ClientEmbeddingStore',
},
) {
if (storageType === 'indexedDB') {
await this.saveToIndexedDB(options.DBName, options.objectStoreName);
} else {
throw new Error(
`Unsupported storage type: ${storageType} \n Supported storage types: "indexedDB"`,
);
}
}
async saveToIndexedDB(
DBname: string = 'clientVectorDB',
objectStoreName: string = 'ClientEmbeddingStore',
): Promise<void> {
if (typeof indexedDB === 'undefined') {
console.error('IndexedDB is not defined');
throw new Error('IndexedDB is not supported');
}
if (!this.objects || this.objects.length === 0) {
throw new Error('Index is empty. Nothing to save');
}
try {
const db = await IndexedDbManager.create(DBname, objectStoreName);
await db.addToIndexedDB(this.objects);
console.log(
`Index saved to database '${DBname}' object store '${objectStoreName}'`,
);
} catch (error) {
console.error('Error saving index to database:', error);
throw new Error('Error saving index to database');
}
}
async loadAndSearchFromIndexedDB(
DBname: string = 'clientVectorDB',
objectStoreName: string = 'ClientEmbeddingStore',
queryEmbedding: number[],
topK: number,
filter: { [key: string]: any },
): Promise<SearchResult[]> {
const db = await IndexedDbManager.create(DBname, objectStoreName);
const generator = db.dbGenerator();
const results: { similarity: number; object: any }[] = [];
for await (const record of generator) {
if (Object.keys(filter).every((key) => record[key] === filter[key])) {
const similarity = cosineSimilarity(queryEmbedding, record.embedding);
results.push({ similarity, object: record });
}
}
results.sort((a, b) => b.similarity - a.similarity);
return results.slice(0, topK);
}
async deleteIndexedDB(DBname: string = 'clientVectorDB'): Promise<void> {
if (typeof indexedDB === 'undefined') {
console.error('IndexedDB is not defined');
throw new Error('IndexedDB is not supported');
}
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(DBname);
request.onsuccess = () => {
console.log(`Database '${DBname}' deleted`);
resolve();
};
request.onerror = (event) => {
console.error('Failed to delete database', event);
reject(new Error('Failed to delete database'));
};
});
}
async deleteIndexedDBObjectStore(
DBname: string = 'clientVectorDB',
objectStoreName: string = 'ClientEmbeddingStore',
): Promise<void> {
const db = await IndexedDbManager.create(DBname, objectStoreName);
try {
await db.deleteIndexedDBObjectStoreFromDB(DBname, objectStoreName);
console.log(
`Object store '${objectStoreName}' deleted from database '${DBname}'`,
);
} catch (error) {
console.error('Error deleting object store:', error);
throw new Error('Error deleting object store');
}
}
async getAllObjectsFromIndexedDB(
DBname: string = 'clientVectorDB',
objectStoreName: string = 'ClientEmbeddingStore',
): Promise<any[]> {
const db = await IndexedDbManager.create(DBname, objectStoreName);
const objects: any[] = [];
for await (const record of db.dbGenerator()) {
objects.push(record);
}
return objects;
}
}
```
@@ -1,50 +0,0 @@
<script lang="ts">
import { highlightMatch, highlightSnippet, stripHtmlButKeepHighlights } from '../utils/highlight';
import type { DynamicContentItem } from '../utils/dynamicItems';
import type { FuseResultMatch } from '../core/types';
const { item, isSelected, searchTerm, matches } = $props<{
item: DynamicContentItem;
isSelected: boolean;
searchTerm: string;
matches?: readonly FuseResultMatch[];
}>();
</script>
<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' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
>
<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'}">{item.metadata?.icon || '\ue924'}</div>
<span class="ml-4 text-lg truncate">
{@html stripHtmlButKeepHighlights(highlightMatch(item.text, searchTerm, 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">
{@html stripHtmlButKeepHighlights(highlightSnippet(item.content, searchTerm, matches))}
</div>
{/if}
</button>
<style>
:global(.highlight) {
background-color: rgba(255, 213, 0, 0.3);
font-weight: 500;
border-radius: 2px;
padding: 0 1px;
margin: 0 -1px;
}
.dark :global(.highlight) {
background-color: rgba(255, 230, 100, 0.4);
}
.due-badge {
font-size: 0.65rem;
}
</style>
@@ -1,7 +1,6 @@
<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte';
import { unitFullNames } from './unitMap';
import * as math from 'mathjs';
import { calculateExpression } from '../utils/calculator';
let { searchTerm = '', isSelected = false } = $props<{ searchTerm: string, isSelected: boolean }>();
@@ -13,73 +12,32 @@
let isCalculating = $state(false);
let inputUnit = $state<string>('');
let outputUnit = $state<string>('');
let isPartial = $state(false);
function detectUnit(expression: string): string {
try {
const unit = math.unit(expression);
if (unit) {
// Get the base unit name
const unitStr = unit.formatUnits();
return unitFullNames[unitStr] || unitStr;
}
} catch (e) {
// Not a unit or invalid expression
}
return '';
}
// Process the input with debounce to avoid unnecessary calculations
const processInput = (input: string) => {
try {
if (
!input.trim() ||
(input.trim().length <= 2 && !/\d/.test(input))
) {
result = null;
inputUnit = '';
outputUnit = '';
dispatch('hasResult', null);
return;
}
isCalculating = true;
// Let mathjs handle everything
const evaluated = math.evaluate(input.replace('**', '^'));
try {
const calcResult = calculateExpression(input);
// Format the result
if (evaluated !== undefined) {
if (math.typeOf(evaluated) === 'Unit') {
// Handle unit conversion results
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
inputUnit = detectUnit(input);
outputUnit = detectUnit(result);
} else if (typeof evaluated === 'number') {
// Handle regular numbers
if (math.round(evaluated) === evaluated) {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
} else {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
}
inputUnit = '';
outputUnit = '';
} else {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
inputUnit = '';
outputUnit = '';
}
dispatch('hasResult', result);
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) {
// If mathjs throws an error, this isn't a valid expression
result = null;
inputUnit = '';
outputUnit = '';
isPartial = false;
dispatch('hasResult', null);
} finally {
isCalculating = false;
@@ -96,7 +54,7 @@
</script>
{#if result !== null}
<div class="p-2">
<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">
@@ -124,7 +82,7 @@
{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 || 'Result'}
{outputUnit || (isPartial ? 'Partial' : 'Result')}
</div>
</div>
{:else}
@@ -6,31 +6,109 @@
import { type StaticCommandItem } from '../core/commands';
import type { CombinedResult } from '../core/types';
import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils';
import { highlightMatch, highlightSnippet, stripHtmlButKeepHighlights } from '../utils/highlight';
import Fuse from 'fuse.js';
import Calculator from './Calculator.svelte';
import { actionMap } from '../indexing/actions';
import type { IndexItem, HydratedIndexItem } from '../indexing/types';
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,
showRecentFirst
searchHotkey: initialSearchHotkey
} = $props<{
transparencyEffects: boolean,
showRecentFirst: boolean
searchHotkey: string
}>();
let commandsFuse = $state<Fuse<StaticCommandItem>>();
let dynamicContentFuse = $state<Fuse<HydratedIndexItem>>();
// Make searchHotkey reactive to setting changes
let currentSearchHotkey = $state(initialSearchHotkey);
const dynamicIdToItemMap = $state(new Map<string, HydratedIndexItem>());
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;
@@ -42,12 +120,18 @@
window.addEventListener('indexing-progress', progressHandler as EventListener);
const itemsUpdatedHandler = () => {
console.log('Search Bar received items-updated event, re-indexing...');
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);
@@ -68,58 +152,23 @@
console.debug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`);
}
let commandPalleteOpen = $state(false);
let searchTerm = $state('');
let selectedIndex = $state(0);
let searchbar = $state<HTMLInputElement>();
let combinedResults = $state<CombinedResult[]>([]);
let isLoading = $state(false);
let prevSearchTerm = $state('');
let calculatorResult = $state<string | null>(null);
const updateCalculatorState = (hasResult: string | null) => {
calculatorResult = hasResult;
};
onMount(() => {
setupSearchIndexes();
// @ts-ignore - Intentionally adding to window
window.setCommandPalleteOpen = (open: boolean) => {
commandPalleteOpen = open;
};
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
commandPalleteOpen = true;
tick().then(() => searchbar?.focus());
}
if (e.key === 'Escape') {
commandPalleteOpen = false;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
});
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,
dynamicContentFuse,
commandIdToItemMap,
dynamicIdToItemMap,
showRecentFirst
);
} else {
combinedResults = [];
@@ -141,7 +190,6 @@
} else {
searchTerm = '';
selectedIndex = 0;
prevSearchTerm = '';
combinedResults = [];
}
});
@@ -156,16 +204,24 @@
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 | HydratedIndexItem) {
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]) {
@@ -188,6 +244,7 @@
};
const handleKeyNav = (e: KeyboardEvent) => {
// Handle regular navigation
if (e.key === 'ArrowDown') {
e.preventDefault();
selectNext();
@@ -210,18 +267,18 @@
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"
<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) => e.key === 'Escape' && (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' : 'bg-white dark:bg-zinc-900' }"
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) => {
onclick={(e: MouseEvent) => {
e.stopPropagation();
}}
onkeydown={(e) => {
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Escape') {
commandPalleteOpen = false;
}
@@ -242,7 +299,10 @@
/>
</div>
<ul class="overflow-y-auto max-h-[24rem] text-base scroll-py-2 p-1 gap-0.5 flex flex-col">
<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}
@@ -257,40 +317,39 @@
{#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
{isSelected ? 'bg-zinc-900/5 dark:bg-white/10 text-zinc-900 dark:text-white' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
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 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'}">{staticItem.icon}</div>
<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">
{@html highlightMatch(staticItem.text, searchTerm, result.matches)}
<HighlightedText text={staticItem.text} term={searchTerm} matches={result.matches} />
</span>
{#if staticItem.keybindLabel}
<div class="flex-none ml-auto">
{@render Shortcut({ text: '', keybind: staticItem.keybindLabel })}
</div>
{/if}
</button>
{:else if result.type === 'dynamic'}
{@const dynamicItem = item as HydratedIndexItem}
{#if dynamicItem.renderComponent}
<dynamicItem.renderComponent
{@const dynamicItem = item as IndexItem}
{@const RenderComponent = renderComponentMap[dynamicItem.renderComponentId]}
{#if RenderComponent}
<RenderComponent
item={dynamicItem}
isSelected={isSelected}
searchTerm={searchTerm}
matches={result.matches}
on:click={() => executeItemAction(dynamicItem)}
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' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
{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">
{@html stripHtmlButKeepHighlights(highlightMatch(dynamicItem.text, searchTerm, result.matches))}
<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}
@@ -298,7 +357,7 @@
</div>
{#if dynamicItem.content}
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start">
{@html stripHtmlButKeepHighlights(highlightSnippet(dynamicItem.content, searchTerm, result.matches))}
<HighlightedText text={dynamicItem.content} term={searchTerm} matches={result.matches} />
</div>
{/if}
</button>
@@ -322,18 +381,7 @@
</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-5 text-sm text-zinc-500 dark:text-zinc-400">
<div class="flex gap-4 items-center">
{#if !calculatorResult}
{#if selectedIndex >= 0 && selectedIndex < combinedResults.length}
{@const item = combinedResults[selectedIndex].item}
{#if 'keybind' in item && item.keybind}
{@render Shortcut({ text: 'Shortcut', keybind: [ ...(item?.keybindLabel ?? []) ] })}
{/if}
{/if}
{/if}
</div>
<div>
<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}
@@ -356,7 +404,6 @@
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
@@ -368,23 +415,9 @@
<div class="flex gap-2 items-center">
<div class="flex gap-1 items-center">
{#each keybind as key}
<kbd class="px-1 py-0.5 text-[0.8rem] text-center align-middle rounded min-w-6 bg-zinc-100 dark:bg-zinc-100/10">{key}</kbd>
<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}
<style>
:global(.highlight) {
background-color: rgba(200, 200, 200, 0.3);
font-weight: 500;
border-radius: 2px;
padding: 0 2px;
margin: 0 -2px;
}
.dark :global(.highlight) {
background-color: rgba(79, 79, 79, 0.2);
}
</style>
@@ -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>
@@ -1,5 +1,6 @@
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;
@@ -16,14 +17,127 @@ export interface StaticCommandItem extends BaseCommandItem {
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",
keybind: ["alt+h"],
keybindLabel: ["Alt", "H"],
action: () => {
window.location.hash = "?page=/home";
loadHomePage();
@@ -35,8 +149,6 @@ const staticCommands: StaticCommandItem[] = [
icon: "\uebfd",
category: "navigation",
text: "Direct Messages",
keybind: ["alt+m"],
keybindLabel: ["Alt", "M"],
action: () => {
window.location.hash = "?page=/messages";
},
@@ -47,13 +159,27 @@ const staticCommands: StaticCommandItem[] = [
icon: "\ue9cd",
category: "navigation",
text: "Timetable",
keybind: ["alt+t"],
keybindLabel: ["Alt", "T"],
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",
@@ -62,17 +188,44 @@ const staticCommands: StaticCommandItem[] = [
keybind: ["alt+a"],
keybindLabel: ["Alt", "A"],
action: () => {
window.location.hash = "?page=/assessments";
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: 2,
priority: 3,
keywords: ["theme", "appearance"],
},
];
@@ -2,21 +2,30 @@ import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings";
import {
booleanSetting,
buttonSetting,
defineSettings,
Setting,
stringSetting,
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: stringSetting({
default: "ctrl+k",
searchHotkey: hotkeySetting({
default: getDefaultHotkey(),
title: "Search Hotkey",
description: "Keyboard shortcut to open the search (cmd on Mac)",
description: "Keyboard shortcut to open the search",
}),
showRecentFirst: booleanSetting({
default: true,
@@ -33,6 +42,43 @@ const settings = defineSettings({
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> {
@@ -47,6 +93,9 @@ class GlobalSearchPlugin extends BasePlugin<typeof settings> {
@Setting(settings.runIndexingOnLoad)
runIndexingOnLoad!: boolean;
@Setting(settings.resetIndex)
resetIndex!: () => void;
}
const settingsInstance = new GlobalSearchPlugin();
@@ -58,13 +107,59 @@ const globalSearchPlugin: Plugin<typeof settings> = {
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();
@@ -76,8 +171,8 @@ const globalSearchPlugin: Plugin<typeof settings> = {
if (title) {
mountSearchBar(title, api, appRef);
} else {
await waitForElm("#title", true, 100, 60);
mountSearchBar(document.querySelector("#title") as Element, api, appRef);
const titleElement = await waitForElm("#title", true, 100, 60);
mountSearchBar(titleElement, api, appRef);
}
return () => {
@@ -2,29 +2,53 @@ 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 }
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;">K</span>
<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" });
@@ -38,6 +62,7 @@ export function mountSearchBar(
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);
@@ -45,12 +70,30 @@ export function mountSearchBar(
}
export function cleanupSearchBar(appRef: { current: any }) {
const searchButton = document.querySelector(".search-trigger");
const searchRoot = document.querySelector(".global-search-root");
if (searchButton) searchButton.remove();
if (searchRoot) searchRoot.remove();
// Clean up workers
VectorWorkerManager.getInstance().terminate();
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(() => {});
}
@@ -56,3 +56,16 @@
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);
}
@@ -1,5 +1,5 @@
import type { StaticCommandItem } from "./commands";
import type { HydratedIndexItem } from "../indexing/types";
import type { IndexItem } from "../indexing/types";
export interface MatchIndices {
readonly 0: number;
@@ -16,7 +16,7 @@ export interface CombinedResult {
id: string;
type: "command" | "dynamic";
score: number;
item: StaticCommandItem | HydratedIndexItem;
item: StaticCommandItem | IndexItem;
matches?: readonly FuseResultMatch[];
}
@@ -1,4 +1,7 @@
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;
@@ -26,15 +29,59 @@ interface AssessmentMetadata {
type ActionHandler<T = any> = (item: IndexItem & { metadata: T }) => void;
export const actionMap: Record<string, ActionHandler<any>> = {
message: ((item: IndexItem & { metadata: MessageMetadata }) => {
window.location.hash = `#?page=/messages&id=${item.metadata.messageId}`;
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: ((item: IndexItem & { metadata: AssessmentMetadata }) => {
assessment: (async (item: IndexItem & { metadata: AssessmentMetadata }) => {
if (item.metadata.isMessageBased) {
window.location.hash = `#?page=/messages&id=${item.metadata.messageId}`;
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>,
};
@@ -1,15 +1,29 @@
import { clear, getAll, put, remove } from "./db";
import { clear, getAll, get, put, remove } from "./db";
import { jobs } from "./jobs";
import { renderComponentMap } from "./renderComponents";
import type { HydratedIndexItem, IndexItem, Job, JobContext } from "./types";
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();
@@ -39,67 +53,117 @@ async function updateLastRunMeta(jobId: string): Promise<void> {
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
}
function shouldIndex(): boolean {
const last = parseInt(localStorage.getItem(LOCK_KEY) || "0", 10);
return isNaN(last) || Date.now() - last > LOCK_TIMEOUT;
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() {
localStorage.setItem(LOCK_KEY, `${Date.now()}`);
const lockId = localStorage.getItem(LOCK_KEY);
if (!lockId) return;
heartbeatTimer = setInterval(() => {
localStorage.setItem(LOCK_KEY, `${Date.now()}`);
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) {
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<HydratedIndexItem[]> {
const all: HydratedIndexItem[] = [];
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 items = (await getAll(jobId)) as IndexItem[];
const job = jobs[jobId];
const renderComponent = renderComponentMap[job.renderComponentId];
if (!renderComponent) {
console.warn(`Render component not found for job ${jobId} (ID: ${job.renderComponentId})`);
}
for (const item of items) {
// Ensure item has all required fields before pushing
if (item && item.id && item.text && item.category && item.actionId && job.renderComponentId) {
all.push({
...item,
renderComponent: renderComponent || undefined, // Assign undefined if not found
});
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 ${jobId}:`, item);
console.warn(`Skipping invalid item from job store ${jobId}:`, item);
}
}
} catch (error) {
console.error(`Error loading items for job ${jobId}:`, error);
console.error(`Error loading items for job store ${jobId}:`, error);
}
}
console.debug(`[Indexer] Loaded ${all.length} items from non-vector storage.`);
console.debug(
`[Indexer] Loaded ${all.length} items from all primary stores.`,
);
return all;
}
export async function runIndexing(): Promise<void> {
if (!shouldIndex()) {
if (!(await acquireLock())) {
console.debug(
"%c[Indexer] Skipping indexing (another tab has the lock)",
"%c[Indexer] Could not acquire lock - another tab is indexing or this tab is already indexing",
"color: gray",
);
return;
@@ -110,15 +174,18 @@ export async function runIndexing(): Promise<void> {
const jobIds = Object.keys(jobs);
let completedJobs = 0;
// Add an extra step for vectorization
const totalSteps = jobIds.length + 1;
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs");
const allItemsFromJobs: HydratedIndexItem[] = [];
let hasStreamingJobs = false;
// --- Step 1: Run Fetching/Storing Jobs (Main Thread) ---
for (const jobId of jobIds) {
dispatchProgress(completedJobs, totalSteps, true, `Running job: ${jobs[jobId].label}`);
dispatchProgress(
completedJobs,
totalSteps,
true,
`Running job: ${jobs[jobId].label}`,
);
const job = jobs[jobId];
const lastRun = await getLastRunMeta(jobId);
@@ -128,30 +195,42 @@ export async function runIndexing(): Promise<void> {
"color: gray",
);
completedJobs++;
dispatchProgress(completedJobs, totalSteps, true, `Skipped job: ${job.label}`);
dispatchProgress(
completedJobs,
totalSteps,
true,
`Skipped job: ${job.label}`,
);
continue;
}
// These DB operations happen on the main thread (acceptable per request)
const getStoredItems = async () => await getAll(jobId);
const setStoredItems = async (items: IndexItem[]) => {
await clear(jobId);
// Add validation before putting
const validItems = items.filter(i => i && i.id);
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}] Filtered out ${items.length - validItems.length} invalid items before storing.`);
console.warn(
`[Indexer Job ${jobId} -> Store ${targetStore}] Filtered out ${items.length - validItems.length} invalid items before storing.`,
);
}
await Promise.all(validItems.map((i) => put(jobId, i, i.id)));
await Promise.all(validItems.map((i) => put(targetStore, i, i.id)));
};
const addItem = async (item: IndexItem) => {
if (item && item.id) { // Add validation
await put(jobId, item, item.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}] Attempted to add invalid item:`, item);
console.warn(
`[Indexer Job ${jobId} -> Store ${targetStore}] Attempted to add invalid item:`,
item,
);
}
};
const removeItem = async (id: string) => {
await remove(jobId, id);
const removeItem = async (id: string, storeId?: string) => {
const targetStore = storeId ?? jobId;
await remove(targetStore, id);
};
const ctx: JobContext = {
@@ -159,6 +238,8 @@ export async function runIndexing(): Promise<void> {
setStoredItems,
addItem,
removeItem,
getProgress: () => loadProgress(jobId),
setProgress: (p) => saveProgress(jobId, p),
};
console.debug(`%c[Indexer] Running job "${jobId}"...`, "color: #4ea1ff");
@@ -170,110 +251,158 @@ export async function runIndexing(): Promise<void> {
let merged = mergeItems(stored, newItemsRaw);
if (job.purge) merged = job.purge(merged);
await setStoredItems(merged); // Store merged non-vector data
await setStoredItems(merged);
await updateLastRunMeta(jobId);
// Hydrate items for vector processing
const renderComponent = renderComponentMap[job.renderComponentId];
if (!renderComponent) {
console.warn(`Render component not found for job ${jobId} (ID: ${job.renderComponentId}) during hydration`);
if (jobId === 'messages' || jobId === 'notifications') {
hasStreamingJobs = true;
}
const hydratedItems = merged
.filter(item => item && item.id && item.text && item.category && item.actionId && job.renderComponentId) // Filter invalid before hydrating
.map((item) => ({
...item,
renderComponent: renderComponent || undefined, // Assign undefined if not found
}));
if (hydratedItems.length !== merged.length) {
console.warn(`[Indexer Job ${jobId}] Filtered out ${merged.length - hydratedItems.length} invalid items during hydration.`);
}
allItemsFromJobs.push(...hydratedItems);
console.debug(
`%c[Indexer] ${job.label}: ${newItemsRaw.length} new items fetched, ${merged.length} total stored (non-vector).`,
`%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.label} failed:`, "color: red");
console.debug(`%c[Indexer] Job ${job.label} failed:`, "color: red");
console.error(err);
}
completedJobs++;
dispatchProgress(completedJobs, totalSteps, true, `Finished job: ${job.label}`);
dispatchProgress(
completedJobs,
totalSteps,
true,
`Finished job: ${job.label}`,
);
}
// --- Step 2: Delegate Vectorization to Worker (Off Main Thread) ---
if (allItemsFromJobs.length > 0) {
if (!hasStreamingJobs) {
const allItemsInPrimaryStores = await loadAllStoredItems();
if (allItemsInPrimaryStores.length > 0) {
console.debug(
`%c[Indexer] Sending ${allItemsFromJobs.length} items to worker for vectorization...`,
`%c[Indexer] Sending ${allItemsInPrimaryStores.length} items from primary stores to worker for vectorization check...`,
"color: #4ea1ff",
);
dispatchProgress(completedJobs, totalSteps, true, "Starting vectorization");
dispatchProgress(completedJobs, totalSteps, true, "Starting vectorization of stored items");
try {
const workerManager = VectorWorkerManager.getInstance();
// Pass a progress callback to the worker manager
await workerManager.processItems(allItemsFromJobs, (progress) => {
// Update overall progress based on worker feedback
let detailMessage = progress.message || '';
if (progress.status === 'processing' && progress.total && progress.processed !== undefined) {
await workerManager.processItems(allItemsInPrimaryStores, (progress) => {
let detailMessage = progress.message || "";
if (
progress.status === "processing" &&
progress.total &&
progress.processed !== undefined
) {
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
// You could potentially update the 'completed' count more granularly here
// For simplicity, we'll just update the detail message
} else if (progress.status === 'complete') {
} else if (progress.status === "complete") {
detailMessage = "Vectorization complete";
// Mark the vectorization step as complete
dispatchProgress(totalSteps, totalSteps, true, "Vectorization finished");
} else if (progress.status === 'error') {
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Indexing finished",
detailMessage
);
} else if (progress.status === "error") {
detailMessage = `Vectorization error: ${progress.message}`;
dispatchProgress(completedJobs, totalSteps, true, "Vectorization failed", detailMessage); // Show error
} else if (progress.status === 'started') {
dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization failed",
detailMessage,
);
} else if (progress.status === "started") {
detailMessage = `Vectorization started for ${progress.total} items`;
} else if (progress.status === 'cancelled') {
} else if (progress.status === "cancelled") {
detailMessage = `Vectorization cancelled: ${progress.message}`;
dispatchProgress(completedJobs, totalSteps, true, "Vectorization cancelled", detailMessage);
dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization cancelled",
detailMessage,
);
}
// Update the status detail
dispatchProgress(completedJobs, totalSteps, true, "Vectorization in progress", detailMessage);
// When worker signals completion of *its* task, mark the final step complete
if (progress.status === 'complete') {
completedJobs++; // Increment completion count *after* vectorization finishes
dispatchProgress(completedJobs, totalSteps, false, "Indexing finished"); // Set indexing to false
} else if (progress.status === 'error' || progress.status === 'cancelled') {
// Don't increment completed count on failure/cancel, just stop indexing indicator
dispatchProgress(completedJobs, totalSteps, false, "Indexing stopped due to error/cancel");
if (progress.status !== "complete" && progress.status !== "error" && progress.status !== "cancelled") {
dispatchProgress(
completedJobs,
totalSteps,
true,
"Vectorization in progress",
detailMessage,
);
}
});
console.debug("%c[Indexer] Vectorization task sent to worker.", "color: green");
// Note: runIndexing might return *before* vectorization is complete now.
// The progress updates will signal the true end state.
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)); // Stop indexing indicator
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 to send for vectorization.", "color: gray");
// If no vectorization needed, indexing is done here.
completedJobs++; // Count the "skipped" vectorization step
dispatchProgress(completedJobs, totalSteps, false, "Indexing finished (no vectorization needed)");
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)",
);
}
// Stop heartbeat ONLY when all jobs *and* the vectorization dispatch are done.
// The actual *completion* of vectorization is now asynchronous.
stopHeartbeat();
// Final progress update might be handled by the worker callback now.
// dispatchProgress(completedJobs, totalSteps, false); // This might be premature
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>();
// Prioritize incoming items if IDs clash
for (const item of existing) {
if (item && item.id) map.set(item.id, item);
}
@@ -1,351 +1,12 @@
import type { Job } from "./types";
import type { IndexItem } from "./types";
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 MessageListResponse {
payload: {
hasMore: boolean;
messages: {
date: string;
attachments: boolean;
attachmentCount: number;
read: number;
sender: string;
sender_id: number;
sender_type: string;
subject: string;
id: number;
participants: Array<{
name: string;
photo: string;
type: string;
}>;
}[];
ts: string;
};
status: string;
}
interface MessageContentResponse {
payload: {
date: string;
blind: boolean;
read: boolean;
subject: string;
sender_type: string;
sender_id: number;
starred: boolean;
contents: string;
sender: string;
files: any[];
id: number;
participants: Array<{
read: number;
name: string;
photo: string;
id: number;
type: string;
}>;
};
status: string;
}
// Helper to strip HTML tags from text
function stripHtmlTags(html: string): string {
return html.replace(/<[^>]*>/g, "");
}
// Helper to fetch messages with pagination
async function fetchMessages(
offset: number = 0,
limit: number = 100,
): Promise<MessageListResponse> {
const response = 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 await response.json();
}
// Helper to fetch message content
async function fetchMessageContent(
messageId: number,
): Promise<MessageContentResponse> {
const response = 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: messageId,
}),
},
);
return await response.json();
}
// Helper to fetch notifications
async function fetchNotifications(): Promise<Notification[]> {
const response = 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 response.json();
return json.notifications ?? [];
}
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: {
id: "messages",
label: "Messages",
renderComponentId: "message",
frequency: { type: "expiry", afterMs: 1000 * 60 * 5 }, // every 5 minutes
run: async (ctx) => {
// Get existing items first
const existing = await ctx.getStoredItems();
const existingIds = new Set(existing.map((i) => i.id));
const newItems: IndexItem[] = [];
let offset = 0;
const limit = 100;
let hasMore = true;
let consecutiveExisting = 0;
// Fetch all messages with pagination
while (hasMore) {
try {
const response = await fetchMessages(offset, limit);
if (response.status !== "200") {
console.error("Failed to fetch messages:", response);
break;
}
const messages = response.payload.messages;
hasMore = response.payload.hasMore;
// Process each message
for (const message of messages) {
const id = message.id.toString();
// Skip if we already have this message
if (existingIds.has(id)) {
consecutiveExisting++;
// If we've found 20 consecutive existing messages, assume we've caught up
if (consecutiveExisting >= 20) {
console.debug(
"[Messages Job] Found 20 consecutive existing messages, stopping fetch",
);
hasMore = false;
break;
}
continue;
}
// Reset consecutive counter when we find a new message
consecutiveExisting = 0;
try {
// Fetch message content
const contentResponse = await fetchMessageContent(message.id);
if (contentResponse.status !== "200") {
console.error(
"Failed to fetch message content:",
contentResponse,
);
continue;
}
const content = stripHtmlTags(contentResponse.payload.contents);
newItems.push({
id,
text: message.subject,
category: "messages",
content: `From: ${message.sender}\n\n${content}`,
dateAdded: new Date(message.date).getTime(),
metadata: {
messageId: message.id,
author: message.sender,
senderId: message.sender_id,
senderType: message.sender_type,
timestamp: message.date,
hasAttachments: message.attachments,
attachmentCount: message.attachmentCount,
read: message.read === 1,
},
actionId: "message",
renderComponentId: "message",
});
// Add to existingIds as we process to prevent duplicates in the same run
existingIds.add(id);
} catch (error) {
console.error("Error fetching message content:", error);
continue;
}
}
offset += limit;
} catch (error) {
console.error("Error fetching messages:", error);
break;
}
// Small delay to avoid overwhelming the server
await new Promise((resolve) => setTimeout(resolve, 100));
}
console.debug(`[Messages Job] Found ${newItems.length} new messages`);
return newItems;
},
purge: (items) => {
// Keep messages from the last 30 days
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
return items.filter((i) => i.dateAdded >= cutoff);
},
},
assessments: {
id: "assessments",
label: "Assessments",
renderComponentId: "assessment",
frequency: { type: "expiry", afterMs: 1000 * 60 * 15 }, // every 15 minutes
run: async (ctx) => {
const notifications = await fetchNotifications();
const assessmentNotifications = notifications.filter(
(n): n is MessageNotification | AssessmentNotification =>
n.type === "coneqtassessments" ||
(n.type === "message" &&
n.message.title.toLowerCase().includes("assessment")),
);
const existing = await ctx.getStoredItems();
const existingIds = new Set(existing.map((i) => i.id));
const newItems: IndexItem[] = [];
for (const notification of assessmentNotifications) {
const id = notification.notificationID.toString();
if (existingIds.has(id)) continue;
if (notification.type === "coneqtassessments") {
const { coneqtAssessments: assessment } = notification;
newItems.push({
id,
text: assessment.title,
category: "assessments",
content: assessment.subtitle,
dateAdded: new Date(notification.timestamp).getTime(),
metadata: {
assessmentId: assessment.assessmentID,
subject: assessment.subjectCode,
term: assessment.term,
programmeId: assessment.programmeID,
metaclassId: assessment.metaclassID,
timestamp: notification.timestamp,
},
actionId: "assessment",
renderComponentId: "assessment",
});
} else {
// Handle message-based assessments
const { message } = notification;
newItems.push({
id,
text: message.title,
category: "assessments",
content: `From: ${message.subtitle}`,
dateAdded: new Date(notification.timestamp).getTime(),
metadata: {
messageId: message.messageID,
author: message.subtitle,
timestamp: notification.timestamp,
isMessageBased: true,
},
actionId: "assessment",
renderComponentId: "assessment",
});
}
}
return newItems;
},
purge: (items) => {
// Keep assessments from the current year
const date = new Date();
date.setMonth(0); // January
date.setDate(1);
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
const cutoff = date.getTime();
return items.filter((i) => i.dateAdded >= cutoff);
},
},
// We can add more job types here as needed:
// - notices
// - timetable changes
// - homework
// etc.
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;
},
};
@@ -1,10 +1,11 @@
import type { SvelteComponent } from "svelte";
import AssessmentComponent from "../components/AssessmentItem.svelte";
// import other components as needed
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: AssessmentComponent as unknown as typeof SvelteComponent,
// messages: MessageComponent,
// subject: SubjectComponent,
// etc...
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,
};
@@ -9,10 +9,7 @@ export interface IndexItem {
metadata: Record<string, any>;
actionId: string;
renderComponentId: string;
}
export interface HydratedIndexItem extends IndexItem {
renderComponent: typeof SvelteComponent;
renderComponent?: typeof SvelteComponent;
}
export type Frequency =
@@ -21,10 +18,12 @@ export type Frequency =
| { type: "expiry"; afterMs: number };
export interface JobContext {
getStoredItems: () => Promise<IndexItem[]>;
setStoredItems: (items: IndexItem[]) => Promise<void>;
addItem: (item: IndexItem) => Promise<void>;
removeItem: (id: string) => Promise<void>;
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 {
@@ -34,4 +33,5 @@ export interface Job {
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;
}
@@ -1,13 +1,20 @@
import {
EmbeddingIndex,
getEmbedding,
initializeModel,
} from "client-vector-search";
import type { HydratedIndexItem } from "../types";
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) {
@@ -19,11 +26,24 @@ async function initWorker() {
await initializeModel();
vectorIndex = new EmbeddingIndex([]);
// Load existing items but track them to prevent duplicates
const stored = await vectorIndex.getAllObjectsFromIndexedDB();
if (stored.length > 0) {
stored.forEach((item) => vectorIndex!.add(item));
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 ${stored.length} items from IndexedDB.`,
`Vector index loaded ${loadedItemIds.size} unique items from IndexedDB.`,
);
} else {
console.debug("No existing vector index found in IndexedDB.");
@@ -32,16 +52,14 @@ async function initWorker() {
console.debug("Vector worker initialized successfully.");
} catch (e) {
console.error("Failed to initialize vector worker:", e);
// Set as initialized even on error to prevent retries, but index will be null
isInitialized = true;
vectorIndex = null; // Ensure index is null on error
vectorIndex = null;
}
}
async function vectorizeItem(
item: HydratedIndexItem,
): Promise<(HydratedIndexItem & { embedding: number[] }) | null> {
// Simplified for brevity - assumes embedding function doesn't need cancellation signal
item: IndexItem,
): Promise<(IndexItem & { embedding: number[] }) | null> {
try {
const textToEmbed = [
item.text,
@@ -57,19 +75,247 @@ async function vectorizeItem(
return { ...item, embedding };
} catch (error) {
console.error(`Error vectorizing item ${item.id}:`, error);
return null; // Return null if vectorization fails for an item
return null;
}
}
async function processItems(items: HydratedIndexItem[], signal: AbortSignal) {
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(); // Attempt initialization if not ready
await initWorker();
if (!vectorIndex) {
// Check again after attempt
self.postMessage({
type: "progress",
data: {
@@ -82,16 +328,10 @@ async function processItems(items: HydratedIndexItem[], signal: AbortSignal) {
}
}
// Find items we haven't processed yet by checking against the index instance
// Use our tracking set for more efficient deduplication
const unprocessedItems = items.filter((item) => {
if (signal.aborted) return false; // Check cancellation during filtering
try {
// Check if the item ID already exists in the index (loaded or added)
return !vectorIndex!.get({ id: item.id });
} catch (e) {
// If get throws (e.g., item not found), it means it's unprocessed
return true;
}
if (signal.aborted) return false;
return item.id && !loadedItemIds.has(item.id);
});
if (signal.aborted) {
@@ -107,15 +347,15 @@ async function processItems(items: HydratedIndexItem[], signal: AbortSignal) {
}
if (unprocessedItems.length === 0) {
console.debug("No new items to process.");
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" },
data: { status: "complete", message: `No new items to process (${loadedItemIds.size} items already indexed)` },
});
return;
}
console.debug(`Starting processing of ${unprocessedItems.length} items.`);
console.debug(`Starting processing of ${unprocessedItems.length} items (${items.length - unprocessedItems.length} already processed).`);
self.postMessage({
type: "progress",
data: {
@@ -141,11 +381,10 @@ async function processItems(items: HydratedIndexItem[], signal: AbortSignal) {
}
const batch = unprocessedItems.slice(i, i + BATCH_SIZE);
// Vectorize batch
const vectorizationResults = await Promise.all(batch.map(vectorizeItem));
const successfullyVectorized = vectorizationResults.filter(
(result) => result !== null,
) as (HydratedIndexItem & { embedding: number[] })[];
) as (IndexItem & { embedding: number[] })[];
if (signal.aborted) {
console.debug("Processing cancelled after vectorization batch.");
@@ -159,18 +398,18 @@ async function processItems(items: HydratedIndexItem[], signal: AbortSignal) {
return;
}
// Add successfully vectorized items to index
if (successfullyVectorized.length > 0) {
try {
successfullyVectorized.forEach((item) => vectorIndex!.add(item));
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}` },
});
// Decide whether to continue or stop on error
// return; // Example: Stop processing if adding fails
}
}
@@ -186,234 +425,125 @@ async function processItems(items: HydratedIndexItem[], signal: AbortSignal) {
return;
}
// Save index after processing the batch
try {
await vectorIndex!.saveIndex("indexedDB");
console.debug(`Saved index after processing batch ${i / BATCH_SIZE + 1}`);
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}` },
});
// Continue processing next batch even if saving failed? Or stop?
// return; // Example: Stop if saving fails
}
processedCount = Math.min(i + BATCH_SIZE, unprocessedItems.length);
// Report progress
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)`,
},
});
// Yield control briefly to allow other messages (like cancellation) to be processed
await new Promise((resolve) => setTimeout(resolve, 0));
}
if (!signal.aborted) {
console.debug("Processing completed successfully.");
console.debug(`Processing complete. Total unique items in index: ${loadedItemIds.size}`);
self.postMessage({
type: "progress",
data: { status: "complete", message: "All items processed successfully" },
});
} else {
console.debug("Processing completed, but was cancelled.");
// No need to send 'cancelled' again if already sent during batching
// self.postMessage({ type: 'progress', data: { status: 'cancelled', message: 'Processing finished but was cancelled' }});
}
}
async function search(
query: string,
topK: number,
signal: AbortSignal,
messageId: string,
) {
console.debug(
`Worker received search request (ID: ${messageId}): "${query}"`,
);
if (!vectorIndex) {
console.warn(
`Search (ID: ${messageId}) requested but vector index not ready. Attempting init.`,
);
await initWorker(); // Attempt initialization
// Re-check after waiting/init attempt
if (!vectorIndex) {
console.error(
`Search (ID: ${messageId}) failed: Vector index unavailable after init attempt.`,
);
self.postMessage({
type: "searchError",
data: { messageId, error: "Vector index not available." },
});
return;
}
console.debug(
`Vector index ready after init for search (ID: ${messageId}).`,
);
}
if (signal.aborted) {
console.debug(`Search (ID: ${messageId}) cancelled before starting.`);
self.postMessage({ type: "searchCancelled", data: { messageId } });
return;
}
try {
console.debug(`Getting embedding for query (ID: ${messageId})...`);
const queryEmbedding = await getEmbedding(query);
if (signal.aborted) {
console.debug(`Search (ID: ${messageId}) cancelled after embedding.`);
self.postMessage({ type: "searchCancelled", data: { messageId } });
return;
}
console.debug(`Performing vector search (ID: ${messageId})...`);
// Await the search and let TypeScript infer the type
const results = await vectorIndex!.search(queryEmbedding, {
topK,
useStorage: "indexedDB", // Ensure we search the stored index
});
console.debug(
`Vector search (ID: ${messageId}) completed with ${results.length} results.`,
);
if (signal.aborted) {
console.debug(
`Search (ID: ${messageId}) cancelled after search completed, discarding results.`,
);
self.postMessage({ type: "searchCancelled", data: { messageId } });
return;
}
// Post results back to the main thread
self.postMessage({ type: "searchResults", data: { messageId, results } });
} catch (error) {
console.error(`Vector search error in worker (ID: ${messageId}):`, error);
// Ensure signal isn't checked *after* an error occurred before posting error message
if (!signal.aborted) {
// Only post error if not cancelled
self.postMessage({
type: "searchError",
data: {
messageId,
error: error instanceof Error ? error.message : String(error),
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.`,
},
});
} else {
console.debug(
`Search (ID: ${messageId}) encountered error but was cancelled, suppressing error message.`,
);
self.postMessage({ type: "searchCancelled", data: { messageId } }); // Still notify of cancellation
}
}
}
// Handle messages from the main thread
self.addEventListener("message", async (e) => {
// Make sure data and type exist
if (!e.data || !e.data.type) {
console.warn("Worker received message with no data or type.");
return;
}
const { type, data, messageId } = e.data; // messageId used for requests needing response/cancellation tracking
// Cancel previous long-running operation (process or search) if a new one starts
if (type === "process" || type === "search") {
if (currentAbortController) {
console.debug(
`Worker cancelling previous operation due to new '${type}' request.`,
);
currentAbortController.abort(`New '${type}' operation requested`);
}
currentAbortController = new AbortController();
console.debug(`Worker starting new '${type}' operation.`);
}
// Use the signal from the *current* controller for the task being started
const signal = currentAbortController?.signal;
const { type, data } = e.data;
switch (type) {
case "process":
if (signal && data?.items) {
await processItems(data.items, signal);
} else if (!signal) {
console.error(
"Process message received but no abort signal available.",
);
} else if (!data?.items) {
console.error("Process message received without 'items' data.");
self.postMessage({
type: "progress",
data: {
status: "error",
message: "Process command received without items.",
},
});
}
break;
case "search":
if (signal && messageId && typeof data?.query === "string") {
await search(data.query, data.topK ?? 10, signal, messageId);
} else {
const errorReason = !signal
? "Missing signal"
: !messageId
? "Missing messageId"
: "Missing or invalid query";
console.error(`Search message received invalid: ${errorReason}.`, {
data,
messageId,
signalExists: !!signal,
});
// Send an error back if messageId exists
if (messageId) {
self.postMessage({
type: "searchError",
data: { messageId, error: `Worker internal error: ${errorReason}` },
});
}
}
break;
case "init":
// Init should not be cancellable in the same way, it's foundational
// Check if already initialized before potentially running it again
if (!isInitialized) {
await initWorker();
self.postMessage({ type: "ready" }); // Signal ready *after* init attempt
} else {
console.debug("Received init message, but worker already initialized.");
self.postMessage({ type: "ready" }); // Signal ready anyway
}
self.postMessage({ type: "ready" });
break;
// No explicit 'cancel' case needed as new tasks auto-cancel previous ones
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 received by vector worker:", type);
console.warn("Unknown message type:", type);
}
});
// Initial check or trigger for initialization when the worker starts
initWorker()
.then(() => {
self.postMessage({ type: "ready" });
})
.catch((err) => {
console.error("Initial worker initialization failed:", err);
// Still need to signal readiness, perhaps with an error state?
// Or rely on the first 'process' or 'search' to retry init.
// For now, just signal ready, but the index might be null.
self.postMessage({ type: "ready" });
});
@@ -1,9 +1,9 @@
import type { HydratedIndexItem } from '../types';
import vectorWorker from './vectorWorker.ts?inlineWorker';
import type { SearchResult } from 'client-vector-search';
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';
status: "started" | "processing" | "complete" | "error" | "cancelled";
total?: number;
processed?: number;
message?: string;
@@ -13,209 +13,366 @@ export class VectorWorkerManager {
private static instance: VectorWorkerManager;
private worker: Worker | null = null;
private isInitialized = false;
private readyPromise: Promise<void> | null = null; // To await initialization
private readyPromise: Promise<void> | null = null;
private progressCallback: ProgressCallback | null = null;
private searchPromises = new Map<string, { resolve: (value: SearchResult[]) => void, reject: (reason?: any) => void, timer: NodeJS.Timeout }>();
private debounceTimer: NodeJS.Timeout | null = null;
private lastSearchParams: { query: string; topK: number; resolve: (results: SearchResult[]) => void, reject: (reason?: any) => void } | 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() {
// Start initialization immediately, but allow awaiting it
this.readyPromise = this.initWorker();
}
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 already initialized or initializing, return the existing promise
if (this.isInitialized) return Promise.resolve();
if (this.readyPromise) return this.readyPromise;
console.debug("Lazy-loading vector worker...");
return new Promise<void>((resolve, reject) => {
// Create the worker
// 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');
this.worker?.terminate(); // Clean up worker if it exists
this.worker = null;
this.isInitialized = false; // Ensure state reflects failure
this.readyPromise = null; // Allow retrying init later
reject(new Error('Worker initialization timed out'));
}, 10000); // Increased timeout
// Set up message handling
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(); // Resolve the init promise
break;
case 'progress':
if (this.progressCallback) {
this.progressCallback(data);
}
break;
case 'searchResults':
const searchInfo = this.searchPromises.get(data.messageId);
if (searchInfo) {
clearTimeout(searchInfo.timer); // Clear timeout on success
searchInfo.resolve(data.results);
this.searchPromises.delete(data.messageId);
} else {
console.warn('Received search results for unknown messageId:', data.messageId);
}
break;
case 'searchError':
const errorInfo = this.searchPromises.get(data.messageId);
if (errorInfo) {
clearTimeout(errorInfo.timer); // Clear timeout on error
errorInfo.reject(new Error(data.error));
this.searchPromises.delete(data.messageId);
} else {
console.warn('Received search error for unknown messageId:', data.messageId);
}
break;
case 'searchCancelled':
const cancelledInfo = this.searchPromises.get(data.messageId);
if (cancelledInfo) {
clearTimeout(cancelledInfo.timer); // Clear timeout on cancel
// Reject with a specific cancellation error or resolve with empty? Let's reject.
cancelledInfo.reject(new Error('Search cancelled by worker'));
this.searchPromises.delete(data.messageId);
} else {
console.debug('Received cancellation for unknown messageId:', data.messageId);
}
break;
default:
console.warn('Unknown message from worker:', type, data);
}
});
// Initialize the worker
this.worker!.postMessage({ type: 'init' });
});
}
// Ensures worker is ready before proceeding
private async ensureReady() {
if (!this.readyPromise) {
// If init wasn't called or failed, try again
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: HydratedIndexItem[], onProgress?: ProgressCallback) {
await this.ensureReady(); // Wait for worker to be ready
this.progressCallback = onProgress || null;
// Cancel any ongoing search when starting processing
this.cancelAllSearches("Processing started");
console.debug(`Sending ${items.length} items to worker for processing.`);
this.worker!.postMessage({
type: 'process',
data: { items }
});
}
// Public search method
public async search(query: string, topK: number = 10): Promise<SearchResult[]> {
await this.ensureReady();
return new Promise((resolve, reject) => {
this.lastSearchParams = { query, topK, resolve, reject };
const messageId = crypto.randomUUID();
if (this.lastSearchParams && this.worker) {
const currentParams = this.lastSearchParams; // Capture current params
this.lastSearchParams = null; // Clear last params *before* posting
this.debounceTimer = null;
// Set a timeout for the search operation itself
const searchTimeout = 10000; // e.g., 10 seconds
const searchTimer = setTimeout(() => {
if (this.searchPromises.has(messageId)) {
console.error(`Search timed out for messageId: ${messageId}`);
currentParams.reject(new Error(`Search timed out after ${searchTimeout}ms`));
this.searchPromises.delete(messageId);
}
}, searchTimeout);
this.searchPromises.set(messageId, { resolve: currentParams.resolve, reject: currentParams.reject, timer: searchTimer });
console.debug(`Sending search request (ID: ${messageId}) to worker: "${currentParams.query}"`);
this.worker.postMessage({
type: "search",
data: { query: currentParams.query, topK: currentParams.topK },
messageId
});
} else if (this.lastSearchParams) {
// This case might happen if ensureReady failed but didn't throw
console.error("Worker unavailable when trying to send search request.");
this.lastSearchParams.reject(new Error("Worker unavailable for search"));
this.lastSearchParams = null;
this.debounceTimer = null;
}
});
}
// Method to cancel all pending/debounced searches
private cancelAllSearches(reason: string = "Cancelled") {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
if (this.lastSearchParams) {
this.lastSearchParams.reject(new Error(`Search cancelled: ${reason}`));
this.lastSearchParams = null;
}
}
// We might also want to tell the worker to cancel its *current* search
// if it supports it, but this requires worker modification.
// For now, just reject pending promises in the manager.
for (const [messageId, promiseInfo] of this.searchPromises.entries()) {
clearTimeout(promiseInfo.timer);
promiseInfo.reject(new Error(`Search cancelled: ${reason}`));
this.searchPromises.delete(messageId);
}
}
terminate() {
console.debug("Terminating Vector Worker Manager...");
this.cancelAllSearches("Worker terminated"); // Cancel pending searches
console.error("Vector worker initialization timed out");
if (this.worker) {
this.worker.terminate();
this.worker = null;
}
this.isInitialized = false;
this.readyPromise = null; // Reset init promise
// 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;
// Clear the static instance? Or assume app lifecycle handles this?
// VectorWorkerManager.instance = null; // Uncomment if needed
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");
}
}
@@ -2,9 +2,10 @@ 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 { HydratedIndexItem } from "../indexing/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();
@@ -14,26 +15,23 @@ export function createSearchIndexes() {
keys: ["text", "category", "keywords"],
includeScore: true,
includeMatches: true,
threshold: 0.6,
minMatchCharLength: 1,
ignoreLocation: true,
threshold: 0.4,
minMatchCharLength: 2,
useExtendedSearch: false,
};
const dynamicOptions = {
keys: [
"text",
"content",
"category",
"metadata.author",
"metadata.subject",
{ name: "text", weight: 2 },
{ name: "content", weight: 1 },
{ name: "category", weight: 1 },
],
includeScore: true,
includeMatches: true,
threshold: 0.6,
minMatchCharLength: 3,
distance: 50,
useExtendedSearch: false,
threshold: 0.4,
minMatchCharLength: 2,
distance: 100,
useExtendedSearch: true,
};
return {
@@ -41,7 +39,7 @@ export function createSearchIndexes() {
dynamicContentFuse: new Fuse(
dynamicItems,
dynamicOptions,
) as Fuse<HydratedIndexItem>,
) as Fuse<IndexItem>,
commands,
dynamicItems,
};
@@ -85,11 +83,11 @@ export function searchCommands(
}
export function searchDynamicItems(
dynamicContentFuse: Fuse<HydratedIndexItem>,
dynamicContentFuse: Fuse<IndexItem>,
query: string,
dynamicIdToItemMap: Map<string, HydratedIndexItem>,
dynamicIdToItemMap: Map<string, IndexItem>,
limit = 10,
sortByRecent: boolean = true, // Added option to control sorting
sortByRecent: boolean = true,
): CombinedResult[] {
if (!dynamicContentFuse) return [];
@@ -101,7 +99,7 @@ export function searchDynamicItems(
return items.slice(0, limit).map((item) => ({
id: item.id,
type: "dynamic" as const,
score: 80, // Assign a default score for non-searched items
score: 80,
item,
}));
}
@@ -109,12 +107,15 @@ export function searchDynamicItems(
const now = Date.now();
const searchResults = dynamicContentFuse.search(query, { limit });
return searchResults.map((result: FuseResult<HydratedIndexItem>) => {
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; // Apply boost only if sorting by recent
const score = fuseScore + recencyBoost;
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
score += recencyBoost;
return {
id: item.id,
@@ -129,44 +130,20 @@ export function searchDynamicItems(
export async function performSearch(
query: string,
commandsFuse: Fuse<StaticCommandItem>,
dynamicContentFuse: Fuse<HydratedIndexItem>,
commandIdToItemMap: Map<string, StaticCommandItem>,
dynamicIdToItemMap: Map<string, HydratedIndexItem>,
showRecentFirst: boolean,
): Promise<CombinedResult[]> {
const startTime = performance.now();
// Get all results first
const commandResults = searchCommands(
commandsFuse,
query,
commandIdToItemMap,
);
const commandEndTime = performance.now();
const dynamicResults = searchDynamicItems(
dynamicContentFuse,
query,
dynamicIdToItemMap,
10,
showRecentFirst,
);
const fuseEndTime = performance.now();
// Get vector results in parallel
let vectorResults: VectorSearchResult[] = [];
try {
vectorResults = await searchVectors(query, 10);
vectorResults = await searchVectors(query);
} catch (e) {}
const vectorEndTime = performance.now();
console.log("Vector results:", vectorResults);
// Log timings
console.log(`Command search took ${commandEndTime - startTime} milliseconds`);
console.log(
`Dynamic search took ${fuseEndTime - commandEndTime} milliseconds`,
);
console.log(`Vector search took ${vectorEndTime - fuseEndTime} milliseconds`);
// Create a map to store our final results, using ID as key to avoid duplicates
const resultMap = new Map<string, CombinedResult>();
@@ -177,31 +154,23 @@ export async function performSearch(
// Process dynamic results and vector results together
const seenIds = new Set<string>();
// Add dynamic results first
dynamicResults.forEach((r) => {
seenIds.add(r.id);
const vectorMatch = vectorResults.find((v) => v.object.id === r.id);
if (vectorMatch) {
// If we found it in both searches, combine the scores
resultMap.set(r.id, {
...r,
score: r.score + vectorMatch.similarity * 0.6, // Boost exact matches
});
} else {
// If only in Fuse results, keep as is
resultMap.set(r.id, r);
}
});
// Now add any vector results we haven't seen yet
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: v.similarity * 0.9, // High base score for semantic matches
score,
item: v.object,
});
}
@@ -1,6 +1,6 @@
import { EmbeddingIndex, getEmbedding, initializeModel } from 'client-vector-search';
import type { HydratedIndexItem } from '../../indexing/types';
import type { SearchResult } from 'client-vector-search';
import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
import type { IndexItem } from "../../indexing/types";
import type { SearchResult } from "embeddia";
let vectorIndex: EmbeddingIndex | null = null;
@@ -10,24 +10,36 @@ export async function initVectorSearch() {
vectorIndex = new EmbeddingIndex([]);
vectorIndex.preloadIndexedDB();
} catch (e) {
console.error('Error initializing vector search', e);
console.error("Error initializing vector search", e);
}
}
export interface VectorSearchResult extends SearchResult {
object: HydratedIndexItem & { embedding: number[] };
object: IndexItem & { embedding: number[] };
}
export async function searchVectors(query: string, topK: number = 10): Promise<VectorSearchResult[]> {
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
useStorage: "indexedDB",
dedupeEntries: true,
});
return results as VectorSearchResult[];
// 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();
}
@@ -1,7 +1,6 @@
import type { SearchResult } from "client-vector-search";
import type { HydratedIndexItem } from "../../indexing/types";
import type { SearchResult } from "embeddia";
import type { IndexItem } from "../../indexing/types";
export interface VectorSearchResult extends SearchResult {
object: HydratedIndexItem & { embedding: number[] };
object: IndexItem & { embedding: number[] };
}

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