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 = { module.exports = {
forbidden: [ forbidden: [
{ {
name: 'no-circular', name: "no-circular",
severity: 'warn', severity: "warn",
comment: comment:
'This dependency is part of a circular relationship. You might want to revise ' + "This dependency is part of a circular relationship. You might want to revise " +
'your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ', "your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ",
from: {}, from: {},
to: { to: {
circular: true circular: true,
} },
}, },
{ {
name: 'no-orphans', name: "no-orphans",
comment: comment:
"This is an orphan module - it's likely not used (anymore?). Either use it or " + "This is an orphan module - it's likely not used (anymore?). Either use it or " +
"remove it. If it's logical this module is an orphan (i.e. it's a config file), " + "remove it. If it's logical this module is an orphan (i.e. it's a config file), " +
"add an exception for it in your dependency-cruiser configuration. By default " + "add an exception for it in your dependency-cruiser configuration. By default " +
"this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " + "this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " +
"files (.d.ts), tsconfig.json and some of the babel and webpack configs.", "files (.d.ts), tsconfig.json and some of the babel and webpack configs.",
severity: 'warn', severity: "warn",
from: { from: {
orphan: true, orphan: true,
pathNot: [ pathNot: [
'(^|/)[.][^/]+[.](?:js|cjs|mjs|ts|cts|mts|json)$', // dot files "(^|/)[.][^/]+[.](?:js|cjs|mjs|ts|cts|mts|json)$", // dot files
'[.]d[.]ts$', // TypeScript declaration files "[.]d[.]ts$", // TypeScript declaration files
'(^|/)tsconfig[.]json$', // TypeScript config "(^|/)tsconfig[.]json$", // TypeScript config
'(^|/)(?:babel|webpack)[.]config[.](?:js|cjs|mjs|ts|cts|mts|json)$' // other configs "(^|/)(?:babel|webpack)[.]config[.](?:js|cjs|mjs|ts|cts|mts|json)$", // other configs
] ],
}, },
to: {}, to: {},
}, },
{ {
name: 'no-deprecated-core', name: "no-deprecated-core",
comment: comment:
'A module depends on a node core module that has been deprecated. Find an alternative - these are ' + "A module depends on a node core module that has been deprecated. Find an alternative - these are " +
"bound to exist - node doesn't deprecate lightly.", "bound to exist - node doesn't deprecate lightly.",
severity: 'warn', severity: "warn",
from: {}, from: {},
to: { to: {
dependencyTypes: [ dependencyTypes: ["core"],
'core'
],
path: [ path: [
'^v8/tools/codemap$', "^v8/tools/codemap$",
'^v8/tools/consarray$', "^v8/tools/consarray$",
'^v8/tools/csvparser$', "^v8/tools/csvparser$",
'^v8/tools/logreader$', "^v8/tools/logreader$",
'^v8/tools/profile_view$', "^v8/tools/profile_view$",
'^v8/tools/profile$', "^v8/tools/profile$",
'^v8/tools/SourceMap$', "^v8/tools/SourceMap$",
'^v8/tools/splaytree$', "^v8/tools/splaytree$",
'^v8/tools/tickprocessor-driver$', "^v8/tools/tickprocessor-driver$",
'^v8/tools/tickprocessor$', "^v8/tools/tickprocessor$",
'^node-inspect/lib/_inspect$', "^node-inspect/lib/_inspect$",
'^node-inspect/lib/internal/inspect_client$', "^node-inspect/lib/internal/inspect_client$",
'^node-inspect/lib/internal/inspect_repl$', "^node-inspect/lib/internal/inspect_repl$",
'^async_hooks$', "^async_hooks$",
'^punycode$', "^punycode$",
'^domain$', "^domain$",
'^constants$', "^constants$",
'^sys$', "^sys$",
'^_linklist$', "^_linklist$",
'^_stream_wrap$' "^_stream_wrap$",
], ],
} },
}, },
{ {
name: 'not-to-deprecated', name: "not-to-deprecated",
comment: comment:
'This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later ' + "This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later " +
'version of that module, or find an alternative. Deprecated modules are a security risk.', "version of that module, or find an alternative. Deprecated modules are a security risk.",
severity: 'warn', severity: "warn",
from: {}, from: {},
to: { to: {
dependencyTypes: [ dependencyTypes: ["deprecated"],
'deprecated' },
]
}
}, },
{ {
name: 'no-non-package-json', name: "no-non-package-json",
severity: 'error', severity: "error",
comment: comment:
"This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " + "This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " +
"That's problematic as the package either (1) won't be available on live (2 - worse) will be " + "That's problematic as the package either (1) won't be available on live (2 - worse) will be " +
@@ -90,84 +86,75 @@ module.exports = {
"in your package.json.", "in your package.json.",
from: {}, from: {},
to: { to: {
dependencyTypes: [ dependencyTypes: ["npm-no-pkg", "npm-unknown"],
'npm-no-pkg', },
'npm-unknown'
]
}
}, },
{ {
name: 'not-to-unresolvable', name: "not-to-unresolvable",
comment: comment:
"This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " + "This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " +
'module: add it to your package.json. In all other cases you likely already know what to do.', "module: add it to your package.json. In all other cases you likely already know what to do.",
severity: 'error', severity: "error",
from: {}, from: {},
to: { to: {
couldNotResolve: true couldNotResolve: true,
} },
}, },
{ {
name: 'no-duplicate-dep-types', name: "no-duplicate-dep-types",
comment: comment:
"Likely this module depends on an external ('npm') package that occurs more than once " + "Likely this module depends on an external ('npm') package that occurs more than once " +
"in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " + "in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " +
"maintenance problems later on.", "maintenance problems later on.",
severity: 'warn', severity: "warn",
from: {}, from: {},
to: { to: {
moreThanOneDependencyType: true, moreThanOneDependencyType: true,
// as it's pretty common to have a type import be a type only import // as it's pretty common to have a type import be a type only import
// _and_ (e.g.) a devDependency - don't consider type-only dependency // _and_ (e.g.) a devDependency - don't consider type-only dependency
// types for this rule // types for this rule
dependencyTypesNot: ["type-only"] dependencyTypesNot: ["type-only"],
} },
}, },
/* rules you might want to tweak for your specific situation: */ /* rules you might want to tweak for your specific situation: */
{ {
name: 'not-to-spec', name: "not-to-spec",
comment: comment:
'This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. ' + "This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. " +
"If there's something in a spec that's of use to other modules, it doesn't have that single " + "If there's something in a spec that's of use to other modules, it doesn't have that single " +
'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.', "responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.",
severity: 'error', severity: "error",
from: {}, from: {},
to: { to: {
path: '[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$' path: "[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$",
} },
}, },
{ {
name: 'not-to-dev-dep', name: "not-to-dev-dep",
severity: 'error', severity: "error",
comment: comment:
"This module depends on an npm package from the 'devDependencies' section of your " + "This module depends on an npm package from the 'devDependencies' section of your " +
'package.json. It looks like something that ships to production, though. To prevent problems ' + "package.json. It looks like something that ships to production, though. To prevent problems " +
"with npm packages that aren't there on production declare it (only!) in the 'dependencies'" + "with npm packages that aren't there on production declare it (only!) in the 'dependencies'" +
'section of your package.json. If this module is development only - add it to the ' + "section of your package.json. If this module is development only - add it to the " +
'from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration', "from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration",
from: { from: {
path: '^(src)', path: "^(src)",
pathNot: '[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$' pathNot: "[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$",
}, },
to: { to: {
dependencyTypes: [ dependencyTypes: ["npm-dev"],
'npm-dev',
],
// type only dependencies are not a problem as they don't end up in the // type only dependencies are not a problem as they don't end up in the
// production code or are ignored by the runtime. // production code or are ignored by the runtime.
dependencyTypesNot: [ dependencyTypesNot: ["type-only"],
'type-only' pathNot: ["node_modules/@types/"],
], },
pathNot: [
'node_modules/@types/'
]
}
}, },
{ {
name: 'optional-deps-used', name: "optional-deps-used",
severity: 'info', severity: "info",
comment: comment:
"This module depends on an npm package that is declared as an optional dependency " + "This module depends on an npm package that is declared as an optional dependency " +
"in your package.json. As this makes sense in limited situations only, it's flagged here. " + "in your package.json. As this makes sense in limited situations only, it's flagged here. " +
@@ -175,33 +162,28 @@ module.exports = {
"dependency-cruiser configuration.", "dependency-cruiser configuration.",
from: {}, from: {},
to: { to: {
dependencyTypes: [ dependencyTypes: ["npm-optional"],
'npm-optional' },
]
}
}, },
{ {
name: 'peer-deps-used', name: "peer-deps-used",
comment: comment:
"This module depends on an npm package that is declared as a peer dependency " + "This module depends on an npm package that is declared as a peer dependency " +
"in your package.json. This makes sense if your package is e.g. a plugin, but in " + "in your package.json. This makes sense if your package is e.g. a plugin, but in " +
"other cases - maybe not so much. If the use of a peer dependency is intentional " + "other cases - maybe not so much. If the use of a peer dependency is intentional " +
"add an exception to your dependency-cruiser configuration.", "add an exception to your dependency-cruiser configuration.",
severity: 'warn', severity: "warn",
from: {}, from: {},
to: { to: {
dependencyTypes: [ dependencyTypes: ["npm-peer"],
'npm-peer' },
] },
}
}
], ],
options: { options: {
/* Which modules not to follow further when encountered */ /* Which modules not to follow further when encountered */
doNotFollow: { doNotFollow: {
/* path: an array of regular expressions in strings to match against */ /* path: an array of regular expressions in strings to match against */
path: ['node_modules'] path: ["node_modules"],
}, },
/* Which modules to exclude */ /* Which modules to exclude */
@@ -274,7 +256,7 @@ module.exports = {
defaults to './tsconfig.json'. defaults to './tsconfig.json'.
*/ */
tsConfig: { tsConfig: {
fileName: 'tsconfig.json' fileName: "tsconfig.json",
}, },
/* Webpack configuration to use to get resolve options from. /* Webpack configuration to use to get resolve options from.
@@ -364,8 +346,8 @@ module.exports = {
"bun:wrap", "bun:wrap",
"detect-libc", "detect-libc",
"undici", "undici",
"ws" "ws",
] ],
}, },
reporterOptions: { reporterOptions: {
@@ -375,7 +357,7 @@ module.exports = {
collapses everything in node_modules to one folder deep so you see collapses everything in node_modules to one folder deep so you see
the external modules, but their innards. the external modules, but their innards.
*/ */
collapsePattern: 'node_modules/(?:@[^/]+/[^/]+|[^/]+)', collapsePattern: "node_modules/(?:@[^/]+/[^/]+|[^/]+)",
/* Options to tweak the appearance of your graph.See /* Options to tweak the appearance of your graph.See
https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions
@@ -397,7 +379,8 @@ module.exports = {
dependency graph reporter (`archi`) you probably want to tweak dependency graph reporter (`archi`) you probably want to tweak
this collapsePattern to your situation. this collapsePattern to your situation.
*/ */
collapsePattern: '^(?:packages|src|lib(s?)|app(s?)|bin|test(s?)|spec(s?))/[^/]+|node_modules/(?:@[^/]+/[^/]+|[^/]+)', collapsePattern:
"^(?:packages|src|lib(s?)|app(s?)|bin|test(s?)|spec(s?))/[^/]+|node_modules/(?:@[^/]+/[^/]+|[^/]+)",
/* Options to tweak the appearance of your graph. If you don't specify a /* Options to tweak the appearance of your graph. If you don't specify a
theme for 'archi' dependency-cruiser will use the one specified in the theme for 'archi' dependency-cruiser will use the one specified in the
@@ -405,10 +388,10 @@ module.exports = {
*/ */
// theme: { }, // theme: { },
}, },
"text": { text: {
"highlightFocused": true highlightFocused: true,
},
},
}, },
}
}
}; };
// generated: dependency-cruiser@16.10.0 on 2025-02-16T22:32:01.621Z // generated: dependency-cruiser@16.10.0 on 2025-02-16T22:32:01.621Z
+5 -2
View File
@@ -12,12 +12,15 @@
}, },
"rules": { "rules": {
// allow importing ts extensions // allow importing ts extensions
"sort-imports": ["error", { "sort-imports": [
"error",
{
"ignoreCase": true, "ignoreCase": true,
"ignoreDeclarationSort": true, "ignoreDeclarationSort": true,
"ignoreMemberSort": false, "ignoreMemberSort": false,
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"] "memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
}], }
],
"import/extensions": [ "import/extensions": [
"error", "error",
"ignorePackages", "ignorePackages",
+3 -6
View File
@@ -3,7 +3,6 @@ description: Suggest a new Feature to be added or replaced in BetterSeqtaPLUS
labels: enhancement labels: enhancement
title: "[FR]" title: "[FR]"
body: body:
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Confirm label: Confirm
@@ -25,7 +24,6 @@ body:
## Feature details ## 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!) 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: attributes:
label: Feature type label: Feature type
@@ -37,18 +35,17 @@ body:
validations: validations:
required: true required: true
- type: input - type: input
attributes: attributes:
label: Feature Details label: Feature Details
description: Please write, with as much detail as possible, what you would like to see from this mod. description: Please write, with as much detail as possible, what you would like to see from this feature.
placeholder: I would like to see... placeholder: it would be cool if
validations: validations:
required: false required: false
- type: textarea - type: textarea
attributes: attributes:
label: Additional details label: Additional details
description: Anything else you want to add? description: Anything else that would help describe your vision (reference images, descriptions, etc)
validations: validations:
required: false required: false
+10 -10
View File
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our Examples of behavior that contributes to a positive environment for our
community include: community include:
* Demonstrating empathy and kindness toward other people - Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences - Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback - Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, - Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience and learning from the experience
* Focusing on what is best not just for us as individuals, but for the - Focusing on what is best not just for us as individuals, but for the
overall community overall community
Examples of unacceptable behavior include: Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or - The use of sexualized language or imagery, and sexual attention or
advances of any kind advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks - Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment - Public or private harassment
* Publishing others' private information, such as a physical or email - Publishing others' private information, such as a physical or email
address, without their explicit permission address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a - Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
## Enforcement Responsibilities ## Enforcement Responsibilities
+6 -7
View File
@@ -1,4 +1,3 @@
# #
<a href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel"> <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 git clone https://github.com/BetterSEQTA/BetterSEQTA-Plus
``` ```
1. Install dependencies 1. Install dependencies
You may install the dependencies like below: 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 npm install --legacy-peer-deps # Only NPM supported
``` ```
### Running Development ### Running Development
2. Run the dev script (it updates as you save files) 2. Run the dev script (it updates as you save files)
``` ```
npm run dev # or use your perferred package manager npm run dev # or use your perferred package manager
``` ```
### Building for production ### Building for production
2. Run the build script 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 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 3. Load the extension into chrome
- Go to `chrome://extensions` - 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 folder structure is as follows:
- The `src` folder contains source files that are compiled to the build directory. - The `src` folder contains source files that are compiled to the build directory.
-
- The `src/plugins` folder contains vital loaders required for BetterSEQTA+ functionality. - The `src/plugins` folder contains vital loaders required for BetterSEQTA+ functionality.
- The `src/interface` folder contains source React & Svelte files that are required for the Settings page. - The `src/interface` folder contains source React & Svelte files that are required for the Settings page.
@@ -130,9 +128,10 @@ The folder structure is as follows:
</a> </a>
Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plus/blob/main/CONTRIBUTING.md) Want to contribute? [Click Here!](https://github.com/BetterSEQTA/BetterSEQTA-Plus/blob/main/CONTRIBUTING.md)
## Credits ## Credits
This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development by [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) This extension was initially developed by [Nulkem](https://github.com/Nulkem/betterseqta), was ported to manifest V3 by [MEGA-Dawg68](https://github.com/MEGA-Dawg68) and is currently under active development from lead developers [SethBurkart123](https://github.com/SethBurkart123) and [Crazypersonalph](https://github.com/Crazypersonalph) with help from other volunteers
## Star History ## Star History
+2 -1
View File
@@ -5,11 +5,12 @@
Below here is the supported versions of BetterSEQTA+. Anything older than this is not supported and contains bugs. Below here is the supported versions of BetterSEQTA+. Anything older than this is not supported and contains bugs.
| Version | Supported | | Version | Supported |
| ------- | ------------------ | | ------- | --------- |
| 3.4.3 | ✅ | | 3.4.3 | ✅ |
| < 3.4.3 | :x: | | < 3.4.3 | :x: |
`*` May not work on other devices. `*` May not work on other devices.
## Reporting a Vulnerability ## Reporting a Vulnerability
If you find vulnerabilities, REPORT IT IMMEDIATELY. open the [advisories tab](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories) on the left and click the green "report a vulnerability" button or use [this quick-link](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories/new) to create a new report If you find vulnerabilities, REPORT IT IMMEDIATELY. open the [advisories tab](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories) on the left and click the green "report a vulnerability" button or use [this quick-link](https://github.com/BetterSEQTA/BetterSEQTA-Plus/security/advisories/new) to create a new report
+2
View File
@@ -7,11 +7,13 @@ Welcome to the BetterSEQTA+ documentation! This documentation will help you unde
## Table of Contents ## Table of Contents
### Getting Started ### Getting Started
- [Project Overview](./README.md) - This file - [Project Overview](./README.md) - This file
- [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+ - [Installation Guide](./installation.md) - How to install and set up BetterSEQTA+
- [Contributing Guide](../CONTRIBUTING.md) - How to contribute to BetterSEQTA+ - [Contributing Guide](../CONTRIBUTING.md) - How to contribute to BetterSEQTA+
### Plugin System ### Plugin System
- [Creating Your First Plugin](./plugins/README.md) - A comprehensive, beginner-friendly guide to creating plugins - [Creating Your First Plugin](./plugins/README.md) - A comprehensive, beginner-friendly guide to creating plugins
- [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs - [Plugin API Reference](./plugins/api-reference.md) - Detailed technical documentation of the plugin APIs
+6
View File
@@ -22,6 +22,7 @@ Thank you for your interest in contributing to BetterSEQTA+! This document provi
BetterSEQTA+ is committed to providing a welcoming and inclusive environment for all contributors. We expect all participants to adhere to our Code of Conduct, which promotes respectful and harassment-free interaction. BetterSEQTA+ is committed to providing a welcoming and inclusive environment for all contributors. We expect all participants to adhere to our Code of Conduct, which promotes respectful and harassment-free interaction.
Key points: Key points:
- Be respectful and inclusive - Be respectful and inclusive
- Focus on what is best for the community - Focus on what is best for the community
- Show empathy towards other community members - Show empathy towards other community members
@@ -105,6 +106,7 @@ git checkout -b feature/my-new-feature
2. **Write Clear Commit Messages** 2. **Write Clear Commit Messages**
Follow the conventional commits format: Follow the conventional commits format:
``` ```
feat: add new feature feat: add new feature
fix: resolve bug with timetable fix: resolve bug with timetable
@@ -118,6 +120,7 @@ git checkout -b feature/my-new-feature
4. **Run Tests** 4. **Run Tests**
Make sure all tests pass before submitting your PR: Make sure all tests pass before submitting your PR:
```bash ```bash
npm test npm test
``` ```
@@ -157,6 +160,7 @@ We follow TypeScript best practices and have a consistent code style:
5. **Use Linters** 5. **Use Linters**
We use ESLint and Prettier. Run them before submitting your PR: We use ESLint and Prettier. Run them before submitting your PR:
```bash ```bash
npm run lint npm run lint
npm run format npm run format
@@ -173,6 +177,7 @@ If you find a bug, please report it by creating an issue on GitHub:
2. **Use the Bug Report Template** 2. **Use the Bug Report Template**
Fill in all sections of the bug report template: Fill in all sections of the bug report template:
- Description - Description
- Steps to reproduce - Steps to reproduce
- Expected behavior - Expected behavior
@@ -195,6 +200,7 @@ We welcome feature suggestions! To suggest a new feature:
2. **Use the Feature Request Template** 2. **Use the Feature Request Template**
Fill in all sections of the feature request template: Fill in all sections of the feature request template:
- Description - Description
- Use case - Use case
- Potential implementation - Potential implementation
+2
View File
@@ -132,6 +132,7 @@ bun install
#### Extension not appearing in SEQTA #### Extension not appearing in SEQTA
Make sure: Make sure:
- You're visiting a SEQTA Learn page - You're visiting a SEQTA Learn page
- The extension is enabled - The extension is enabled
- You've refreshed the page after installing the extension - You've refreshed the page after installing the extension
@@ -139,6 +140,7 @@ Make sure:
#### Development build not updating #### Development build not updating
Try: Try:
1. Stopping the development server 1. Stopping the development server
2. Clearing your browser cache 2. Clearing your browser cache
3. Removing the extension from your browser 3. Removing the extension from your browser
+75 -35
View File
@@ -5,6 +5,7 @@ Hey there! 👋 So you want to create a plugin for BetterSEQTA+? That's awesome!
## What is a Plugin? ## What is a Plugin?
In BetterSEQTA+, a plugin is like a mini-app that adds new features to SEQTA. Think of it as a piece of LEGO that you can snap onto SEQTA to make it do new things. For example, you could create a plugin that: In BetterSEQTA+, a plugin is like a mini-app that adds new features to SEQTA. Think of it as a piece of LEGO that you can snap onto SEQTA to make it do new things. For example, you could create a plugin that:
- Changes how SEQTA looks - Changes how SEQTA looks
- Adds new buttons or features - Adds new buttons or features
- Shows extra information on your timetable - Shows extra information on your timetable
@@ -16,29 +17,32 @@ In BetterSEQTA+, a plugin is like a mini-app that adds new features to SEQTA. Th
Let's create a super simple plugin together. We'll make one that adds a friendly message to the SEQTA homepage. Here's what we'll need: Let's create a super simple plugin together. We'll make one that adds a friendly message to the SEQTA homepage. Here's what we'll need:
```typescript ```typescript
import type { Plugin } from '@/plugins/core/types'; import type { Plugin } from "@/plugins/core/types";
const myFirstPlugin: Plugin = { const myFirstPlugin: Plugin = {
// Every plugin needs these basic details // Every plugin needs these basic details
id: 'my-first-plugin', id: "my-first-plugin",
name: 'My First Plugin', name: "My First Plugin",
description: 'Adds a friendly message to SEQTA', description: "Adds a friendly message to SEQTA",
version: '1.0.0', version: "1.0.0",
// This tells BetterSEQTA+ that users can turn our plugin on/off // This tells BetterSEQTA+ that users can turn our plugin on/off
disableToggle: true, disableToggle: true,
// Optional: Mark your plugin as beta to show a "Beta" tag in settings
beta: true,
// This is where the magic happens! // This is where the magic happens!
run: async (api) => { run: async (api) => {
// Wait for the homepage to load // Wait for the homepage to load
api.seqta.onMount('.home-page', (homePage) => { api.seqta.onMount(".home-page", (homePage) => {
// Create our message // Create our message
const message = document.createElement('div'); const message = document.createElement("div");
message.textContent = 'Hello from my first plugin! 🎉'; message.textContent = "Hello from my first plugin! 🎉";
message.style.padding = '20px'; message.style.padding = "20px";
message.style.backgroundColor = '#e9f5ff'; message.style.backgroundColor = "#e9f5ff";
message.style.borderRadius = '8px'; message.style.borderRadius = "8px";
message.style.margin = '20px'; message.style.margin = "20px";
// Add it to the page // Add it to the page
homePage.prepend(message); homePage.prepend(message);
@@ -46,10 +50,10 @@ const myFirstPlugin: Plugin = {
// Return a cleanup function that removes our message when the plugin is disabled // Return a cleanup function that removes our message when the plugin is disabled
return () => { return () => {
const message = document.querySelector('.home-page > div'); const message = document.querySelector(".home-page > div");
message?.remove(); message?.remove();
}; };
} },
}; };
export default myFirstPlugin; export default myFirstPlugin;
@@ -64,10 +68,11 @@ Let's break down what's happening here:
- `description`: Explain what your plugin does - `description`: Explain what your plugin does
- `version`: Your plugin's version number - `version`: Your plugin's version number
3. We set `disableToggle: true` so users can turn our plugin on/off in settings 3. We set `disableToggle: true` so users can turn our plugin on/off in settings
4. The `run` function is where we put our plugin's code 4. We set `beta: true` to mark the plugin as beta
5. We use `api.seqta.onMount` to wait for the homepage to load 5. The `run` function is where we put our plugin's code
6. We create and style a message element 6. We use `api.seqta.onMount` to wait for the homepage to load
7. We return a cleanup function that removes our changes when the plugin is disabled 7. We create and style a message element
8. We return a cleanup function that removes our changes when the plugin is disabled
## The Plugin API ## The Plugin API
@@ -79,13 +84,13 @@ This helps you interact with SEQTA's pages:
```typescript ```typescript
// Wait for an element to appear on the page // Wait for an element to appear on the page
api.seqta.onMount('.some-class', (element) => { api.seqta.onMount(".some-class", (element) => {
// Do something with the element // Do something with the element
}); });
// Know when the user changes pages // Know when the user changes pages
api.seqta.onPageChange((page) => { api.seqta.onPageChange((page) => {
console.log('User went to:', page); console.log("User went to:", page);
}); });
// Get the current page // Get the current page
@@ -97,8 +102,12 @@ const currentPage = api.seqta.getCurrentPage();
Want to let users customize your plugin? Use settings! Want to let users customize your plugin? Use settings!
```typescript ```typescript
import { BasePlugin } from '@/plugins/core/settings'; import { BasePlugin } from "@/plugins/core/settings";
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers'; import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
// Define your settings // Define your settings
const settings = defineSettings({ const settings = defineSettings({
@@ -106,7 +115,7 @@ const settings = defineSettings({
default: true, default: true,
title: "Show Welcome Message", title: "Show Welcome Message",
description: "Show a friendly message on the homepage", description: "Show a friendly message on the homepage",
}) }),
}); });
// Create a class for your plugin // Create a class for your plugin
@@ -129,14 +138,14 @@ const myPlugin: Plugin<typeof settings> = {
} }
// Listen for setting changes // Listen for setting changes
api.settings.onChange('showMessage', (newValue) => { api.settings.onChange("showMessage", (newValue) => {
if (newValue) { if (newValue) {
// Show the message // Show the message
} else { } else {
// Hide the message // Hide the message
} }
}); });
} },
}; };
``` ```
@@ -146,14 +155,14 @@ Need to save some data? The storage API has got you covered:
```typescript ```typescript
// Save some data // Save some data
await api.storage.set('lastVisit', new Date().toISOString()); await api.storage.set("lastVisit", new Date().toISOString());
// Get it back later // Get it back later
const lastVisit = await api.storage.get('lastVisit'); const lastVisit = await api.storage.get("lastVisit");
// Listen for changes // Listen for changes
api.storage.onChange('lastVisit', (newValue) => { api.storage.onChange("lastVisit", (newValue) => {
console.log('Last visit updated:', newValue); console.log("Last visit updated:", newValue);
}); });
``` ```
@@ -163,12 +172,12 @@ Want your plugin to be able to interface with other plugins? Then use events!
```typescript ```typescript
// Listen for an event // Listen for an event
api.events.on('myCustomEvent', (data) => { api.events.on("myCustomEvent", (data) => {
console.log('Got event:', data); console.log("Got event:", data);
}); });
// Send an event // Send an event
api.events.emit('myCustomEvent', { some: 'data' }); api.events.emit("myCustomEvent", { some: "data" });
``` ```
## Adding Styles ## Adding Styles
@@ -199,7 +208,7 @@ const myPlugin: Plugin = {
run: async (api) => { run: async (api) => {
// Your plugin code here // Your plugin code here
} },
}; };
``` ```
@@ -208,28 +217,31 @@ const myPlugin: Plugin = {
Here are some tips to make your plugin awesome: Here are some tips to make your plugin awesome:
1. **Always Clean Up**: When your plugin is disabled, clean up any changes you made: 1. **Always Clean Up**: When your plugin is disabled, clean up any changes you made:
```typescript ```typescript
run: async (api) => { run: async (api) => {
// Add stuff to the page // Add stuff to the page
const element = document.createElement('div'); const element = document.createElement("div");
document.body.appendChild(element); document.body.appendChild(element);
// Return a cleanup function // Return a cleanup function
return () => { return () => {
element.remove(); element.remove();
}; };
} };
``` ```
2. **Use TypeScript**: It helps catch errors before they happen and makes your code easier to understand. 2. **Use TypeScript**: It helps catch errors before they happen and makes your code easier to understand.
3. **Test Your Plugin**: Make sure it works in different situations: 3. **Test Your Plugin**: Make sure it works in different situations:
- When SEQTA is loading - When SEQTA is loading
- When the user switches pages - When the user switches pages
- When the plugin is enabled/disabled - When the plugin is enabled/disabled
- When settings are changed - When settings are changed
4. **Keep It Fast**: Don't slow down SEQTA: 4. **Keep It Fast**: Don't slow down SEQTA:
- Use `onMount` instead of intervals or timeouts - Use `onMount` instead of intervals or timeouts
- Clean up event listeners when they're not needed - Clean up event listeners when they're not needed
- Don't do heavy calculations on the main thread - Don't do heavy calculations on the main thread
@@ -238,10 +250,37 @@ Here are some tips to make your plugin awesome:
- Add clear settings with good descriptions - Add clear settings with good descriptions
- Use `disableToggle: true` so users can turn it off if needed - Use `disableToggle: true` so users can turn it off if needed
- Add helpful error messages if something goes wrong - Add helpful error messages if something goes wrong
- Use `beta: true` for experimental features to let users know they're trying something new
## Plugin Metadata Options
Your plugin object supports several optional flags to customize how it appears and behaves:
```typescript
const myPlugin: Plugin = {
id: "my-plugin",
name: "My Plugin",
description: "What my plugin does",
version: "1.0.0",
// Optional flags:
disableToggle: true, // Show enable/disable toggle in settings
defaultEnabled: false, // Start disabled by default (requires disableToggle: true)
beta: true, // Show "Beta" tag in settings UI
// Your plugin code...
run: async (api) => { /* ... */ },
};
```
- **`disableToggle`**: When `true`, users can enable/disable your plugin in settings
- **`defaultEnabled`**: When `false`, your plugin starts disabled (only works with `disableToggle: true`)
- **`beta`**: When `true`, shows an orange "Beta" tag next to your plugin name in settings
## Examples ## Examples
Want to see more examples? Check out our built-in plugins: Want to see more examples? Check out our built-in plugins:
- [themes](../../src/plugins/built-in/themes/index.ts): Shows how to change SEQTA's appearance - [themes](../../src/plugins/built-in/themes/index.ts): Shows how to change SEQTA's appearance
- [notificationCollector](../../src/plugins/built-in/notificationCollector/index.ts): Shows how to work with SEQTA's notifications - [notificationCollector](../../src/plugins/built-in/notificationCollector/index.ts): Shows how to work with SEQTA's notifications
- [timetable](../../src/plugins/built-in/timetable/index.ts): Shows how to modify SEQTA's timetable view - [timetable](../../src/plugins/built-in/timetable/index.ts): Shows how to modify SEQTA's timetable view
@@ -250,6 +289,7 @@ Want to see more examples? Check out our built-in plugins:
## Need Help? ## Need Help?
Got stuck? No worries! Here's where you can get help: Got stuck? No worries! Here's where you can get help:
- Join our [Discord server](https://discord.gg/YzmbnCDkat) - Join our [Discord server](https://discord.gg/YzmbnCDkat)
- Check out the built-in plugins in the `src/plugins/built-in` folder - Check out the built-in plugins in the `src/plugins/built-in` folder
- Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues) - Open an issue on our [GitHub page](https://github.com/betterseqta/betterseqta-plus/issues)
+126 -74
View File
@@ -7,9 +7,13 @@ This document provides detailed technical information about BetterSEQTA+'s plugi
Here's how a plugin is structured: Here's how a plugin is structured:
```typescript ```typescript
import type { Plugin } from '@/plugins/core/types'; import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from '@/plugins/core/settings'; import { BasePlugin } from "@/plugins/core/settings";
import { booleanSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers'; import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
// First, define your settings // First, define your settings
const settings = defineSettings({ const settings = defineSettings({
@@ -17,7 +21,7 @@ const settings = defineSettings({
default: true, default: true,
title: "Enable Feature", title: "Enable Feature",
description: "Turn this feature on or off", description: "Turn this feature on or off",
}) }),
}); });
// Create a class to handle your settings // Create a class to handle your settings
@@ -31,59 +35,92 @@ const settingsInstance = new MyPluginClass();
// Create your plugin // Create your plugin
const myPlugin: Plugin<typeof settings> = { const myPlugin: Plugin<typeof settings> = {
id: 'my-plugin', id: "my-plugin",
name: 'My Plugin', name: "My Plugin",
description: 'A cool plugin that does things', description: "A cool plugin that does things",
version: '1.0.0', version: "1.0.0",
settings: settingsInstance.settings, settings: settingsInstance.settings,
disableToggle: true, disableToggle: true,
beta: true,
run: async (api) => { run: async (api) => {
console.log('Plugin is running!'); console.log("Plugin is running!");
// Do stuff when settings change // Do stuff when settings change
api.settings.onChange('enabled', (enabled) => { api.settings.onChange("enabled", (enabled) => {
if (enabled) { if (enabled) {
console.log('Feature enabled!'); console.log("Feature enabled!");
} }
}); });
// Return a cleanup function // Return a cleanup function
return () => { return () => {
console.log('Plugin cleanup'); console.log("Plugin cleanup");
}; };
} },
}; };
export default myPlugin; export default myPlugin;
``` ```
## Plugin Metadata
The plugin object supports several metadata fields and options:
```typescript
interface Plugin {
// Required fields
id: string; // Unique identifier (lowercase, dashes)
name: string; // Display name shown to users
description: string; // Brief description of what the plugin does
version: string; // Semantic version (e.g., "1.0.0")
settings: PluginSettings; // Plugin settings object
run: (api: PluginAPI) => void; // Main plugin function
// Optional fields
styles?: string; // CSS styles to inject
disableToggle?: boolean; // Show enable/disable toggle in settings
defaultEnabled?: boolean; // Start enabled/disabled (requires disableToggle)
beta?: boolean; // Show "Beta" tag in settings UI
}
```
### Metadata Options
- **`disableToggle`**: When `true`, users can enable/disable your plugin in the settings page
- **`defaultEnabled`**: When `false`, your plugin starts disabled by default (only works with `disableToggle: true`)
- **`beta`**: When `true`, displays an orange "Beta" tag next to your plugin name in the settings UI
- **`styles`**: CSS string that gets injected into the page when your plugin runs
## SEQTA API ## SEQTA API
The SEQTA API helps you interact with SEQTA's pages: The SEQTA API helps you interact with SEQTA's pages:
```typescript ```typescript
import type { Plugin } from '@/plugins/core/types'; import type { Plugin } from "@/plugins/core/types";
const seqtaPlugin: Plugin<typeof settings> = { const seqtaPlugin: Plugin<typeof settings> = {
id: 'seqta-example', id: "seqta-example",
name: 'SEQTA Example', name: "SEQTA Example",
description: 'Shows how to use the SEQTA API', description: "Shows how to use the SEQTA API",
version: '1.0.0', version: "1.0.0",
settings: {}, settings: {},
disableToggle: true, disableToggle: true,
run: async (api) => { run: async (api) => {
// Wait for elements to appear // Wait for elements to appear
const { unregister: timetableUnregister } = api.seqta.onMount('.timetable', (timetable) => { const { unregister: timetableUnregister } = api.seqta.onMount(
const button = document.createElement('button'); ".timetable",
button.textContent = 'Export'; (timetable) => {
const button = document.createElement("button");
button.textContent = "Export";
timetable.appendChild(button); timetable.appendChild(button);
}); },
);
// Track page changes // Track page changes
const { unregister: pageUnregister } = api.seqta.onPageChange((page) => { const { unregister: pageUnregister } = api.seqta.onPageChange((page) => {
console.log('User went to:', page); console.log("User went to:", page);
}); });
// Clean up when disabled // Clean up when disabled
@@ -91,7 +128,7 @@ const seqtaPlugin: Plugin<typeof settings> = {
timetableUnregister(); timetableUnregister();
pageUnregister(); pageUnregister();
}; };
} },
}; };
export default seqtaPlugin; export default seqtaPlugin;
@@ -102,22 +139,29 @@ export default seqtaPlugin;
Here's how to add settings to your plugin: Here's how to add settings to your plugin:
```typescript ```typescript
import type { Plugin } from '@/plugins/core/types'; import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from '@/plugins/core/settings'; import { BasePlugin } from "@/plugins/core/settings";
import { booleanSetting, stringSetting, numberSetting, selectSetting, defineSettings, Setting } from '@/plugins/core/settingsHelpers'; import {
booleanSetting,
stringSetting,
numberSetting,
selectSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
// Define your settings // Define your settings
const settings = defineSettings({ const settings = defineSettings({
darkMode: booleanSetting({ darkMode: booleanSetting({
default: false, default: false,
title: "Dark Mode", title: "Dark Mode",
description: "Enable dark mode" description: "Enable dark mode",
}), }),
userName: stringSetting({ userName: stringSetting({
default: "", default: "",
title: "User Name", title: "User Name",
description: "Your display name", description: "Your display name",
placeholder: "Enter your name..." placeholder: "Enter your name...",
}), }),
theme: selectSetting({ theme: selectSetting({
default: "light", default: "light",
@@ -125,9 +169,9 @@ const settings = defineSettings({
description: "Choose your theme", description: "Choose your theme",
options: [ options: [
{ value: "light", label: "Light" }, { value: "light", label: "Light" },
{ value: "dark", label: "Dark" } { value: "dark", label: "Dark" },
] ],
}) }),
}); });
// Create your settings class // Create your settings class
@@ -144,29 +188,29 @@ class ThemePluginClass extends BasePlugin<typeof settings> {
// Create the plugin // Create the plugin
const themePlugin: Plugin<typeof settings> = { const themePlugin: Plugin<typeof settings> = {
id: 'theme-example', id: "theme-example",
name: 'Theme Example', name: "Theme Example",
description: 'Shows how to use settings', description: "Shows how to use settings",
version: '1.0.0', version: "1.0.0",
settings: new ThemePluginClass().settings, settings: new ThemePluginClass().settings,
disableToggle: true, disableToggle: true,
run: async (api) => { run: async (api) => {
// Apply initial settings // Apply initial settings
if (api.settings.darkMode) { if (api.settings.darkMode) {
document.body.classList.add('dark'); document.body.classList.add("dark");
} }
// Listen for changes // Listen for changes
const { unregister } = api.settings.onChange('darkMode', (enabled) => { const { unregister } = api.settings.onChange("darkMode", (enabled) => {
document.body.classList.toggle('dark', enabled); document.body.classList.toggle("dark", enabled);
}); });
return () => { return () => {
unregister(); unregister();
document.body.classList.remove('dark'); document.body.classList.remove("dark");
}; };
} },
}; };
export default themePlugin; export default themePlugin;
@@ -177,13 +221,13 @@ export default themePlugin;
Here's how to use storage in your plugin: Here's how to use storage in your plugin:
```typescript ```typescript
import type { Plugin } from '@/plugins/core/types'; import type { Plugin } from "@/plugins/core/types";
const storagePlugin: Plugin<typeof settings> = { const storagePlugin: Plugin<typeof settings> = {
id: 'storage-example', id: "storage-example",
name: 'Storage Example', name: "Storage Example",
description: 'Shows how to use storage', description: "Shows how to use storage",
version: '1.0.0', version: "1.0.0",
settings: {}, settings: {},
disableToggle: true, disableToggle: true,
@@ -192,21 +236,21 @@ const storagePlugin: Plugin<typeof settings> = {
await api.storage.loaded; await api.storage.loaded;
// Save some data // Save some data
await api.storage.set('lastVisit', new Date().toISOString()); await api.storage.set("lastVisit", new Date().toISOString());
// Get saved data // Get saved data
const lastVisit = await api.storage.get('lastVisit'); const lastVisit = await api.storage.get("lastVisit");
console.log('Last visit:', lastVisit); console.log("Last visit:", lastVisit);
// Listen for changes // Listen for changes
const { unregister } = api.storage.onChange('lastVisit', (newValue) => { const { unregister } = api.storage.onChange("lastVisit", (newValue) => {
console.log('Last visit updated:', newValue); console.log("Last visit updated:", newValue);
}); });
return () => { return () => {
unregister(); unregister();
}; };
} },
}; };
export default storagePlugin; export default storagePlugin;
@@ -217,33 +261,39 @@ export default storagePlugin;
Here's how to use events in your plugin: Here's how to use events in your plugin:
```typescript ```typescript
import type { Plugin } from '@/plugins/core/types'; import type { Plugin } from "@/plugins/core/types";
const eventsPlugin: Plugin<typeof settings> = { const eventsPlugin: Plugin<typeof settings> = {
id: 'events-example', id: "events-example",
name: 'Events Example', name: "Events Example",
description: 'Shows how to use events', description: "Shows how to use events",
version: '1.0.0', version: "1.0.0",
settings: {}, settings: {},
disableToggle: true, disableToggle: true,
run: async (api) => { run: async (api) => {
// Listen for theme changes // Listen for theme changes
const { unregister: themeListener } = api.events.on('theme.changed', (theme) => { const { unregister: themeListener } = api.events.on(
console.log('Theme changed to:', theme); "theme.changed",
}); (theme) => {
console.log("Theme changed to:", theme);
},
);
// Listen for notifications // Listen for notifications
const { unregister: notifyListener } = api.events.on('notification.new', (notification) => { const { unregister: notifyListener } = api.events.on(
console.log('New notification:', notification); "notification.new",
}); (notification) => {
console.log("New notification:", notification);
},
);
// Clean up listeners // Clean up listeners
return () => { return () => {
themeListener(); themeListener();
notifyListener(); notifyListener();
}; };
} },
}; };
export default eventsPlugin; export default eventsPlugin;
@@ -254,20 +304,20 @@ export default eventsPlugin;
Here's how to write efficient plugins: Here's how to write efficient plugins:
```typescript ```typescript
import type { Plugin } from '@/plugins/core/types'; import type { Plugin } from "@/plugins/core/types";
const efficientPlugin: Plugin<typeof settings> = { const efficientPlugin: Plugin<typeof settings> = {
id: 'efficient-example', id: "efficient-example",
name: 'Efficient Example', name: "Efficient Example",
description: 'Shows performance best practices', description: "Shows performance best practices",
version: '1.0.0', version: "1.0.0",
settings: {}, settings: {},
disableToggle: true, disableToggle: true,
run: async (api) => { run: async (api) => {
// ✅ Good: Use onMount // ✅ Good: Use onMount
const { unregister } = api.seqta.onMount('.timetable', (el) => { const { unregister } = api.seqta.onMount(".timetable", (el) => {
el.classList.add('enhanced'); el.classList.add("enhanced");
}); });
// ❌ Bad: Don't use intervals // ❌ Bad: Don't use intervals
@@ -277,7 +327,7 @@ const efficientPlugin: Plugin<typeof settings> = {
// }, 100); // }, 100);
// ✅ Good: Cache DOM elements // ✅ Good: Cache DOM elements
const header = document.querySelector('.header'); const header = document.querySelector(".header");
if (header) { if (header) {
// Reuse header instead of querying again // Reuse header instead of querying again
} }
@@ -285,7 +335,7 @@ const efficientPlugin: Plugin<typeof settings> = {
// ✅ Good: Batch DOM updates // ✅ Good: Batch DOM updates
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const div = document.createElement('div'); const div = document.createElement("div");
fragment.appendChild(div); fragment.appendChild(div);
} }
document.body.appendChild(fragment); document.body.appendChild(fragment);
@@ -294,13 +344,14 @@ const efficientPlugin: Plugin<typeof settings> = {
unregister(); unregister();
// clearInterval(interval); // If you used the bad approach // clearInterval(interval); // If you used the bad approach
}; };
} },
}; };
export default efficientPlugin; export default efficientPlugin;
``` ```
Each plugin should be in its own file and exported as the default export. The plugin should: Each plugin should be in its own file and exported as the default export. The plugin should:
1. Import necessary types and helpers 1. Import necessary types and helpers
2. Define settings if needed 2. Define settings if needed
3. Create a settings class if using settings 3. Create a settings class if using settings
@@ -308,6 +359,7 @@ Each plugin should be in its own file and exported as the default export. The pl
5. Export the plugin as default 5. Export the plugin as default
Remember to always: Remember to always:
- Use proper TypeScript types - Use proper TypeScript types
- Clean up when your plugin is disabled - Clean up when your plugin is disabled
- Handle errors gracefully - Handle errors gracefully
+17
View File
@@ -0,0 +1,17 @@
export default {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: [
'**/__tests__/**/*.ts',
'**/?(*.)+(spec|test).ts'
],
transform: {
'^.+\\.ts$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'js', 'json'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
],
};
+1 -1
View File
@@ -7,7 +7,7 @@ export const base64Loader = {
const [filePath, query] = id.split("?"); const [filePath, query] = id.split("?");
if (query !== "base64") return null; if (query !== "base64") return null;
const data = fs.readFileSync(filePath, { encoding: 'base64' }); const data = fs.readFileSync(filePath, { encoding: "base64" });
const mimeType = mime.lookup(filePath); const mimeType = mime.lookup(filePath);
const dataURL = `data:${mimeType};base64,${data}`; const dataURL = `data:${mimeType};base64,${data}`;
+9 -9
View File
@@ -1,25 +1,25 @@
// ref: https://stackoverflow.com/a/76920975 // ref: https://stackoverflow.com/a/76920975
import type { Plugin } from 'vite'; import type { Plugin } from "vite";
export default function ClosePlugin(): Plugin { export default function ClosePlugin(): Plugin {
return { 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 // use this to catch errors when building
buildEnd(error) { buildEnd(error) {
if (error) { if (error) {
console.error('Error bundling') console.error("Error bundling");
console.error(error) console.error(error);
process.exit(1) process.exit(1);
} else { } else {
console.log('Build ended') console.log("Build ended");
} }
}, },
// use this to catch the end of a build without errors // use this to catch the end of a build without errors
closeBundle() { closeBundle() {
console.log('Bundle closed') console.log("Bundle closed");
process.exit(0) process.exit(0);
}, },
} };
} }
+4 -4
View File
@@ -1,5 +1,5 @@
import type { Browser, BuildTarget, Manifest } from './types' import type { Browser, BuildTarget, Manifest } from "./types";
import type { AnyCase } from './utils' import type { AnyCase } from "./utils";
/** /**
* *
* *
@@ -15,7 +15,7 @@ export function createManifest(
return { return {
manifest, manifest,
browser, browser,
} };
} }
/** /**
@@ -29,5 +29,5 @@ export function createManifest(
* @return {*} {@link Manifest} * @return {*} {@link Manifest}
*/ */
export function createManifestBase(manifest: Manifest): 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 // vite-plugin-inline-worker-dev.ts
import { Plugin } from 'vite' import { Plugin } from "vite";
import fs from 'fs/promises' import fs from "fs/promises";
import { build, transform } from 'esbuild' import { build, transform } from "esbuild";
export default function InlineWorkerDevPlugin(): Plugin { export default function InlineWorkerDevPlugin(): Plugin {
return { return {
name: 'vite:inline-worker-dev', name: "vite:inline-worker-dev",
async load(id) { async load(id) {
if (id.includes('?inlineWorker')) { if (id.includes("?inlineWorker")) {
const [cleanPath] = id.split('?') const [cleanPath] = id.split("?");
console.log('cleanPath', cleanPath) console.log("cleanPath", cleanPath);
const code = await fs.readFile(cleanPath, 'utf-8') const code = await fs.readFile(cleanPath, "utf-8");
const result = await build({ const result = await build({
entryPoints: [cleanPath], entryPoints: [cleanPath],
bundle: true, bundle: true,
write: false, write: false,
platform: 'browser', platform: "browser",
format: 'iife', format: "iife",
target: 'esnext', target: "esnext",
}) });
const workerCode = result.outputFiles[0].text const workerCode = result.outputFiles[0].text;
const workerBlobCode = ` const workerBlobCode = `
const code = ${JSON.stringify(workerCode)}; const code = ${JSON.stringify(workerCode)};
@@ -28,10 +28,10 @@ export default function InlineWorkerDevPlugin(): Plugin {
const blob = new Blob([code], { type: 'application/javascript' }); const blob = new Blob([code], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob), { type: 'module' }); return new Worker(URL.createObjectURL(blob), { type: 'module' });
} }
` `;
return workerBlobCode return workerBlobCode;
}
return null
}
} }
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 glob = require("glob");
const semver = require('semver'); const semver = require("semver");
const { execSync } = require('child_process'); const { execSync } = require("child_process");
const path = require('path'); const path = require("path");
function getLatestVersion(files) { function getLatestVersion(files) {
console.log('Files passed to getLatestVersion:', files); console.log("Files passed to getLatestVersion:", files);
const versions = files.map(file => { const versions = files
.map((file) => {
const match = file.match(/@([\d\.]+)-/); const match = file.match(/@([\d\.]+)-/);
console.log('Matching file:', file, 'Version found:', match ? match[1] : 'None'); console.log(
"Matching file:",
file,
"Version found:",
match ? match[1] : "None",
);
if (!match) return null; if (!match) return null;
const fullVersion = match[1]; // Original version (e.g., 3.4.5.1) const fullVersion = match[1]; // Original version (e.g., 3.4.5.1)
const semverVersion = fullVersion.split('.').slice(0, 3).join('.'); // Trim to 3.4.5 const semverVersion = fullVersion.split(".").slice(0, 3).join("."); // Trim to 3.4.5
return { fullVersion, semverVersion }; 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 // Find latest version using the trimmed semver format
const latestSemver = semver.maxSatisfying(versions.map(v => v.semverVersion), '*'); const latestSemver = semver.maxSatisfying(
console.log('Latest SemVer-compatible version:', latestSemver); versions.map((v) => v.semverVersion),
"*",
);
console.log("Latest SemVer-compatible version:", latestSemver);
// Get the full version that matches the latest SemVer version // 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; return latestVersion;
} }
function getLatestFiles(browser) { function getLatestFiles(browser) {
const pattern = `dist/betterseqtaplus@*-*${browser}.zip`; const pattern = `dist/betterseqtaplus@*-*${browser}.zip`;
console.log('Glob pattern:', pattern); console.log("Glob pattern:", pattern);
const files = glob.sync(pattern); const files = glob.sync(pattern);
console.log('Files found for browser', browser, ':', files); console.log("Files found for browser", browser, ":", files);
const latestVersion = getLatestVersion(files); const latestVersion = getLatestVersion(files);
// Find the exact file by matching the original full version // 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; return latestFile;
} }
@@ -51,44 +65,53 @@ function zipSources() {
const zipFileName = `dist/betterseqtaplus@latest-sources.zip`; const zipFileName = `dist/betterseqtaplus@latest-sources.zip`;
const excludePatterns = [ const excludePatterns = [
'node_modules', "node_modules",
'dist', "dist",
'.env*', ".env*",
'.git', ".git",
'.github', ".github",
'.vscode', ".vscode",
'LICENSE', "LICENSE",
'package.json' "package.json",
].map(pattern => `-x!${pattern}`).join(' '); ]
.map((pattern) => `-x!${pattern}`)
.join(" ");
const zipCommand = `7z a ${zipFileName} . ${excludePatterns}`; const zipCommand = `7z a ${zipFileName} . ${excludePatterns}`;
console.log('Zipping project sources with command:', zipCommand); console.log("Zipping project sources with command:", zipCommand);
execSync(zipCommand, { stdio: 'inherit' }); execSync(zipCommand, { stdio: "inherit" });
return zipFileName; return zipFileName;
} }
function runPublishCommand(browsers) { function runPublishCommand(browsers) {
const chromeZip = browsers.includes('chrome') ? getLatestFiles('chrome') : null; const chromeZip = browsers.includes("chrome")
const firefoxZip = browsers.includes('firefox') ? getLatestFiles('firefox') : null; ? getLatestFiles("chrome")
const firefoxSourcesZip = browsers.includes('firefox') ? zipSources() : null; : null;
const firefoxZip = browsers.includes("firefox")
? getLatestFiles("firefox")
: null;
const firefoxSourcesZip = browsers.includes("firefox") ? zipSources() : null;
console.log('Chrome zip:', chromeZip); console.log("Chrome zip:", chromeZip);
console.log('Firefox zip:', firefoxZip); console.log("Firefox zip:", firefoxZip);
console.log('Firefox sources zip:', firefoxSourcesZip); console.log("Firefox sources zip:", firefoxSourcesZip);
if (browsers.length === 0) { if (browsers.length === 0) {
console.log('No browsers specified. Exiting.'); console.log("No browsers specified. Exiting.");
process.exit(0); process.exit(0);
} }
if ((browsers.includes('chrome') && !chromeZip) || (browsers.includes('firefox') && (!firefoxZip || !firefoxSourcesZip))) { if (
console.error('Could not find required zip files for specified browsers.'); (browsers.includes("chrome") && !chromeZip) ||
(browsers.includes("firefox") && (!firefoxZip || !firefoxSourcesZip))
) {
console.error("Could not find required zip files for specified browsers.");
process.exit(1); process.exit(1);
} }
let command = 'publish-extension'; let command = "publish-extension";
if (chromeZip) { if (chromeZip) {
command += ` --chrome-zip ${chromeZip}`; command += ` --chrome-zip ${chromeZip}`;
} }
@@ -96,13 +119,13 @@ function runPublishCommand(browsers) {
command += ` --firefox-zip ${firefoxZip} --firefox-sources-zip ${firefoxSourcesZip}`; command += ` --firefox-zip ${firefoxZip} --firefox-sources-zip ${firefoxSourcesZip}`;
} }
console.log('Running command:', command); console.log("Running command:", command);
execSync(command, { stdio: 'inherit' }); execSync(command, { stdio: "inherit" });
} }
// Parse command-line arguments // Parse command-line arguments
const args = process.argv.slice(2); const args = process.argv.slice(2);
const browserIndex = args.indexOf('--b'); const browserIndex = args.indexOf("--b");
const browsers = browserIndex !== -1 ? args.slice(browserIndex + 1) : []; const browsers = browserIndex !== -1 ? args.slice(browserIndex + 1) : [];
runPublishCommand(browsers); runPublishCommand(browsers);
+8 -8
View File
@@ -1,17 +1,17 @@
import fs from 'fs'; import fs from "fs";
export default function touchGlobalCSSPlugin() { export default function touchGlobalCSSPlugin() {
return { return {
name: 'touch-global-css', name: "touch-global-css",
handleHotUpdate({ modules }) { handleHotUpdate({ modules }) {
// log all of the staticImportedUrls // log all of the staticImportedUrls
const importers = modules[0]._clientModule.importers const importers = modules[0]._clientModule.importers;
importers.forEach((importer) => { importers.forEach((importer) => {
if (importer.file.includes('.css')) { if (importer.file.includes(".css")) {
console.log("touching", importer.file) console.log("touching", importer.file);
fs.utimesSync(importer.file, new Date(), new Date()) 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 { ManifestV3Export } from "@crxjs/vite-plugin";
import { type AnyCase, createEnum } from './utils' import { type AnyCase, createEnum } from "./utils";
export const FrameworkEnum = { export const FrameworkEnum = {
React: 'React', React: "React",
Vanilla: 'Vanilla', Vanilla: "Vanilla",
Preact: 'Preact', Preact: "Preact",
Lit: 'Lit', Lit: "Lit",
Svelte: 'Svelte', Svelte: "Svelte",
Vue: 'Vue', Vue: "Vue",
} as const } as const;
export const BrowserEnum = { export const BrowserEnum = {
Chrome: 'Chrome', Chrome: "Chrome",
Brave: 'Brave', Brave: "Brave",
Opera: 'Opera', Opera: "Opera",
Edge: 'Edge', Edge: "Edge",
Firefox: 'Firefox', Firefox: "Firefox",
Safari: 'Safari', Safari: "Safari",
} as const } as const;
const LanguageEnum = { const LanguageEnum = {
TypeScript: 'TypeScript', TypeScript: "TypeScript",
JavaScript: 'JavaScript', JavaScript: "JavaScript",
} as const } as const;
export const StyleEnum = { export const StyleEnum = {
Tailwind: 'Tailwind', Tailwind: "Tailwind",
} as const } as const;
export const PackageManagerEnum = { export const PackageManagerEnum = {
Bun: 'Bun', Bun: "Bun",
PnPm: 'PnPm', PnPm: "PnPm",
Npm: 'Npm', Npm: "Npm",
Yarn: 'Yarn', Yarn: "Yarn",
} as const } as const;
// see: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/firefox-webext-browser/index.d.ts // see: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/firefox-webext-browser/index.d.ts
export type BrowserSpecificSettings = { export type BrowserSpecificSettings = {
browser_specific_settings?: { browser_specific_settings?: {
gecko?: { gecko?: {
id: string id: string;
strict_min_version?: string strict_min_version?: string;
strict_max_version?: string strict_max_version?: string;
} };
} };
} };
export type Manifest = ManifestV3Export export type Manifest = ManifestV3Export;
export type ManifestIcons = chrome.runtime.ManifestIcons export type ManifestIcons = chrome.runtime.ManifestIcons;
export type ManifestBackground = chrome.runtime.ManifestV3['background'] export type ManifestBackground = chrome.runtime.ManifestV3["background"];
export type ManifestContentScripts = export type ManifestContentScripts =
chrome.runtime.ManifestV3['content_scripts'] chrome.runtime.ManifestV3["content_scripts"];
export type ManifestWebAccessibleResources = export type ManifestWebAccessibleResources =
chrome.runtime.ManifestV3['web_accessible_resources'] chrome.runtime.ManifestV3["web_accessible_resources"];
export type ManifestCommands = chrome.runtime.ManifestV3['commands'] export type ManifestCommands = chrome.runtime.ManifestV3["commands"];
export type ManifestAction = chrome.runtime.ManifestV3['action'] export type ManifestAction = chrome.runtime.ManifestV3["action"];
export type ManifestPermissions = chrome.runtime.ManifestV3['permissions'] export type ManifestPermissions = chrome.runtime.ManifestV3["permissions"];
export type ManifestOptionsUI = chrome.runtime.ManifestV3['options_ui'] export type ManifestOptionsUI = chrome.runtime.ManifestV3["options_ui"];
export type ManifestURLOverrides = export type ManifestURLOverrides =
chrome.runtime.ManifestV3['chrome_url_overrides'] chrome.runtime.ManifestV3["chrome_url_overrides"];
export type BrowserName<T extends string> = Capitalize<T> | Lowercase<T> export type BrowserName<T extends string> = Capitalize<T> | Lowercase<T>;
export type BrowserEnumType<T extends string> = { export type BrowserEnumType<T extends string> = {
[browser in BrowserName<T>]: BrowserName<T> [browser in BrowserName<T>]: BrowserName<T>;
} };
export type BuildMode = AnyCase<Browser> export type BuildMode = AnyCase<Browser>;
export type BuildTarget = { export type BuildTarget = {
manifest: Manifest manifest: Manifest;
browser: AnyCase<Browser> browser: AnyCase<Browser>;
} };
export type BuildConfig = { export type BuildConfig = {
command?: 'build' | 'serve' command?: "build" | "serve";
mode?: AnyCase<Browser> | string | undefined mode?: AnyCase<Browser> | string | undefined;
} };
export interface Repository { export interface Repository {
type: string type: string;
url?: string url?: string;
bugs?: Bugs bugs?: Bugs;
} }
export interface Bugs { export interface Bugs {
url?: string url?: string;
email?: string email?: string;
} }
export type Browser = (typeof BrowserEnum)[keyof typeof BrowserEnum] export type Browser = (typeof BrowserEnum)[keyof typeof BrowserEnum];
export const Browser: AnyCase<Browser> = createEnum(BrowserEnum) export const Browser: AnyCase<Browser> = createEnum(BrowserEnum);
export type PackageManager = export type PackageManager =
(typeof PackageManagerEnum)[keyof typeof PackageManagerEnum] (typeof PackageManagerEnum)[keyof typeof PackageManagerEnum];
export const PackageManager: AnyCase<PackageManager> = export const PackageManager: AnyCase<PackageManager> =
createEnum(PackageManagerEnum) createEnum(PackageManagerEnum);
export type Framework = (typeof FrameworkEnum)[keyof typeof FrameworkEnum] export type Framework = (typeof FrameworkEnum)[keyof typeof FrameworkEnum];
export const Framework: AnyCase<Framework> = createEnum(FrameworkEnum) export const Framework: AnyCase<Framework> = createEnum(FrameworkEnum);
export type Style = (typeof StyleEnum)[keyof typeof StyleEnum] export type Style = (typeof StyleEnum)[keyof typeof StyleEnum];
export const Style: AnyCase<Style> = createEnum(StyleEnum) export const Style: AnyCase<Style> = createEnum(StyleEnum);
export type Language = (typeof LanguageEnum)[keyof typeof LanguageEnum] export type Language = (typeof LanguageEnum)[keyof typeof LanguageEnum];
export const Language: AnyCase<Language> = createEnum(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) { 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> = export type AnyCase<T extends string> =
| Uppercase<T> | Uppercase<T>
| Lowercase<T> | Lowercase<T>
| Capitalize<T> | Capitalize<T>
| Uncapitalize<T> | Uncapitalize<T>;
export type AnyCaseLanguage<T extends string, K extends string> = export type AnyCaseLanguage<T extends string, K extends string> =
| Uppercase<T | K> | Uppercase<T | K>
| Lowercase<T | K> | Lowercase<T | K>
| Capitalize<T | K> | Capitalize<T | K>
| Uncapitalize<T | K> | Uncapitalize<T | K>;
export type OptionalKeys<T> = { export type OptionalKeys<T> = {
[K in keyof T as undefined extends T[K] ? K : never]: T[K] [K in keyof T as undefined extends T[K] ? K : never]: T[K];
} };
+10 -3
View File
@@ -16,7 +16,10 @@
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg", "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", "release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
"publish": "bun lib/publish.js --b", "publish": "bun lib/publish.js --b",
"zip": "bedframe zip" "zip": "bedframe zip",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}, },
"targets": { "targets": {
"prod": { "prod": {
@@ -36,7 +39,8 @@
"@babel/plugin-transform-runtime": "^7.26.9", "@babel/plugin-transform-runtime": "^7.26.9",
"@babel/runtime": "^7.26.9", "@babel/runtime": "^7.26.9",
"@bedframe/cli": "^0.0.91", "@bedframe/cli": "^0.0.91",
"@crxjs/vite-plugin": "2.0.0-beta.25", "@crxjs/vite-plugin": "2.0.0-beta.32",
"@types/jest": "^29.5.14",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@@ -44,6 +48,7 @@
"dependency-cruiser": "^16.10.0", "dependency-cruiser": "^16.10.0",
"eslint": "9.22.0", "eslint": "9.22.0",
"glob": "^11.0.1", "glob": "^11.0.1",
"jest": "^29.7.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"process": "^0.11.10", "process": "^0.11.10",
@@ -52,6 +57,7 @@
"sass-loader": "^16.0.5", "sass-loader": "^16.0.5",
"semver": "^7.7.1", "semver": "^7.7.1",
"tailwindcss": "3", "tailwindcss": "3",
"ts-jest": "^29.3.4",
"url": "^0.11.4" "url": "^0.11.4"
}, },
"dependencies": { "dependencies": {
@@ -75,12 +81,13 @@
"@uiw/codemirror-extensions-color": "^4.23.10", "@uiw/codemirror-extensions-color": "^4.23.10",
"@uiw/codemirror-theme-github": "^4.23.10", "@uiw/codemirror-theme-github": "^4.23.10",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"client-vector-search": "../client-vector-search",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"color": "^5.0.0", "color": "^5.0.0",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"embeddia": "^1.2.1",
"embla-carousel-autoplay": "^8.5.2", "embla-carousel-autoplay": "^8.5.2",
"embla-carousel-svelte": "^8.5.2", "embla-carousel-svelte": "^8.5.2",
"esbuild": "^0.25.3",
"events": "^3.3.0", "events": "^3.3.0",
"flexsearch": "^0.8.147", "flexsearch": "^0.8.147",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
+32 -27
View File
@@ -1,55 +1,60 @@
import { import {
initializeSettingsState, initializeSettingsState,
settingsState, settingsState,
} from "@/seqta/utils/listeners/SettingsState" } from "@/seqta/utils/listeners/SettingsState";
import documentLoadCSS from "@/css/documentload.scss?inline" import documentLoadCSS from "@/css/documentload.scss?inline";
import icon48 from "@/resources/icons/icon-48.png?base64" import icon48 from "@/resources/icons/icon-48.png?base64";
import browser from "webextension-polyfill" import browser from "webextension-polyfill";
import * as plugins from "@/plugins" import * as plugins from "@/plugins";
import { main } from "@/seqta/main" import { main } from "@/seqta/main";
import { delay } from "./seqta/utils/delay";
export let MenuOptionsOpen = false;
export let MenuOptionsOpen = false var IsSEQTAPage = false;
let hasSEQTAText = false;
var IsSEQTAPage = false
let hasSEQTAText = false
// This check is placed outside of the document load event due to issues with EP (https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/84) // This check is placed outside of the document load event due to issues with EP (https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/84)
if (document.childNodes[1]) { if (document.childNodes[1]) {
hasSEQTAText = hasSEQTAText =
document.childNodes[1].textContent?.includes( document.childNodes[1].textContent?.includes(
"Copyright (c) SEQTA Software", "Copyright (c) SEQTA Software",
) ?? false ) ?? false;
init() init();
} }
async function init() { async function init() {
const hasSEQTATitle = document.title.includes("SEQTA Learn") const hasSEQTATitle = document.title.includes("SEQTA Learn");
if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) { // Verify we are on a SEQTA page if (hasSEQTAText && hasSEQTATitle && !IsSEQTAPage) {
IsSEQTAPage = true // Verify we are on a SEQTA page
console.info("[BetterSEQTA+] Verified SEQTA Page") IsSEQTAPage = true;
console.info("[BetterSEQTA+] Verified SEQTA Page");
const documentLoadStyle = document.createElement("style") const documentLoadStyle = document.createElement("style");
documentLoadStyle.textContent = documentLoadCSS documentLoadStyle.textContent = documentLoadCSS;
document.head.appendChild(documentLoadStyle) document.head.appendChild(documentLoadStyle);
const icon = document.querySelector('link[rel*="icon"]')! as HTMLLinkElement const icon = document.querySelector(
icon.href = icon48 // Change the icon 'link[rel*="icon"]',
)! as HTMLLinkElement;
icon.href = icon48; // Change the icon
try { try {
await initializeSettingsState() await initializeSettingsState();
if (typeof settingsState.onoff === "undefined") { if (typeof settingsState.onoff === "undefined") {
browser.runtime.sendMessage({ type: "setDefaultStorage" }) await browser.runtime.sendMessage({ type: "setDefaultStorage" });
await delay(5);
} }
await main() await main();
if (settingsState.onoff) { if (settingsState.onoff) {
// Initialize legacy plugins // Initialize legacy plugins
plugins.Monofile() plugins.Monofile();
// Initialize new plugin system // Initialize new plugin system
await plugins.initializePlugins(); await plugins.initializePlugins();
@@ -57,9 +62,9 @@ async function init() {
console.info( console.info(
"[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.", "[BetterSEQTA+] Successfully initialised BetterSEQTA+, starting to load assets.",
) );
} catch (error: any) { } catch (error: any) {
console.error(error) console.error(error);
} }
} }
} }
+68 -83
View File
@@ -1,63 +1,68 @@
import browser from 'webextension-polyfill' import browser from "webextension-polyfill";
import type { SettingsState } from "@/types/storage"; import type { SettingsState } from "@/types/storage";
import { fetchNews } from './background/news'; import { fetchNews } from "./background/news";
function reloadSeqtaPages() { function reloadSeqtaPages() {
const result = browser.tabs.query({}) const result = browser.tabs.query({});
function open(tabs: any) { function open(tabs: any) {
for (let tab of tabs) { for (let tab of tabs) {
if (tab.title.includes('SEQTA Learn')) { if (tab.title.includes("SEQTA Learn")) {
browser.tabs.reload(tab.id); browser.tabs.reload(tab.id);
} }
} }
} }
result.then(open, console.error) result.then(open, console.error);
} }
// @ts-ignore // @ts-ignore
browser.runtime.onMessage.addListener((request: any, _: any, sendResponse: (response?: any) => void) => { browser.runtime.onMessage.addListener(
(request: any, _: any, sendResponse: (response?: any) => void) => {
switch (request.type) { switch (request.type) {
case 'reloadTabs': case "reloadTabs":
reloadSeqtaPages(); reloadSeqtaPages();
break; break;
case 'extensionPages': case "extensionPages":
browser.tabs.query({}).then(function (tabs) { browser.tabs.query({}).then(function (tabs) {
for (let tab of tabs) { for (let tab of tabs) {
if (tab.url?.includes('chrome-extension://')) { if (tab.url?.includes("chrome-extension://")) {
browser.tabs.sendMessage(tab.id!, request); browser.tabs.sendMessage(tab.id!, request);
} }
} }
}); });
break; break;
case 'currentTab': case "currentTab":
browser.tabs.query({ active: true, currentWindow: true }).then(function (tabs) { browser.tabs
browser.tabs.sendMessage(tabs[0].id!, request).then(function (response) { .query({ active: true, currentWindow: true })
.then(function (tabs) {
browser.tabs
.sendMessage(tabs[0].id!, request)
.then(function (response) {
sendResponse(response); sendResponse(response);
}); });
}); });
return true; return true;
case 'githubTab': case "githubTab":
browser.tabs.create({ url: 'github.com/BetterSEQTA/BetterSEQTA-Plus' }); browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
break; break;
case 'setDefaultStorage': case "setDefaultStorage":
SetStorageValue(DefaultValues); SetStorageValue(DefaultValues);
break; break;
case 'sendNews': case "sendNews":
fetchNews(request.source ?? 'australia', sendResponse); fetchNews(request.source ?? "australia", sendResponse);
return true; return true;
default: default:
console.log('Unknown request type'); console.log("Unknown request type");
} }
return false; return false;
}); },
);
const DefaultValues: SettingsState = { const DefaultValues: SettingsState = {
onoff: true, onoff: true,
@@ -86,66 +91,31 @@ const DefaultValues: SettingsState = {
}, },
menuorder: [], menuorder: [],
subjectfilters: {}, subjectfilters: {},
selectedTheme: '', selectedTheme: "",
selectedColor: 'linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)', selectedColor:
originalSelectedColor: '', "linear-gradient(40deg, rgba(201,61,0,1) 0%, RGBA(170, 5, 58, 1) 100%)",
originalSelectedColor: "",
DarkMode: true, DarkMode: true,
animations: true, animations: true,
assessmentsAverage: true, assessmentsAverage: true,
defaultPage: 'home', defaultPage: "home",
shortcuts: [ shortcuts: [
{ {
name: 'YouTube', name: "Outlook",
enabled: false,
},
{
name: 'Outlook',
enabled: true, enabled: true,
}, },
{ {
name: 'Office', name: "Office",
enabled: true, enabled: true,
}, },
{ {
name: 'Spotify', name: "Google",
enabled: false,
},
{
name: 'Google',
enabled: true, enabled: true,
}, },
{
name: 'DuckDuckGo',
enabled: false,
},
{
name: 'Cool Math Games',
enabled: false,
},
{
name: 'SACE',
enabled: false,
},
{
name: 'Google Scholar',
enabled: false,
},
{
name: 'Gmail',
enabled: false,
},
{
name: 'Netflix',
enabled: false,
},
{
name: 'Education Perfect',
enabled: false,
},
], ],
customshortcuts: [], customshortcuts: [],
lettergrade: false, lettergrade: false,
newsSource: 'australia', newsSource: "australia",
}; };
function SetStorageValue(object: any) { function SetStorageValue(object: any) {
@@ -158,7 +128,8 @@ function convertBksliderToSpeed(bksliderinput: number): number {
const minBase = 50; const minBase = 50;
const maxBase = 150; const maxBase = 150;
const scaledValue = 2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4; const scaledValue =
2 + ((maxBase - bksliderinput) / (maxBase - minBase)) ** 4;
const baseSpeed = 3; const baseSpeed = 3;
const speed = baseSpeed / scaledValue; const speed = baseSpeed / scaledValue;
@@ -166,50 +137,64 @@ function convertBksliderToSpeed(bksliderinput: number): number {
} }
async function migrateLegacySettings() { async function migrateLegacySettings() {
const storage = await browser.storage.local.get(null) as unknown as SettingsState; const storage = (await browser.storage.local.get(
null,
)) as unknown as SettingsState;
// Animated Background Migration // Animated Background Migration
if ('animatedbk' in storage || 'bksliderinput' in storage) { if ("animatedbk" in storage || "bksliderinput" in storage) {
const animatedSettings = { const animatedSettings = {
enabled: storage.animatedbk ?? true, enabled: storage.animatedbk ?? true,
speed: storage.bksliderinput ? convertBksliderToSpeed(parseFloat(storage.bksliderinput)) : 1 speed: storage.bksliderinput
? convertBksliderToSpeed(parseFloat(storage.bksliderinput))
: 1,
}; };
await browser.storage.local.set({ 'plugin.animated-background.settings': animatedSettings }); await browser.storage.local.set({
"plugin.animated-background.settings": animatedSettings,
});
} }
// Assessments Average Migration // Assessments Average Migration
if ('assessmentsAverage' in storage || 'lettergrade' in storage) { if ("assessmentsAverage" in storage || "lettergrade" in storage) {
const assessmentsSettings = { const assessmentsSettings = {
enabled: storage.assessmentsAverage ?? true, enabled: storage.assessmentsAverage ?? true,
lettergrade: storage.lettergrade ?? false lettergrade: storage.lettergrade ?? false,
}; };
await browser.storage.local.set({ 'plugin.assessments-average.settings': assessmentsSettings }); await browser.storage.local.set({
"plugin.assessments-average.settings": assessmentsSettings,
});
} }
if ('selectedTheme' in storage) { if ("selectedTheme" in storage) {
const themesSettings = { enabled: true }; const themesSettings = { enabled: true };
await browser.storage.local.set({ 'plugin.themes.settings': themesSettings }); await browser.storage.local.set({
"plugin.themes.settings": themesSettings,
});
} }
if (storage.notificationCollector !== false) { if (storage.notificationCollector !== false) {
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: true } }); await browser.storage.local.set({
"plugin.notificationCollector.settings": { enabled: true },
});
} else { } else {
await browser.storage.local.set({ 'plugin.notificationCollector.settings': { enabled: false } }); await browser.storage.local.set({
"plugin.notificationCollector.settings": { enabled: false },
});
} }
const keysToRemove = [ const keysToRemove = [
'animatedbk', "animatedbk",
'bksliderinput', "bksliderinput",
'assessmentsAverage', "assessmentsAverage",
'lettergrade' "lettergrade",
]; ];
await browser.storage.local.remove(keysToRemove); await browser.storage.local.remove(keysToRemove);
} }
browser.runtime.onInstalled.addListener(function (event) { browser.runtime.onInstalled.addListener(function (event) {
browser.storage.local.remove(['justupdated']); browser.storage.local.remove(["justupdated"]);
browser.storage.local.remove(['data']); browser.storage.local.remove(["data"]);
if ( event.reason == 'install' || event.reason == 'update' ) { if (event.reason == "install" || event.reason == "update") {
browser.storage.local.set({ justupdated: true }); browser.storage.local.set({ justupdated: true });
migrateLegacySettings(); migrateLegacySettings();
} }
+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) => { const fetchAustraliaNews = async (url: string, sendResponse: any) => {
fetch(url) fetch(url)
.then((result) => result.json()) .then((result) => result.json())
.then((response) => { .then((response) => {
if (response.code == 'rateLimited') { if (response.code == "rateLimited") {
fetchAustraliaNews(url += '%00', sendResponse); fetchAustraliaNews((url += "%00"), sendResponse);
} else { } else {
sendResponse({ news: response }); sendResponse({ news: response });
} }
@@ -31,13 +31,13 @@ const rssFeedsByCountry: Record<string, string[]> = {
"https://critica.com.pa/rss.xml", "https://critica.com.pa/rss.xml",
"https://www.panamaamerica.com.pa/rss.xml", "https://www.panamaamerica.com.pa/rss.xml",
"https://noticiassin.com/feed/", "https://noticiassin.com/feed/",
"https://elcapitalfinanciero.com/feed/" "https://elcapitalfinanciero.com/feed/",
], ],
canada: [ canada: [
"https://www.cbc.ca/cmlink/rss-topstories", "https://www.cbc.ca/cmlink/rss-topstories",
"https://calgaryherald.com/feed", "https://calgaryherald.com/feed",
"https://ottawacitizen.com/feed", "https://ottawacitizen.com/feed",
"https://www.montrealgazette.com/feed" "https://www.montrealgazette.com/feed",
], ],
singapore: [ singapore: [
"https://www.straitstimes.com/news/singapore/rss.xml", "https://www.straitstimes.com/news/singapore/rss.xml",
@@ -49,12 +49,9 @@ const rssFeedsByCountry: Record<string, string[]> = {
], ],
japan: [ japan: [
"https://www3.nhk.or.jp/nhkworld/en/news/feeds/", "https://www3.nhk.or.jp/nhkworld/en/news/feeds/",
"https://news.livedoor.com/topics/rss/int.xml" "https://news.livedoor.com/topics/rss/int.xml",
],
netherlands: [
"https://www.dutchnews.nl/feed/",
"https://www.nrc.nl/rss/"
], ],
netherlands: ["https://www.dutchnews.nl/feed/", "https://www.nrc.nl/rss/"],
}; };
export async function fetchNews(source: string, sendResponse: any) { export async function fetchNews(source: string, sendResponse: any) {
@@ -63,9 +60,9 @@ export async function fetchNews(source: string, sendResponse: any) {
const from = const from =
date.getFullYear() + date.getFullYear() +
'-' + "-" +
(date.getMonth() + 1) + (date.getMonth() + 1) +
'-' + "-" +
(date.getDate() - 5); (date.getDate() - 5);
const url = `https://newsapi.org/v2/everything?domains=abc.net.au&from=${from}&apiKey=17c0da766ba347c89d094449504e3080`; const url = `https://newsapi.org/v2/everything?domains=abc.net.au&from=${from}&apiKey=17c0da766ba347c89d094449504e3080`;
@@ -76,7 +73,7 @@ export async function fetchNews(source: string, sendResponse: any) {
const parser = new Parser(); const parser = new Parser();
let feeds: string[]; let feeds: string[];
console.log('fetchNews', source) console.log("fetchNews", source);
if (rssFeedsByCountry[source.toLowerCase()]) { if (rssFeedsByCountry[source.toLowerCase()]) {
// If the source is a country, fetch from predefined feeds // If the source is a country, fetch from predefined feeds
@@ -85,7 +82,9 @@ export async function fetchNews(source: string, sendResponse: any) {
// If the source is a URL, use it directly // If the source is a URL, use it directly
feeds = [source]; feeds = [source];
} else { } else {
throw new Error("Invalid source. Provide a country code or a valid RSS feed URL."); throw new Error(
"Invalid source. Provide a country code or a valid RSS feed URL.",
);
} }
const articlesPromises = feeds.map(async (feedUrl) => { const articlesPromises = feeds.map(async (feedUrl) => {
+4 -2
View File
@@ -15,7 +15,7 @@
* along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>. * along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>.
*/ */
@use 'injected/popup.scss'; @use "injected/popup.scss";
html { html {
background: #161616 !important; background: #161616 !important;
@@ -77,7 +77,9 @@ html {
transform-origin: top; transform-origin: top;
transition: transform 0.2s; transition: transform 0.2s;
} }
body:has(.outside-container:not(.hide)) #AddedSettings.tooltip:hover > .tooltiptext { body:has(.outside-container:not(.hide))
#AddedSettings.tooltip:hover
> .tooltiptext {
transform: scale(0); transform: scale(0);
} }
.assessmenttooltip svg { .assessmenttooltip svg {
+1 -1
View File
@@ -1 +1 @@
import './documentload.scss'; import "./documentload.scss";
+10 -1
View File
@@ -25,7 +25,9 @@
span, span,
body { body {
color: white !important; color: white !important;
text-shadow: 1px 1px 2px #161616, 0 0 1em #161616; text-shadow:
1px 1px 2px #161616,
0 0 1em #161616;
} }
body { body {
@@ -112,3 +114,10 @@
transition: text-shadow 0.5s; transition: text-shadow 0.5s;
} }
} }
.cke_panel_listItem > a {
&:hover {
background: #3d3d3e !important;
}
}
+217 -37
View File
@@ -12,6 +12,13 @@
font-family: Rubik, sans-serif !important; font-family: Rubik, sans-serif !important;
} }
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
pointer-events: none;
}
.hidden { .hidden {
display: none; display: none;
} }
@@ -664,14 +671,12 @@ td.colourBar {
} }
#toolbar span:has(.search) { #toolbar span:has(.search) {
position: relative; position: relative;
/* Makes sure the pseudo-element is positioned relative to this element */
} }
#toolbar .search { #toolbar .search {
padding-left: 30px; padding-left: 30px;
} }
#toolbar span:has(.search)::before { #toolbar span:has(.search)::before {
content: "\eca5"; content: "\eca5";
/* Unicode for the search icon */
position: absolute; position: absolute;
left: 8px; left: 8px;
z-index: 10; z-index: 10;
@@ -684,7 +689,7 @@ td.colourBar {
} }
#container #content .search { #container #content .search {
width: 100%; width: 100%;
border-radius: 16px; border-radius: 8px;
background: var(--background-primary); background: var(--background-primary);
} }
#container #content .uiButton { #container #content .uiButton {
@@ -946,6 +951,23 @@ div > ol:has(.uiFileHandlerWrapper) {
opacity: 0.8; 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 { #main > .notices > .notice > .contents {
background: var(--background-primary); background: var(--background-primary);
} }
@@ -1225,17 +1247,6 @@ div > ol:has(.uiFileHandlerWrapper) {
position: relative; position: relative;
transition: 200ms; 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 { .shortcut:hover {
background: var(--auto-background); background: var(--auto-background);
} }
@@ -1375,10 +1386,13 @@ div > ol:has(.uiFileHandlerWrapper) {
margin: 20px auto 0px; margin: 20px auto 0px;
cursor: pointer; cursor: pointer;
} }
.dark [class*="notifications__detailsBody___"] > [class*="notifications__subtitle___"] { .dark
[class*="notifications__detailsBody___"]
> [class*="notifications__subtitle___"] {
color: #c1bcbc; color: #c1bcbc;
} }
[class*="notifications__detailsBody___"] > [class*="notifications__subtitle___"] { [class*="notifications__detailsBody___"]
> [class*="notifications__subtitle___"] {
font-size: 12px; font-size: 12px;
} }
[class*="notifications__notifications___"] > button { [class*="notifications__notifications___"] > button {
@@ -1394,7 +1408,9 @@ div > ol:has(.uiFileHandlerWrapper) {
height: 25px; height: 25px;
width: 24px; width: 24px;
} }
[class*="notifications__notifications___"] > button > [class*="notifications__bubble___"] { [class*="notifications__notifications___"]
> button
> [class*="notifications__bubble___"] {
background: var(--better-alert-highlight); background: var(--better-alert-highlight);
width: 25px; width: 25px;
height: 25px; height: 25px;
@@ -1523,9 +1539,9 @@ div > ol:has(.uiFileHandlerWrapper) {
background: var(--background-primary); background: var(--background-primary);
} }
[class*="Input__Input___"]::before, [class*="Input__Input___"]::before,
.navigator .bar.flat::before,
.ais-btnSearch::before { .ais-btnSearch::before {
content: ""; content: "";
/* Unicode for the search icon */
transform: translateY(-50%); transform: translateY(-50%);
color: currentColor; color: currentColor;
font-size: 16px; font-size: 16px;
@@ -1614,6 +1630,13 @@ iframe.userHTML {
[class*="Thermoscore__Thermoscore___"] { [class*="Thermoscore__Thermoscore___"] {
background-image: unset; background-image: unset;
background: var(--auto-background); 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 { #toolbar {
color: var(--text-primary); color: var(--text-primary);
@@ -1628,9 +1651,6 @@ iframe.userHTML {
.dailycal > .times > .time { .dailycal > .times > .time {
padding: 0 !important; padding: 0 !important;
} }
.navigator {
border-top-right-radius: 16px;
}
.programmeNavigator > .navigator, .programmeNavigator > .navigator,
.programmeNavigator > .navigator > li > ul { .programmeNavigator > .navigator > li > ul {
background: var(--background-primary); background: var(--background-primary);
@@ -1639,10 +1659,132 @@ iframe.userHTML {
.programmeNavigator > .navigator > .week > .lessons > .lesson:hover { .programmeNavigator > .navigator > .week > .lessons > .lesson:hover {
background: var(--auto-background); background: var(--auto-background);
} }
.programmeNavigator > .navigator > .week > .lessons > .lesson.selected,
.programmeNavigator > .navigator > .cover.selected { .pane .navigator::after {
background: transparent; 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; 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 { .dark #main > .course > .content > h1 {
text-shadow: 0 0 10px black; text-shadow: 0 0 10px black;
@@ -1692,12 +1834,19 @@ ul {
.programmeNavigator { .programmeNavigator {
width: 400px; width: 400px;
background: var(--background-primary); background: var(--background-primary);
border-top-right-radius: 16px;
position: relative; position: relative;
} }
#userActions > .details > .code { #userActions > .details > .code {
text-transform: initial; 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___"] { [class*="SelectedAssessment__SelectedAssessment___"] {
color: var(--text-primary); color: var(--text-primary);
} }
@@ -1710,7 +1859,9 @@ ul {
> [class*="SelectedAssessment__meta___"] { > [class*="SelectedAssessment__meta___"] {
border-bottom: 1px solid var(--better-main); 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); border-bottom-color: var(--better-main);
} }
[class*="TabSet__TabSet___"] > ol[class*="TabSet__tabs___"] { [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 { .quicktable {
border-radius: 12px; border-radius: 12px;
} }
@@ -2147,6 +2302,23 @@ body {
} }
[class*="Viewer__Viewer___"] { [class*="Viewer__Viewer___"] {
background: unset; 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 { .weekend {
display: none !important; display: none !important;
@@ -2181,7 +2353,9 @@ body {
border-radius: 1600px; border-radius: 1600px;
} }
[class*="MessageList__MessageList___"] > ol > li[class*="MessageList__selected___"] [class*="MessageList__MessageList___"]
> ol
> li[class*="MessageList__selected___"]
[class*="MessageList__unread___"] { [class*="MessageList__unread___"] {
box-shadow: none; box-shadow: none;
} }
@@ -2190,7 +2364,9 @@ body {
box-shadow: none; 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 { [class*="MessageList__MessageList___"] > ol > li::before {
content: ""; content: "";
position: absolute; position: absolute;
@@ -2202,7 +2378,9 @@ body {
transition: width 0.1s; transition: width 0.1s;
} }
[class*="MessageList__MessageList___"] > ol > li[class*="MessageList__unread___"]::before { [class*="MessageList__MessageList___"]
> ol
> li[class*="MessageList__unread___"]::before {
width: 3px; width: 3px;
} }
.connectedNotificationsWrapper > div > button { .connectedNotificationsWrapper > div > button {
@@ -2283,9 +2461,13 @@ body {
background: var(--background-secondary); 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); background: rgb(228 225 225);
color: var(--text-primary); color: var(--text-primary);
box-shadow: none !important;
position: relative;
} }
.NewsArticle { .NewsArticle {
border-radius: 16px !important; border-radius: 16px !important;
@@ -2429,21 +2611,17 @@ body {
} }
[data-label="inbox"] > [class*="LabelList__name___"]::before { [data-label="inbox"] > [class*="LabelList__name___"]::before {
content: "\eb70"; content: "\eb70";
/* Unicode for the search icon */
color: currentColor; color: currentColor;
font-size: 16px; font-size: 16px;
margin-right: 8px; margin-right: 8px;
/* Adjusted to margin-right for the icon to be on the left */
font-family: "IconFamily"; font-family: "IconFamily";
pointer-events: none; pointer-events: none;
} }
[data-label="outbox"] > [class*="LabelList__name___"]::before { [data-label="outbox"] > [class*="LabelList__name___"]::before {
content: "\eca6"; content: "\eca6";
/* Unicode for the search icon */
color: currentColor; color: currentColor;
font-size: 16px; font-size: 16px;
margin-right: 8px; margin-right: 8px;
/* Adjusted to margin-right for the icon to be on the left */
font-family: "IconFamily"; font-family: "IconFamily";
pointer-events: none; pointer-events: none;
} }
@@ -2452,17 +2630,14 @@ body {
color: currentColor; color: currentColor;
font-size: 16px; font-size: 16px;
margin-right: 8px; margin-right: 8px;
/* Adjusted to margin-right for the icon to be on the left */
font-family: "IconFamily"; font-family: "IconFamily";
pointer-events: none; pointer-events: none;
} }
[data-label="trash"] > [class*="LabelList__name___"]::before { [data-label="trash"] > [class*="LabelList__name___"]::before {
content: "\ed2c"; content: "\ed2c";
/* Unicode for the search icon */
color: currentColor; color: currentColor;
font-size: 16px; font-size: 16px;
margin-right: 8px; margin-right: 8px;
/* Adjusted to margin-right for the icon to be on the left */
font-family: "IconFamily"; font-family: "IconFamily";
pointer-events: none; pointer-events: none;
} }
@@ -3355,3 +3530,8 @@ body {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none !important; 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; transform-origin: 70% 0;
will-change: opacity, transform; will-change: opacity, transform;
transform: translateZ(0); // promotes GPU rendering transform: translateZ(0); // promotes GPU rendering
transition: opacity 0.05s, transform 0.05s; transition:
opacity 0.05s,
transform 0.05s;
} }
+2 -2
View File
@@ -8,7 +8,6 @@ html.transparencyEffects:not(.dark) {
--background-secondary: rgba(229, 231, 235, 0.6); --background-secondary: rgba(229, 231, 235, 0.6);
} }
html.transparencyEffects { html.transparencyEffects {
/* Background Fixes */ /* Background Fixes */
[class*="notifications__item___"], [class*="notifications__item___"],
@@ -37,7 +36,8 @@ html.transparencyEffects {
[class*="LabelList__selected___"], [class*="LabelList__selected___"],
.buttonChecklist, .buttonChecklist,
.pane, .pane,
.legacy-root button, .legacy-root a, .legacy-root button,
.legacy-root a,
[class*="MessageList__MessageList___"] { [class*="MessageList__MessageList___"] {
backdrop-filter: blur(80px); backdrop-filter: blur(80px);
} }
+7 -7
View File
@@ -1,11 +1,11 @@
declare module '*.mp4'; declare module "*.mp4";
declare module '*.woff'; declare module "*.woff";
declare module '*.scss'; declare module "*.scss";
declare module '*.png'; declare module "*.png";
declare module '*.html'; declare module "*.html";
declare module '*.svelte'; declare module "*.svelte";
declare module '*?inlineWorker' { declare module "*?inlineWorker" {
const value: () => Worker; const value: () => Worker;
export default value; export default value;
} }
+1 -1
View File
@@ -2,6 +2,6 @@
let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>(); let { onClick, text } = $props<{ onClick: () => void, text: string, [key: string]: any }>();
</script> </script>
<button onclick={onClick} class='px-4 py-1 text-[0.75rem] dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white rounded-md'> <button onclick={onClick} class='px-5 py-1.5 text-[0.75rem] shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white rounded-lg'>
{text} {text}
</button> </button>
+3 -3
View File
@@ -81,20 +81,20 @@
</script> </script>
{#if standalone} {#if standalone}
<div class="h-auto rounded-xl overflow-clip"> <div class="h-auto overflow-clip rounded-xl">
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} /> <ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
</div> </div>
{:else} {:else}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
bind:this={background} bind:this={background}
class="absolute top-0 left-0 z-50 flex items-center justify-center w-full h-full cursor-pointer bg-black/20" class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full shadow-2xl cursor-pointer bg-black/20 border border-[#DDDDDD]/30 dark:border-[#38373D]/30"
onclick={handleBackgroundClick} onclick={handleBackgroundClick}
onkeydown={(e) => { e.key === 'Enter' && handleBackgroundClick }} onkeydown={(e) => { e.key === 'Enter' && handleBackgroundClick }}
> >
<div <div
bind:this={content} bind:this={content}
class="h-auto p-4 bg-white border shadow-lg cursor-auto rounded-xl dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700" class="p-4 h-auto bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
> >
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} /> <ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
</div> </div>
+42 -27
View File
@@ -1,6 +1,6 @@
import ColorPicker from "react-best-gradient-color-picker" import ColorPicker from "react-best-gradient-color-picker";
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react";
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts" import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts";
const defaultPresets = [ const defaultPresets = [
"linear-gradient(30deg, rgba(229,209,218,1) 0%, RGBA(235,169,202,1) 46%, rgba(214,155,162,1) 100%)", "linear-gradient(30deg, rgba(229,209,218,1) 0%, RGBA(235,169,202,1) 46%, rgba(214,155,162,1) 100%)",
@@ -22,12 +22,12 @@ const defaultPresets = [
"rgba(30, 64, 175, 0.89)", "rgba(30, 64, 175, 0.89)",
"rgba(134, 25, 143, 1)", "rgba(134, 25, 143, 1)",
"rgba(14, 165, 233, 0.9)", "rgba(14, 165, 233, 0.9)",
] ];
interface PickerProps { interface PickerProps {
customOnChange?: (color: string) => void customOnChange?: (color: string) => void;
customState?: string customState?: string;
savePresets?: boolean savePresets?: boolean;
} }
export default function Picker({ export default function Picker({
@@ -35,32 +35,44 @@ export default function Picker({
customState, customState,
savePresets = true, savePresets = true,
}: PickerProps) { }: PickerProps) {
const [customThemeColor, setCustomThemeColor] = useState<string | null>() const [customThemeColor, setCustomThemeColor] = useState<string | null>();
const [presets, setPresets] = useState<string[]>() const [presets, setPresets] = useState<string[]>();
const latestValuesRef = useRef({ customThemeColor, customOnChange, savePresets, presets }); const latestValuesRef = useRef({
customThemeColor,
customOnChange,
savePresets,
presets,
});
useEffect(() => { useEffect(() => {
if (customState !== undefined && customState !== null) { if (customState !== undefined && customState !== null) {
setCustomThemeColor(customState) setCustomThemeColor(customState);
} else { } else {
setCustomThemeColor(settingsState.selectedColor ?? null) setCustomThemeColor(settingsState.selectedColor ?? null);
} }
if (presets === undefined) { if (presets === undefined) {
const savedPresets = localStorage.getItem("colorPickerPresets") const savedPresets = localStorage.getItem("colorPickerPresets");
setPresets(savedPresets ? JSON.parse(savedPresets) : defaultPresets) setPresets(savedPresets ? JSON.parse(savedPresets) : defaultPresets);
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
latestValuesRef.current = { customThemeColor, customOnChange, savePresets, presets }; latestValuesRef.current = {
customThemeColor,
customOnChange,
savePresets,
presets,
};
}, [customThemeColor, customOnChange, savePresets, presets]); }, [customThemeColor, customOnChange, savePresets, presets]);
useEffect(() => { useEffect(() => {
return () => { return () => {
const { customThemeColor, customOnChange, savePresets, presets } = latestValuesRef.current; const { customThemeColor, customOnChange, savePresets, presets } =
if (!(customThemeColor && !customOnChange && savePresets && presets)) return; latestValuesRef.current;
if (!(customThemeColor && !customOnChange && savePresets && presets))
return;
// Only proceed if presets are different (avoid unnecessary updates) // Only proceed if presets are different (avoid unnecessary updates)
const existingIndex = presets.indexOf(customThemeColor); const existingIndex = presets.indexOf(customThemeColor);
@@ -79,15 +91,18 @@ export default function Picker({
updatedPresets = [customThemeColor, ...presets].slice(0, 18); updatedPresets = [customThemeColor, ...presets].slice(0, 18);
} }
localStorage.setItem("colorPickerPresets", JSON.stringify(updatedPresets)); localStorage.setItem(
} "colorPickerPresets",
}, []) JSON.stringify(updatedPresets),
);
};
}, []);
useEffect(() => { useEffect(() => {
if (customThemeColor && !customOnChange) { if (customThemeColor && !customOnChange) {
settingsState.selectedColor = customThemeColor settingsState.selectedColor = customThemeColor;
} }
}, [customThemeColor, customOnChange]) }, [customThemeColor, customOnChange]);
return ( return (
<ColorPicker <ColorPicker
@@ -97,12 +112,12 @@ export default function Picker({
value={customThemeColor ?? ""} value={customThemeColor ?? ""}
onChange={(color: string) => { onChange={(color: string) => {
if (customOnChange) { if (customOnChange) {
customOnChange(color) customOnChange(color);
setCustomThemeColor(color) setCustomThemeColor(color);
} else { } else {
setCustomThemeColor(color) setCustomThemeColor(color);
} }
}} }}
/> />
) );
} }
+227
View File
@@ -0,0 +1,227 @@
<script lang="ts">
import { isValidHotkey, parseHotkey } from '@/plugins/built-in/globalSearch/src/utils/hotkeyUtils';
let { value, onChange } = $props<{
value: string,
onChange: (newValue: string) => void
}>();
let isRecording = $state(false);
let recordedKeys = $state<Set<string>>(new Set());
let inputElement = $state<HTMLInputElement>();
const formatKeyForHotkey = (key: string): string => {
// Map special keys to their hotkey format
const keyMap: Record<string, string> = {
'Control': 'ctrl',
'Meta': 'cmd',
'Alt': 'alt',
'Shift': 'shift',
' ': 'space',
'ArrowUp': 'up',
'ArrowDown': 'down',
'ArrowLeft': 'left',
'ArrowRight': 'right',
'Escape': 'esc',
'Enter': 'enter',
'Tab': 'tab',
'Backspace': 'backspace',
'Delete': 'delete',
};
return keyMap[key] || key.toLowerCase();
};
const formatKeyForDisplay = (key: string): string => {
// Map keys to their display format
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const keyMap: Record<string, string> = {
'ctrl': isMac ? '⌃' : 'Ctrl',
'cmd': '⌘',
'meta': '⌘',
'alt': isMac ? '⌥' : 'Alt',
'shift': isMac ? '⇧' : 'Shift',
'space': 'Space',
'up': '↑',
'down': '↓',
'left': '←',
'right': '→',
'esc': 'Esc',
'enter': 'Enter',
'tab': 'Tab',
'backspace': 'Backspace',
'delete': 'Delete',
};
return keyMap[key.toLowerCase()] || key.toUpperCase();
};
const getHotkeyParts = (hotkeyString: string): string[] => {
if (!hotkeyString || !isValidHotkey(hotkeyString)) {
return [];
}
const parsed = parseHotkey(hotkeyString);
const parts: string[] = [];
// Add modifiers in a consistent order
if (parsed.ctrl) parts.push('ctrl');
if (parsed.meta) parts.push('cmd');
if (parsed.alt) parts.push('alt');
if (parsed.shift) parts.push('shift');
// Add the main key
if (parsed.key) parts.push(parsed.key);
return parts;
};
const startRecording = () => {
isRecording = true;
recordedKeys.clear();
inputElement?.focus();
};
const stopRecording = () => {
if (recordedKeys.size > 0) {
if (recordedKeys.has('esc')) {
onChange('');
isRecording = false;
recordedKeys.clear();
inputElement?.blur();
return;
}
// Build the hotkey string
const modifiers: string[] = [];
let mainKey = '';
for (const key of recordedKeys) {
if (['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
modifiers.push(key);
} else {
mainKey = key;
}
}
if (mainKey) {
const hotkeyString = [...modifiers, mainKey].join('+');
if (isValidHotkey(hotkeyString)) {
onChange(hotkeyString);
}
}
}
isRecording = false;
recordedKeys.clear();
inputElement?.blur();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isRecording) return;
e.preventDefault();
e.stopPropagation();
const key = formatKeyForHotkey(e.key);
// Add modifiers
if (e.ctrlKey) recordedKeys.add('ctrl');
if (e.metaKey) recordedKeys.add('cmd');
if (e.altKey) recordedKeys.add('alt');
if (e.shiftKey) recordedKeys.add('shift');
// Add the main key (ignore modifier keys themselves)
if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
recordedKeys.add(key);
}
// Auto-stop recording if we have a main key
if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
setTimeout(stopRecording, 100);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!isRecording) return;
e.preventDefault();
e.stopPropagation();
};
const handleBlur = () => {
if (isRecording) {
stopRecording();
}
};
$effect(() => {
if (isRecording && inputElement) {
inputElement.focus();
}
});
// Get the parts to display
const hotkeyParts = $derived(isRecording
? Array.from(recordedKeys).map(formatKeyForDisplay)
: getHotkeyParts(value).map(formatKeyForDisplay));
</script>
<div class="flex gap-2 items-center">
<div class="relative">
{#if isRecording}
<!-- Recording state -->
<div
class="flex items-center justify-center px-3 py-1.5 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white border cursor-pointer text-nowrap"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
Press keys...
</div>
{:else if hotkeyParts.length > 0}
<!-- Display current hotkey -->
<div
class="flex gap-1 items-center text-sm rounded-md border-none cursor-pointer dark:text-white"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
{#each hotkeyParts as part}
<div class="size-8 text-sm flex items-center justify-center rounded-md border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30">
{part}
</div>
{/each}
</div>
{:else}
<!-- Empty state -->
<div
class="flex items-center justify-center px-3 py-2 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD] dark:text-white border-none cursor-pointer text-nowrap"
onclick={startRecording}
onkeydown={startRecording}
role="button"
tabindex="0"
>
<span class="text-gray-500 dark:text-gray-400">Click to set</span>
</div>
{/if}
<!-- Hidden input for focus management -->
<input
bind:this={inputElement}
type="text"
readonly
class="absolute inset-0 opacity-0 pointer-events-none"
onkeydown={handleKeyDown}
onkeyup={handleKeyUp}
onblur={handleBlur}
/>
</div>
</div>
<style>
input:focus {
outline: none;
}
</style>
+1 -1
View File
@@ -8,5 +8,5 @@
aria-label="Color Picker Swatch" aria-label="Color Picker Swatch"
onclick={onClick} onclick={onClick}
style="background: {$settingsState.selectedColor}" style="background: {$settingsState.selectedColor}"
class="w-16 h-8 rounded-md" class="w-16 h-8 rounded-md shadow-2xl ring-[1px] ring-[#DDDDDD]/30 dark:ring-[#38373D]/30"
></button> ></button>
+3 -1
View File
@@ -8,11 +8,12 @@
let select: HTMLSelectElement; let select: HTMLSelectElement;
</script> </script>
<div class="border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 shadow-2xl rounded-lg w-full overflow-clip">
<select <select
bind:this={select} bind:this={select}
value={state} value={state}
onchange={() => onChange(select.value)} onchange={() => onChange(select.value)}
class="px-4 py-1 text-[0.75rem] dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white rounded-md w-full" class="px-4 py-1 text-[0.75rem] dark:text-white w-full border-none bg-transparent focus:ring-0 focus:bg-white/20 dark:focus:bg-black/10"
> >
{#each options as option} {#each options as option}
<option value={option.value}> <option value={option.value}>
@@ -20,3 +21,4 @@
</option> </option>
{/each} {/each}
</select> </select>
</div>
+3 -2
View File
@@ -16,9 +16,9 @@
max={max} max={max}
step={step} step={step}
bind:value={state} bind:value={state}
style={`background: linear-gradient(to right, #30D259 ${percentage}%, #dddddd ${percentage}%)`} style={`background: linear-gradient(to right, #30d259ad 0%, #30D259 ${percentage}%, #dddddd ${percentage}%)`}
onchange={(e) => onChange(Number(e.currentTarget.value))} onchange={(e) => onChange(Number(e.currentTarget.value))}
class="w-full h-1 rounded-full appearance-none cursor-pointer dark:bg-[#38373D] bg-[#DDDDDD] slider" class="w-full h-1 rounded-full appearance-none cursor-pointer slider"
/> />
</div> </div>
@@ -38,6 +38,7 @@
height: 24px; height: 24px;
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.3); box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.3);
background: white; background: white;
color: #30d259ad;
cursor: pointer; cursor: pointer;
border-radius: 50%; border-radius: 50%;
} }
+1 -1
View File
@@ -1,4 +1,4 @@
.dark .switch[data-ison="true"], .dark .switch[data-ison="true"],
.switch[data-ison="true"] { .switch[data-ison="true"] {
background-color: #30D259; background-color: #30d259;
} }
+1 -8
View File
@@ -30,8 +30,7 @@
</script> </script>
<div <div
class="flex w-14 p-1 cursor-pointer transition-all duration-150 rounded-full dark:bg-[#38373D] bg-[#DDDDDD] switch select-none" class="flex w-14 p-1 cursor-pointer transition-all duration-150 rounded-full bg-gradient-to-tr select-none shadow-2xl ring-[1px] ring-[#DDDDDD]/30 dark:ring-[#38373D]/30 {state ? 'to-[#30D259]/80 from-[#30D259] dark:from-[#30D259]/40 dark:to-[#30D259]' : 'dark:from-[#38373D]/50 dark:to-[#38373D] to-[#DDDDDD]/50 from-[#DDDDDD]'}"
data-ison={state}
onclick={() => onChange(!state)} onclick={() => onChange(!state)}
onkeydown={(e) => e.key === "Enter" && onChange(!state)} onkeydown={(e) => e.key === "Enter" && onChange(!state)}
role="switch" role="switch"
@@ -43,9 +42,3 @@
class="w-6 h-6 bg-white dark:bg-[#FEFEFE] rounded-full drop-shadow-md" class="w-6 h-6 bg-white dark:bg-[#FEFEFE] rounded-full drop-shadow-md"
></div> ></div>
</div> </div>
<style>
.switch[data-ison="true"] {
background-color: #30D259;
}
</style>
@@ -43,7 +43,7 @@
<div class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container"> <div class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container">
<div bind:this={containerRef} class="flex relative"> <div bind:this={containerRef} class="flex relative">
<MotionDiv <MotionDiv
class="absolute top-0 left-0 z-0 h-full bg-[#DDDDDD] dark:bg-[#38373D] rounded-full opacity-40 tab-width" class="absolute top-0 left-0 z-0 h-full bg-gradient-to-tr dark:from-[#38373D]/80 dark:to-[#38373D] from-[#DDDDDD]/80 to-[#DDDDDD] rounded-full opacity-40 tab-width"
animate={{ x: calcXPos(activeTab) }} animate={{ x: calcXPos(activeTab) }}
transition={springTransition} transition={springTransition}
/> />
@@ -65,8 +65,9 @@
> >
<div class="flex"> <div class="flex">
{#each tabs as { Content, props }, index} {#each tabs as { Content, props }, index}
<div class="absolute focus:outline-none w-full transition-opacity duration-300 overflow-y-scroll no-scrollbar h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}" <div class="absolute focus:outline-none w-full pt-2 transition-opacity duration-300 overflow-y-scroll no-scrollbar pb-2 h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}"
style="left: {index * 100}%;"> style="left: {index * 100}%;">
<div style="left: {index * 100}%;" class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
<Content {...props} /> <Content {...props} />
</div> </div>
{/each} {/each}
@@ -14,12 +14,14 @@
let isDragging = $state(false); let isDragging = $state(false);
let tempTheme = $state(null); let tempTheme = $state(null);
const handleThemeClick = async (theme: CustomTheme) => { const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
if (isEditMode) return; if (isEditMode) return;
if (theme.id === themes?.selectedTheme) { if (theme.id === themes?.selectedTheme) {
themeManager.setTransitionPoint(e.clientX, e.clientY);
await themeManager.disableTheme(); await themeManager.disableTheme();
themes.selectedTheme = ''; themes.selectedTheme = '';
} else { } else {
themeManager.setTransitionPoint(e.clientX, e.clientY);
await themeManager.setTheme(theme.id); await themeManager.setTheme(theme.id);
if (!themes) return; if (!themes) return;
themes.selectedTheme = theme.id; themes.selectedTheme = theme.id;
@@ -127,7 +129,7 @@
{#each themes.themes as theme (theme.id)} {#each themes.themes as theme (theme.id)}
<button <button
class="relative group w-full aspect-theme flex justify-center items-center rounded-xl transition ring dark:ring-white ring-zinc-300 {theme.id === themes.selectedTheme ? 'dark:ring-2 ring-4' : 'ring-0'}" class="relative group w-full aspect-theme flex justify-center items-center rounded-xl transition ring dark:ring-white ring-zinc-300 {theme.id === themes.selectedTheme ? 'dark:ring-2 ring-4' : 'ring-0'}"
onclick={() => handleThemeClick(theme)} onclick={(e) => handleThemeClick(theme, e)}
> >
{#if isEditMode} {#if isEditMode}
<div <div
+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 { interface BackgroundDB extends DBSchema {
backgrounds: { backgrounds: {
@@ -16,38 +16,46 @@ let db: IDBPDatabase<BackgroundDB> | null = null;
export async function openDatabase(): Promise<IDBPDatabase<BackgroundDB>> { export async function openDatabase(): Promise<IDBPDatabase<BackgroundDB>> {
if (db) return db; if (db) return db;
db = await openDB<BackgroundDB>('BackgroundDB', 1, { db = await openDB<BackgroundDB>("BackgroundDB", 1, {
upgrade(db: IDBPDatabase<BackgroundDB>) { upgrade(db: IDBPDatabase<BackgroundDB>) {
db.createObjectStore('backgrounds', { keyPath: 'id' }); db.createObjectStore("backgrounds", { keyPath: "id" });
}, },
}); });
return db; return db;
} }
export async function readAllData(): Promise<Array<{ id: string; type: string; blob: Blob }>> { export async function readAllData(): Promise<
Array<{ id: string; type: string; blob: Blob }>
> {
const db = await openDatabase(); const db = await openDatabase();
return db.getAll('backgrounds'); return db.getAll("backgrounds");
} }
export async function writeData(id: string, type: string, blob: Blob): Promise<void> { export async function writeData(
id: string,
type: string,
blob: Blob,
): Promise<void> {
const db = await openDatabase(); const db = await openDatabase();
await db.put('backgrounds', { id, type, blob }); await db.put("backgrounds", { id, type, blob });
} }
export async function deleteData(id: string): Promise<void> { export async function deleteData(id: string): Promise<void> {
const db = await openDatabase(); const db = await openDatabase();
await db.delete('backgrounds', id); await db.delete("backgrounds", id);
} }
export async function clearAllData(): Promise<void> { export async function clearAllData(): Promise<void> {
const db = await openDatabase(); const db = await openDatabase();
await db.clear('backgrounds'); await db.clear("backgrounds");
} }
export async function getDataById(id: string): Promise<{ id: string; type: string; blob: Blob } | undefined> { export async function getDataById(
id: string,
): Promise<{ id: string; type: string; blob: Blob } | undefined> {
const db = await openDatabase(); const db = await openDatabase();
return db.get('backgrounds', id); return db.get("backgrounds", id);
} }
export function closeDatabase(): void { export function closeDatabase(): void {
@@ -59,15 +67,17 @@ export function closeDatabase(): void {
// Helper function to check if IndexedDB is supported // Helper function to check if IndexedDB is supported
export function isIndexedDBSupported(): boolean { export function isIndexedDBSupported(): boolean {
return 'indexedDB' in window; return "indexedDB" in window;
} }
// Helper function to check if there's enough storage space // Helper function to check if there's enough storage space
export async function hasEnoughStorageSpace(requiredSpace: number): Promise<boolean> { export async function hasEnoughStorageSpace(
if ('storage' in navigator && 'estimate' in navigator.storage) { requiredSpace: number,
): Promise<boolean> {
if ("storage" in navigator && "estimate" in navigator.storage) {
const { quota, usage } = await navigator.storage.estimate(); const { quota, usage } = await navigator.storage.estimate();
if (quota !== undefined && usage !== undefined) { if (quota !== undefined && usage !== undefined) {
return (quota - usage) > requiredSpace; return quota - usage > requiredSpace;
} }
} }
// If we can't determine, assume there's enough space // If we can't determine, assume there's enough space
+1 -1
View File
@@ -22,7 +22,7 @@ class BackgroundUpdates {
} }
public triggerUpdate(): void { public triggerUpdate(): void {
this.listeners.forEach(callback => callback()); this.listeners.forEach((callback) => callback());
} }
} }
+1 -1
View File
@@ -30,7 +30,7 @@ class SettingsPopup {
} }
public triggerClose(): void { 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 { public triggerUpdate(): void {
this.listeners.forEach(callback => callback()); this.listeners.forEach((callback) => callback());
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
@import './components/ColourPicker.css'; @import "./components/ColourPicker.css";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
+1 -1
View File
@@ -6,7 +6,7 @@
<title>BetterSEQTA+ Settings</title> <title>BetterSEQTA+ Settings</title>
</head> </head>
<body class="h-[600px]"> <body class="h-[600px]">
<div id="app" style="height: 100%;"></div> <div id="app" style="height: 100%"></div>
<script type="module" src="./index.ts"></script> <script type="module" src="./index.ts"></script>
</body> </body>
</html> </html>
+15 -15
View File
@@ -1,29 +1,29 @@
import "./index.css" import "./index.css";
import Settings from "./pages/settings.svelte" import Settings from "./pages/settings.svelte";
import IconFamily from '@/resources/fonts/IconFamily.woff' import IconFamily from "@/resources/fonts/IconFamily.woff";
import browser from "webextension-polyfill" import browser from "webextension-polyfill";
import renderSvelte from "./main" import renderSvelte from "./main";
function InjectCustomIcons() { function InjectCustomIcons() {
console.info('[BetterSEQTA+] Injecting Icons') console.info("[BetterSEQTA+] Injecting Icons");
const style = document.createElement('style') const style = document.createElement("style");
style.setAttribute('type', 'text/css') style.setAttribute("type", "text/css");
style.innerHTML = ` style.innerHTML = `
@font-face { @font-face {
font-family: 'IconFamily'; font-family: 'IconFamily';
src: url('${browser.runtime.getURL(IconFamily)}') format('woff'); src: url('${browser.runtime.getURL(IconFamily)}') format('woff');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
}` }`;
document.head.appendChild(style) document.head.appendChild(style);
} }
const mountPoint = document.getElementById('app') const mountPoint = document.getElementById("app");
if (!mountPoint) { if (!mountPoint) {
console.error('Mount point #app not found') console.error("Mount point #app not found");
throw new Error('Mount point #app not found') throw new Error("Mount point #app not found");
} }
InjectCustomIcons() InjectCustomIcons();
renderSvelte(Settings, mountPoint, { standalone: true }) renderSvelte(Settings, mountPoint, { standalone: true });
+1 -1
View File
@@ -1,4 +1,4 @@
import './index.css'; import "./index.css";
declare module "*.png"; declare module "*.png";
declare module "*.svg"; declare module "*.svg";
+8 -8
View File
@@ -1,6 +1,6 @@
import { mount } from "svelte" import { mount } from "svelte";
import type { SvelteComponent } from "svelte" import type { SvelteComponent } from "svelte";
import style from './index.css?inline' import style from "./index.css?inline";
export default function renderSvelte( export default function renderSvelte(
Component: SvelteComponent | any, Component: SvelteComponent | any,
@@ -13,11 +13,11 @@ export default function renderSvelte(
standalone: false, standalone: false,
...props, ...props,
}, },
}) });
const styleElement = document.createElement('style') const styleElement = document.createElement("style");
styleElement.textContent = style styleElement.textContent = style;
mountPoint.appendChild(styleElement) mountPoint.appendChild(styleElement);
return app return app;
} }
+1 -2
View File
@@ -59,14 +59,13 @@
if (!standalone) return; if (!standalone) return;
initializeSettingsState(); initializeSettingsState();
console.log('settingsState', $settingsState);
StandaloneStore.setStandalone(true); StandaloneStore.setStandalone(true);
}); });
</script> </script>
<div class="w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode ? 'dark' : ''} { standalone ? 'h-[600px]' : 'h-full rounded-xl' } overflow-clip"> <div class="w-[384px] no-scrollbar shadow-2xl {$settingsState.DarkMode ? 'dark' : ''} { standalone ? 'h-[600px]' : 'h-full rounded-xl' } overflow-clip">
<div class="flex relative flex-col gap-2 h-full overflow-clip bg-white dark:bg-zinc-800 dark:text-white"> <div class="flex relative flex-col gap-2 h-full overflow-clip bg-white dark:bg-zinc-800 dark:text-white">
<div class="grid place-items-center border-b border-b-zinc-200/40"> <div class="grid place-items-center border-b border-b-zinc-200/40 dark:border-b-zinc-700/40">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<img src={browser.runtime.getURL('resources/icons/betterseqta-dark-full.png')} class="w-4/5 dark:hidden" alt="Light logo" onclick={handleDevModeToggle} /> <img src={browser.runtime.getURL('resources/icons/betterseqta-dark-full.png')} class="w-4/5 dark:hidden" alt="Light logo" onclick={handleDevModeToggle} />
+39 -7
View File
@@ -3,6 +3,7 @@
import Button from "../../components/Button.svelte" import Button from "../../components/Button.svelte"
import Slider from "../../components/Slider.svelte" import Slider from "../../components/Slider.svelte"
import Select from "@/interface/components/Select.svelte" import Select from "@/interface/components/Select.svelte"
import HotkeyInput from "@/interface/components/HotkeyInput.svelte"
import browser from "webextension-polyfill" import browser from "webextension-polyfill"
@@ -12,7 +13,7 @@
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent" import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent"
import { getAllPluginSettings } from "@/plugins" import { getAllPluginSettings } from "@/plugins"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting } from "@/plugins/core/types" import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting } from "@/plugins/core/types"
// Union type representing all possible settings // Union type representing all possible settings
type SettingType = type SettingType =
@@ -23,12 +24,21 @@
type: 'select', type: 'select',
id: string, id: string,
options: string[] options: string[]
}) |
(Omit<ButtonSetting, 'type'> & {
type: 'button',
id: string
}) |
(Omit<HotkeySetting, 'type'> & {
type: 'hotkey',
id: string
}); });
interface Plugin { interface Plugin {
pluginId: string; pluginId: string;
name: string; name: string;
description: string; description: string;
beta?: boolean;
settings: Record<string, SettingType>; settings: Record<string, SettingType>;
} }
@@ -45,7 +55,7 @@
pluginSettingsValues[plugin.pluginId] = stored[storageKey] || {}; pluginSettingsValues[plugin.pluginId] = stored[storageKey] || {};
for (const [key, setting] of Object.entries(plugin.settings)) { for (const [key, setting] of Object.entries(plugin.settings)) {
if (pluginSettingsValues[plugin.pluginId][key] === undefined) { if (pluginSettingsValues[plugin.pluginId][key] === undefined && setting.type !== 'button') {
pluginSettingsValues[plugin.pluginId][key] = setting.default; pluginSettingsValues[plugin.pluginId][key] = setting.default;
} }
} }
@@ -184,12 +194,20 @@
{/each} {/each}
{#each pluginSettings as plugin} {#each pluginSettings as plugin}
<div> <div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40 {!(plugin as any).disableToggle && Object.keys(plugin.settings).length === 0 ? 'hidden' : ''}">
<!-- Always show enable toggle if disableToggle is true --> <!-- Always show enable toggle if disableToggle is true -->
{#if (plugin as any).disableToggle} {#if (plugin as any).disableToggle}
<div class="flex justify-between items-center px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
<div class="pr-4"> <div class="pr-4">
<h2 class="text-sm font-bold">Enable {plugin.name}</h2> <h2 class="flex gap-2 items-center text-sm font-bold">
Enable {plugin.name}
{#if plugin.beta}
<span class="px-2 py-0.5 text-xs font-medium text-orange-800 bg-orange-100 rounded-full border border-orange-300/30 dark:bg-orange-900/30 dark:text-orange-300 dark:border-orange-900/30">
Beta
</span>
{/if}
</h2>
<p class="text-xs">{plugin.description}</p> <p class="text-xs">{plugin.description}</p>
</div> </div>
<div> <div>
@@ -201,7 +219,6 @@
</div> </div>
{/if} {/if}
<!-- Only show other settings if plugin is enabled or has no disableToggle -->
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)} {#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]} {#each Object.entries(plugin.settings) as [key, setting]}
<!-- Skip the 'enabled' setting if it's part of the settings object --> <!-- Skip the 'enabled' setting if it's part of the settings object -->
@@ -228,7 +245,7 @@
{:else if setting.type === 'string'} {:else if setting.type === 'string'}
<input <input
type="text" type="text"
class="px-2 py-1 text-sm rounded-md dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white" class="px-2 py-1 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD] dark:text-white border-none"
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default} value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
oninput={(e) => updatePluginSetting(plugin.pluginId, key, e.currentTarget.value)} oninput={(e) => updatePluginSetting(plugin.pluginId, key, e.currentTarget.value)}
/> />
@@ -241,6 +258,16 @@
label: opt.charAt(0).toUpperCase() + opt.slice(1) label: opt.charAt(0).toUpperCase() + opt.slice(1)
}))} }))}
/> />
{:else if setting.type === 'button'}
<Button
onClick={() => setting.trigger?.()}
text={setting.title}
/>
{:else if setting.type === 'hotkey'}
<HotkeyInput
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/>
{/if} {/if}
</div> </div>
</div> </div>
@@ -248,8 +275,11 @@
{/each} {/each}
{/if} {/if}
</div> </div>
</div>
{/each} {/each}
<div class="p-1 border-none"></div>
{@render Setting({ {@render Setting({
title: "BetterSEQTA+", title: "BetterSEQTA+",
description: "Enables BetterSEQTA+ features", description: "Enables BetterSEQTA+ features",
@@ -262,7 +292,8 @@
})} })}
{#if $settingsState.devMode} {#if $settingsState.devMode}
<div class="flex items-center justify-between px-4 py-3 mt-4 pt-[1.75rem]"> <div class="flex-col p-1 my-1 bg-gradient-to-br from-white rounded-xl border shadow-sm to-zinc-100 border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4"> <div class="pr-4">
<h2 class="text-sm font-bold">Developer Mode</h2> <h2 class="text-sm font-bold">Developer Mode</h2>
<p class="text-xs">Enables developer mode, allowing you to test new features and changes.</p> <p class="text-xs">Enables developer mode, allowing you to test new features and changes.</p>
@@ -283,5 +314,6 @@
/> />
</div> </div>
</div> </div>
</div>
{/if} {/if}
</div> </div>
+17 -15
View File
@@ -3,6 +3,7 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts" import { settingsState } from "@/seqta/utils/listeners/SettingsState.ts"
import Switch from "@/interface/components/Switch.svelte" import Switch from "@/interface/components/Switch.svelte"
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Shortcuts from "@/seqta/content/links.json"
let isLoaded = $state(false); let isLoaded = $state(false);
@@ -21,10 +22,14 @@
}); });
}); });
const switchChange = (index: number) => { const switchChange = (shortcut: any) => {
const updatedShortcuts = [...settingsState.shortcuts]; const value = $settingsState.shortcuts.find(s => s.name === shortcut);
updatedShortcuts[index].enabled = !updatedShortcuts[index].enabled; if (value) {
settingsState.shortcuts = updatedShortcuts; value.enabled = !value.enabled;
settingsState.shortcuts = settingsState.shortcuts;
} else {
settingsState.shortcuts = [...settingsState.shortcuts, { name: shortcut, enabled: true }];
}
} }
let isFormVisible = $state(false); let isFormVisible = $state(false);
@@ -65,15 +70,6 @@
}; };
</script> </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"> <div class="flex flex-col pt-4 divide-y divide-zinc-100 dark:divide-zinc-700">
{#if isLoaded} {#if isLoaded}
<div> <div>
@@ -136,8 +132,14 @@
</MotionDiv> </MotionDiv>
</div> </div>
{#each Object.entries($settingsState.shortcuts) as shortcut} {#each Object.entries(Shortcuts) as shortcut}
{@render Shortcuts(shortcut)} <div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<!-- Use DisplayName if it exists, otherwise use the key (shortcut[0]) as a fallback -->
<h2 class="text-sm">{shortcut[1].DisplayName || shortcut[0]}</h2>
</div>
<Switch state={$settingsState.shortcuts.find(s => s.name === shortcut[0])?.enabled ?? false} onChange={() => switchChange(shortcut[0])} />
</div>
{/each} {/each}
<!-- Custom Shortcuts Section --> <!-- Custom Shortcuts Section -->
+1 -1
View File
@@ -2,6 +2,6 @@ export interface SettingsList {
title: string; title: string;
id: number; id: number;
description: string; description: string;
Component: any; /* TODO: Give this a type */ Component: any /* TODO: Give this a type */;
props?: any; props?: any;
} }
+1 -1
View File
@@ -16,7 +16,7 @@ export class Standalone {
public setStandalone(value: boolean) { public setStandalone(value: boolean) {
this._standalone = value; this._standalone = value;
this.subscribers.forEach(subscriber => subscriber(value)); this.subscribers.forEach((subscriber) => subscriber(value));
} }
public get standalone() { public get standalone() {
+31 -11
View File
@@ -1,23 +1,31 @@
import type { LoadedCustomTheme } from '@/types/CustomThemes'; import type { LoadedCustomTheme } from "@/types/CustomThemes";
export function generateImageId(): string { export function generateImageId(): string {
return Math.random().toString(36).substr(2, 9); return Math.random().toString(36).substr(2, 9);
} }
export function handleImageUpload(event: Event, theme: LoadedCustomTheme): Promise<LoadedCustomTheme> | LoadedCustomTheme { export function handleImageUpload(
event: Event,
theme: LoadedCustomTheme,
): Promise<LoadedCustomTheme> | LoadedCustomTheme {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
input.value = ''; input.value = "";
if (file) { if (file) {
return new Promise((resolve) => { return new Promise((resolve) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async () => { reader.onload = async () => {
const imageBlob = await fetch(reader.result as string).then(res => res.blob()); const imageBlob = await fetch(reader.result as string).then((res) =>
res.blob(),
);
const imageId = generateImageId(); const imageId = generateImageId();
const variableName = `custom-image-${theme.CustomImages.length}`; const variableName = `custom-image-${theme.CustomImages.length}`;
resolve({ resolve({
...theme, ...theme,
CustomImages: [...theme.CustomImages, { id: imageId, blob: imageBlob, variableName, url: null }], CustomImages: [
...theme.CustomImages,
{ id: imageId, blob: imageBlob, variableName, url: null },
],
}); });
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
@@ -26,31 +34,43 @@ export function handleImageUpload(event: Event, theme: LoadedCustomTheme): Promi
return theme; return theme;
} }
export function handleRemoveImage(imageId: string, theme: LoadedCustomTheme): LoadedCustomTheme { export function handleRemoveImage(
imageId: string,
theme: LoadedCustomTheme,
): LoadedCustomTheme {
return { return {
...theme, ...theme,
CustomImages: theme.CustomImages.filter((image) => image.id !== imageId), CustomImages: theme.CustomImages.filter((image) => image.id !== imageId),
} as LoadedCustomTheme; } as LoadedCustomTheme;
} }
export function handleImageVariableChange(imageId: string, variableName: string, theme: LoadedCustomTheme): LoadedCustomTheme { export function handleImageVariableChange(
imageId: string,
variableName: string,
theme: LoadedCustomTheme,
): LoadedCustomTheme {
return { return {
...theme, ...theme,
CustomImages: theme.CustomImages.map((image) => CustomImages: theme.CustomImages.map((image) =>
image.id === imageId ? { ...image, variableName } : image image.id === imageId ? { ...image, variableName } : image,
), ),
} as LoadedCustomTheme; } as LoadedCustomTheme;
} }
export function handleCoverImageUpload(event: Event, theme: LoadedCustomTheme): Promise<LoadedCustomTheme> { export function handleCoverImageUpload(
event: Event,
theme: LoadedCustomTheme,
): Promise<LoadedCustomTheme> {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
input.value = ''; input.value = "";
if (file) { if (file) {
return new Promise((resolve) => { return new Promise((resolve) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async () => { reader.onload = async () => {
const imageBlob = await fetch(reader.result as string).then(res => res.blob()); const imageBlob = await fetch(reader.result as string).then((res) =>
res.blob(),
);
resolve({ ...theme, coverImage: imageBlob }); resolve({ ...theme, coverImage: imageBlob });
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest' import { createManifest } from "../../lib/createManifest";
import baseManifest from './manifest.json' import baseManifest from "./manifest.json";
import pkg from '../../package.json' import pkg from "../../package.json";
export const brave = createManifest({ export const brave = createManifest(
{
...baseManifest, ...baseManifest,
version: pkg.version, version: pkg.version,
description: pkg.description, description: pkg.description,
}, 'brave') },
"brave",
);
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest' import { createManifest } from "../../lib/createManifest";
import baseManifest from './manifest.json' import baseManifest from "./manifest.json";
import pkg from '../../package.json' import pkg from "../../package.json";
export const chrome = createManifest({ export const chrome = createManifest(
{
...baseManifest, ...baseManifest,
version: pkg.version, version: pkg.version,
description: pkg.description, description: pkg.description,
}, 'chrome') },
"chrome",
);
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest' import { createManifest } from "../../lib/createManifest";
import baseManifest from './manifest.json' import baseManifest from "./manifest.json";
import pkg from '../../package.json' import pkg from "../../package.json";
export const edge = createManifest({ export const edge = createManifest(
{
...baseManifest, ...baseManifest,
version: pkg.version, version: pkg.version,
description: pkg.description, description: pkg.description,
}, 'edge') },
"edge",
);
+7 -7
View File
@@ -1,6 +1,6 @@
import { createManifest } from '../../lib/createManifest' import { createManifest } from "../../lib/createManifest";
import baseManifest from './manifest.json' import baseManifest from "./manifest.json";
import pkg from '../../package.json' import pkg from "../../package.json";
const updatedFirefoxManifest = { const updatedFirefoxManifest = {
...baseManifest, ...baseManifest,
@@ -10,13 +10,13 @@ const updatedFirefoxManifest = {
scripts: [baseManifest.background.service_worker], scripts: [baseManifest.background.service_worker],
}, },
action: { action: {
"default_popup": "interface/index.html#settings", default_popup: "interface/index.html#settings",
}, },
browser_specific_settings: { browser_specific_settings: {
gecko: { gecko: {
id: pkg.author.email, id: pkg.author.email,
}, },
} },
} };
export const firefox = createManifest(updatedFirefoxManifest, 'firefox') export const firefox = createManifest(updatedFirefoxManifest, "firefox");
+1 -1
View File
@@ -32,7 +32,7 @@
], ],
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": ["*/*", "resources/*", "seqta/utils/migration/migrate.html", "plugins/built-in/globalSearch/*"], "resources": ["resources/icons/*"],
"matches": ["*://*/*"] "matches": ["*://*/*"]
} }
] ]
+8 -5
View File
@@ -1,9 +1,12 @@
import { createManifest } from '../../lib/createManifest' import { createManifest } from "../../lib/createManifest";
import baseManifest from './manifest.json' import baseManifest from "./manifest.json";
import pkg from '../../package.json' import pkg from "../../package.json";
export const opera = createManifest({ export const opera = createManifest(
{
...baseManifest, ...baseManifest,
version: pkg.version, version: pkg.version,
description: pkg.description, description: pkg.description,
}, 'opera') },
"opera",
);
+7 -7
View File
@@ -1,6 +1,6 @@
import { createManifest } from '../../lib/createManifest' import { createManifest } from "../../lib/createManifest";
import baseManifest from './manifest.json' import baseManifest from "./manifest.json";
import pkg from '../../package.json' import pkg from "../../package.json";
const updatedSafariManifest = { const updatedSafariManifest = {
...baseManifest, ...baseManifest,
@@ -8,12 +8,12 @@ const updatedSafariManifest = {
description: pkg.description, description: pkg.description,
browser_specific_settings: { browser_specific_settings: {
safari: { safari: {
strict_min_version: '15.4', strict_min_version: "15.4",
strict_max_version: '*', strict_max_version: "*",
}, },
// ^^^ https://developer.apple.com/documentation/safariservices/safari_web_extensions/optimizing_your_web_extension_for_safari#3743239 // ^^^ https://developer.apple.com/documentation/safariservices/safari_web_extensions/optimizing_your_web_extension_for_safari#3743239
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings#safari_properties // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings#safari_properties
}, },
} };
export const safari = createManifest(updatedSafariManifest, 'safari') export const safari = createManifest(updatedSafariManifest, "safari");
+57 -35
View File
@@ -3,8 +3,8 @@ class ReactFiber {
this.selector = selector; this.selector = selector;
this.debug = options.debug || false; this.debug = options.debug || false;
this.nodes = [...document.querySelectorAll(selector)]; // Support multiple elements this.nodes = [...document.querySelectorAll(selector)]; // Support multiple elements
this.fibers = this.nodes.map(node => this.getFiberNode(node)); this.fibers = this.nodes.map((node) => this.getFiberNode(node));
this.components = this.fibers.map(fiber => this.getOwnerComponent(fiber)); this.components = this.fibers.map((fiber) => this.getOwnerComponent(fiber));
if (this.debug) { if (this.debug) {
console.log("Selected Nodes:", this.nodes); console.log("Selected Nodes:", this.nodes);
@@ -19,8 +19,10 @@ class ReactFiber {
getFiberNode(node) { getFiberNode(node) {
if (!node) return null; if (!node) return null;
const fiberKey = Object.getOwnPropertyNames(node).find(name => const fiberKey = Object.getOwnPropertyNames(node).find(
name.startsWith('__reactFiber') || name.startsWith('__reactInternalInstance') (name) =>
name.startsWith("__reactFiber") ||
name.startsWith("__reactInternalInstance"),
); );
return fiberKey ? node[fiberKey] : null; return fiberKey ? node[fiberKey] : null;
} }
@@ -28,7 +30,10 @@ class ReactFiber {
getOwnerComponent(fiberNode) { getOwnerComponent(fiberNode) {
let current = fiberNode; let current = fiberNode;
while (current) { while (current) {
if (current.stateNode && (current.stateNode.setState || current.stateNode.forceUpdate)) { if (
current.stateNode &&
(current.stateNode.setState || current.stateNode.forceUpdate)
) {
return current.stateNode; return current.stateNode;
} }
current = current.return; current = current.return;
@@ -42,7 +47,7 @@ class ReactFiber {
if (key === undefined) { if (key === undefined) {
return state; return state;
} else if (typeof key === 'string') { } else if (typeof key === "string") {
return state?.[key]; return state?.[key];
} else if (Array.isArray(key)) { } else if (Array.isArray(key)) {
const filteredState = {}; const filteredState = {};
@@ -57,23 +62,25 @@ class ReactFiber {
} }
setState(update) { setState(update) {
this.components.forEach(component => { this.components.forEach((component) => {
if (component?.setState) { if (component?.setState) {
if (typeof update === 'function') { if (typeof update === "function") {
// Functional update // Functional update
component.setState(prevState => { component.setState((prevState) => {
const newState = update(prevState); const newState = update(prevState);
if (this.debug) console.log("✅ Updated State (Functional):", newState); if (this.debug)
console.log("✅ Updated State (Functional):", newState);
return newState; return newState;
}); });
} else { } else {
// Object update (merge with existing state) // Object update (merge with existing state)
component.setState(prevState => { component.setState((prevState) => {
const newState = { const newState = {
...prevState, ...prevState,
...update ...update,
}; };
if (this.debug) console.log("✅ Updated State (Object Merge):", newState); if (this.debug)
console.log("✅ Updated State (Object Merge):", newState);
return newState; return newState;
}); });
} }
@@ -93,7 +100,7 @@ class ReactFiber {
} }
setProp(propName) { setProp(propName) {
this.fibers.forEach(fiber => { this.fibers.forEach((fiber) => {
if (fiber?.memoizedProps) { if (fiber?.memoizedProps) {
fiber.memoizedProps[propName] = value; fiber.memoizedProps[propName] = value;
} }
@@ -102,7 +109,7 @@ class ReactFiber {
} }
forceUpdate() { forceUpdate() {
this.components.forEach(component => { this.components.forEach((component) => {
if (component?.forceUpdate) { if (component?.forceUpdate) {
component.forceUpdate(); component.forceUpdate();
if (this.debug) console.log("🔄 Forced React Re-render"); if (this.debug) console.log("🔄 Forced React Re-render");
@@ -113,12 +120,12 @@ class ReactFiber {
} }
function makeSerializable(obj) { function makeSerializable(obj) {
if (typeof obj !== 'object' || obj === null) { if (typeof obj !== "object" || obj === null) {
return obj; return obj;
} }
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
return obj.map(item => makeSerializable(item)); return obj.map((item) => makeSerializable(item));
} }
const serializableObj = {}; const serializableObj = {};
@@ -126,17 +133,17 @@ function makeSerializable(obj) {
if (Object.hasOwn(obj, key)) { if (Object.hasOwn(obj, key)) {
let value = obj[key]; let value = obj[key];
if (typeof value === 'function') { if (typeof value === "function") {
value = '[Function]'; value = "[Function]";
} else if (value instanceof HTMLElement) { } else if (value instanceof HTMLElement) {
value = { value = {
type: 'HTMLElement', type: "HTMLElement",
id: value.id, id: value.id,
tagName: value.tagName tagName: value.tagName,
}; // Replace DOM node with ID/tag info }; // Replace DOM node with ID/tag info
} else if (typeof value === 'symbol') { } else if (typeof value === "symbol") {
value = value.toString(); value = value.toString();
} else if (typeof value === 'object' && value !== null) { } else if (typeof value === "object" && value !== null) {
value = makeSerializable(value); value = makeSerializable(value);
} }
@@ -146,17 +153,11 @@ function makeSerializable(obj) {
return serializableObj; return serializableObj;
} }
window.addEventListener('message', (event) => { window.addEventListener("message", (event) => {
if (event.data.type === "reactFiberRequest") { if (event.data.type === "reactFiberRequest") {
const { const { selector, action, payload, debug, messageId } = event.data;
selector,
action,
payload,
debug,
messageId
} = event.data;
const fiberInstance = ReactFiber.find(selector, { const fiberInstance = ReactFiber.find(selector, {
debug debug,
}); });
let response; let response;
@@ -191,14 +192,35 @@ window.addEventListener('message', (event) => {
response = null; response = null;
} }
if (response !== null && typeof response === 'object') { if (response !== null && typeof response === "object") {
response = makeSerializable(response); response = makeSerializable(response);
} }
window.postMessage({ window.postMessage(
{
type: "reactFiberResponse", type: "reactFiberResponse",
response, response,
messageId, messageId,
}, "*"); },
"*",
);
} else if (event.data.type === "triggerKeyboardEvent") {
// Handle keyboard event triggering from content script
const { key, code, altKey, ctrlKey, metaKey, shiftKey, keyCode } = event.data;
const keyboardEvent = new KeyboardEvent('keydown', {
key,
code,
keyCode: keyCode || 0,
which: keyCode || 0,
altKey: altKey || false,
ctrlKey: ctrlKey || false,
metaKey: metaKey || false,
shiftKey: shiftKey || false,
bubbles: true,
cancelable: true
});
document.dispatchEvent(keyboardEvent);
} }
}); });
@@ -1,7 +1,11 @@
import { BasePlugin } from '../../core/settings'; import { BasePlugin } from "../../core/settings";
import { type Plugin } from '@/plugins/core/types'; import { type Plugin } from "@/plugins/core/types";
import { defineSettings, numberSetting, Setting } from '@/plugins/core/settingsHelpers'; import {
import styles from './styles.css?inline'; defineSettings,
numberSetting,
Setting,
} from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline";
const settings = defineSettings({ const settings = defineSettings({
speed: numberSetting({ speed: numberSetting({
@@ -10,8 +14,8 @@ const settings = defineSettings({
description: "Controls how fast the background moves", description: "Controls how fast the background moves",
min: 0.1, min: 0.1,
max: 2, max: 2,
step: 0.05 step: 0.05,
}) }),
}); });
class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> { class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
@@ -22,10 +26,10 @@ class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
const instance = new AnimatedBackgroundPluginClass(); const instance = new AnimatedBackgroundPluginClass();
const animatedBackgroundPlugin: Plugin<typeof settings> = { const animatedBackgroundPlugin: Plugin<typeof settings> = {
id: 'animated-background', id: "animated-background",
name: 'Animated Background', name: "Animated Background",
description: 'Adds an animated background to BetterSEQTA+', description: "Adds an animated background to BetterSEQTA+",
version: '1.0.0', version: "1.0.0",
disableToggle: true, disableToggle: true,
styles: styles, styles: styles,
settings: instance.settings, settings: instance.settings,
@@ -42,12 +46,12 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
const backgrounds = [ const backgrounds = [
{ classes: ["bg"] }, { classes: ["bg"] },
{ classes: ["bg", "bg2"] }, { classes: ["bg", "bg2"] },
{ classes: ["bg", "bg3"] } { classes: ["bg", "bg3"] },
]; ];
backgrounds.forEach(({ classes }) => { backgrounds.forEach(({ classes }) => {
const bk = document.createElement("div"); const bk = document.createElement("div");
classes.forEach(cls => bk.classList.add(cls)); classes.forEach((cls) => bk.classList.add(cls));
container.insertBefore(bk, menu); container.insertBefore(bk, menu);
}); });
@@ -55,20 +59,23 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
updateAnimationSpeed(api.settings.speed); updateAnimationSpeed(api.settings.speed);
// Listen for speed changes // Listen for speed changes
const speedUnregister = api.settings.onChange('speed', updateAnimationSpeed); const speedUnregister = api.settings.onChange(
"speed",
updateAnimationSpeed,
);
// Return cleanup function // Return cleanup function
return () => { return () => {
speedUnregister.unregister(); speedUnregister.unregister();
// Remove background elements // Remove background elements
const backgrounds = document.getElementsByClassName('bg'); const backgrounds = document.getElementsByClassName("bg");
Array.from(backgrounds).forEach(element => element.remove()); Array.from(backgrounds).forEach((element) => element.remove());
}; };
} },
}; };
function updateAnimationSpeed(speed: number) { function updateAnimationSpeed(speed: number) {
const bgElements = document.getElementsByClassName('bg'); const bgElements = document.getElementsByClassName("bg");
Array.from(bgElements).forEach((element, index) => { Array.from(bgElements).forEach((element, index) => {
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5; const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`; (element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`;
@@ -13,12 +13,12 @@ export function CreateBackground() {
const backgrounds = [ const backgrounds = [
{ classes: ["bg"] }, { classes: ["bg"] },
{ classes: ["bg", "bg2"] }, { classes: ["bg", "bg2"] },
{ classes: ["bg", "bg3"] } { classes: ["bg", "bg3"] },
]; ];
backgrounds.forEach(({ classes }) => { backgrounds.forEach(({ classes }) => {
const bk = document.createElement("div"); const bk = document.createElement("div");
classes.forEach(cls => bk.classList.add(cls)); classes.forEach((cls) => bk.classList.add(cls));
container.insertBefore(bk, menu); container.insertBefore(bk, menu);
}); });
} }
@@ -2,5 +2,5 @@ export function RemoveBackground() {
const backgrounds = document.getElementsByClassName("bg"); const backgrounds = document.getElementsByClassName("bg");
// Convert HTMLCollection to Array and remove each element // Convert HTMLCollection to Array and remove each element
Array.from(backgrounds).forEach(element => element.remove()); Array.from(backgrounds).forEach((element) => element.remove());
} }
@@ -1,5 +1,9 @@
import { BasePlugin } from "@/plugins/core/settings"; import { BasePlugin } from "@/plugins/core/settings";
import { booleanSetting, defineSettings, Setting } from "@/plugins/core/settingsHelpers"; import {
booleanSetting,
defineSettings,
Setting,
} from "@/plugins/core/settingsHelpers";
import { type Plugin } from "@/plugins/core/types"; import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML"; import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
@@ -8,7 +12,7 @@ const settings = defineSettings({
lettergrade: booleanSetting({ lettergrade: booleanSetting({
default: false, default: false,
title: "Letter Grades", title: "Letter Grades",
description: "Display the average as a letter instead of a percentage" description: "Display the average as a letter instead of a percentage",
}), }),
}); });
@@ -34,62 +38,105 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']", "#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
true, true,
10, 10,
1000 1000,
); );
// Helper function to find actual class names by their base pattern // Helper function to find actual class names by their base pattern
const getClassByPattern = (element: Element | Document, basePattern: string): string => { const getClassByPattern = (
element: Element | Document,
basePattern: string,
): string => {
// Find all classes on the element // Find all classes on the element
const classes = Array.from(element.querySelectorAll('*')) const classes = Array.from(element.querySelectorAll("*"))
.flatMap(el => Array.from(el.classList)) .flatMap((el) => Array.from(el.classList))
.filter(className => className.startsWith(basePattern)); .filter((className) => className.startsWith(basePattern));
return classes.length ? classes[0] : ''; return classes.length ? classes[0] : "";
}; };
// Find actual class names from the DOM // Find actual class names from the DOM
const sampleAssessmentItem = document.querySelector("[class*='AssessmentItem__AssessmentItem___']"); const sampleAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___']",
);
if (!sampleAssessmentItem) return; if (!sampleAssessmentItem) return;
// Extract all necessary class patterns from a sample assessment item // Extract all necessary class patterns from a sample assessment item
const assessmentItemClass = Array.from(sampleAssessmentItem.classList) const assessmentItemClass =
.find(c => c.startsWith('AssessmentItem__AssessmentItem___')) || ''; Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"),
) || "";
const metaContainerClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__metaContainer___'); const metaContainerClass = getClassByPattern(
const metaClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__meta___'); sampleAssessmentItem,
const simpleResultClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__simpleResult___'); "AssessmentItem__metaContainer___",
const titleClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__title___'); );
const metaClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__meta___",
);
const simpleResultClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__simpleResult___",
);
const titleClass = getClassByPattern(
sampleAssessmentItem,
"AssessmentItem__title___",
);
// Get Thermoscore classes // Get Thermoscore classes
const thermoscoreElement = document.querySelector("[class*='Thermoscore__Thermoscore___']"); const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']",
);
if (!thermoscoreElement) return; if (!thermoscoreElement) return;
const thermoscoreClass = Array.from(thermoscoreElement.classList) const thermoscoreClass =
.find(c => c.startsWith('Thermoscore__Thermoscore___')) || ''; Array.from(thermoscoreElement.classList).find((c) =>
const fillClass = getClassByPattern(thermoscoreElement, 'Thermoscore__fill___'); c.startsWith("Thermoscore__Thermoscore___"),
const textClass = getClassByPattern(thermoscoreElement, 'Thermoscore__text___'); ) || "";
const fillClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__fill___",
);
const textClass = getClassByPattern(
thermoscoreElement,
"Thermoscore__text___",
);
// Find assessment list // Find assessment list
const assessmentsList = document.querySelector("#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']"); const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
if (!assessmentsList) return; if (!assessmentsList) return;
const gradeElements = document.querySelectorAll("[class*='Thermoscore__text___']"); const gradeElements = document.querySelectorAll(
"[class*='Thermoscore__text___']",
);
if (!gradeElements.length) return; if (!gradeElements.length) return;
// Parse and average grades // Parse and average grades
const letterToNumber: Record<string, number> = { const letterToNumber: Record<string, number> = {
"A+": 100, A: 95, "A-": 90, "A+": 100,
"B+": 85, B: 80, "B-": 75, A: 95,
"C+": 70, C: 65, "C-": 60, "A-": 90,
"D+": 55, D: 50, "D-": 45, "B+": 85,
"E+": 40, E: 35, "E-": 30, B: 80,
"B-": 75,
"C+": 70,
C: 65,
"C-": 60,
"D+": 55,
D: 50,
"D-": 45,
"E+": 40,
E: 35,
"E-": 30,
F: 0, F: 0,
}; };
function parseGrade(text: string): number { function parseGrade(text: string): number {
const str = text.trim().toUpperCase(); const str = text.trim().toUpperCase();
if (str.includes("/")) { if (str.includes("/")) {
const [raw, max] = str.split("/").map(n => parseFloat(n)); const [raw, max] = str.split("/").map((n) => parseFloat(n));
return (raw / max) * 100; return (raw / max) * 100;
} }
if (str.includes("%")) { if (str.includes("%")) {
@@ -112,16 +159,23 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
const avg = total / count; const avg = total / count;
const rounded = Math.ceil(avg / 5) * 5; const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce((acc, [k, v]) => { const numberToLetter = Object.entries(letterToNumber).reduce(
(acc, [k, v]) => {
acc[v] = k; acc[v] = k;
return acc; return acc;
}, {} as Record<number, string>); },
{} as Record<number, string>,
);
const letterAvg = numberToLetter[rounded] ?? "N/A"; const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`; const display = api.settings.lettergrade
? letterAvg
: `${avg.toFixed(2)}%`;
// Prevent duplicate // Prevent duplicate
const existing = assessmentsList.querySelector(`[class*='AssessmentItem__title___']`); const existing = assessmentsList.querySelector(
`[class*='AssessmentItem__title___']`,
);
if (existing?.textContent === "Subject Average") return; if (existing?.textContent === "Subject Average") return;
// Use the dynamic class names in the HTML template // Use the dynamic class names in the HTML template
@@ -144,7 +198,7 @@ const assessmentsAveragePlugin: Plugin<typeof settings> = {
assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild); assessmentsList.insertBefore(averageElement!, assessmentsList.firstChild);
}); });
} },
}; };
export default assessmentsAveragePlugin; export default assessmentsAveragePlugin;
@@ -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"> <script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte'; import { createEventDispatcher, onDestroy } from 'svelte';
import { unitFullNames } from './unitMap'; import { calculateExpression } from '../utils/calculator';
import * as math from 'mathjs';
let { searchTerm = '', isSelected = false } = $props<{ searchTerm: string, isSelected: boolean }>(); let { searchTerm = '', isSelected = false } = $props<{ searchTerm: string, isSelected: boolean }>();
@@ -13,73 +12,32 @@
let isCalculating = $state(false); let isCalculating = $state(false);
let inputUnit = $state<string>(''); let inputUnit = $state<string>('');
let outputUnit = $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) => { 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; isCalculating = true;
// Let mathjs handle everything try {
const evaluated = math.evaluate(input.replace('**', '^')); const calcResult = calculateExpression(input);
// Format the result if (calcResult.isValid) {
if (evaluated !== undefined) { result = calcResult.result;
if (math.typeOf(evaluated) === 'Unit') { inputUnit = calcResult.inputUnit;
// Handle unit conversion results outputUnit = calcResult.outputUnit;
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 }); isPartial = calcResult.isPartial;
inputUnit = detectUnit(input); dispatch('hasResult', calcResult.result);
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);
} else { } else {
result = null; result = null;
inputUnit = ''; inputUnit = '';
outputUnit = ''; outputUnit = '';
isPartial = false;
dispatch('hasResult', null); dispatch('hasResult', null);
} }
} catch (e) { } catch (e) {
// If mathjs throws an error, this isn't a valid expression
result = null; result = null;
inputUnit = ''; inputUnit = '';
outputUnit = ''; outputUnit = '';
isPartial = false;
dispatch('hasResult', null); dispatch('hasResult', null);
} finally { } finally {
isCalculating = false; isCalculating = false;
@@ -96,7 +54,7 @@
</script> </script>
{#if result !== null} {#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> <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 items-center justify-between gap-8 rounded-lg border border-transparent {isSelected ? 'bg-zinc-900/5 dark:bg-white/10 border-zinc-900/5 dark:border-zinc-100/5' : ''}">
<div class="flex flex-col flex-1 items-center py-4 pl-4 min-w-0"> <div class="flex flex-col flex-1 items-center py-4 pl-4 min-w-0">
@@ -124,7 +82,7 @@
{result} {result}
</div> </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"> <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>
</div> </div>
{:else} {:else}
@@ -6,31 +6,109 @@
import { type StaticCommandItem } from '../core/commands'; import { type StaticCommandItem } from '../core/commands';
import type { CombinedResult } from '../core/types'; import type { CombinedResult } from '../core/types';
import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils'; import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils';
import { highlightMatch, highlightSnippet, stripHtmlButKeepHighlights } from '../utils/highlight';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import Calculator from './Calculator.svelte'; import Calculator from './Calculator.svelte';
import { actionMap } from '../indexing/actions'; import { actionMap } from '../indexing/actions';
import type { IndexItem, HydratedIndexItem } from '../indexing/types'; import type { IndexItem } from '../indexing/types';
import debounce from 'lodash/debounce'; 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 { const {
transparencyEffects, transparencyEffects,
showRecentFirst searchHotkey: initialSearchHotkey
} = $props<{ } = $props<{
transparencyEffects: boolean, transparencyEffects: boolean,
showRecentFirst: boolean searchHotkey: string
}>(); }>();
let commandsFuse = $state<Fuse<StaticCommandItem>>(); // Make searchHotkey reactive to setting changes
let dynamicContentFuse = $state<Fuse<HydratedIndexItem>>(); 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>()); const commandIdToItemMap = $state(new Map<string, StaticCommandItem>());
let isIndexing = $state(false); let isIndexing = $state(false);
let completedJobs = $state(0); let completedJobs = $state(0);
let totalJobs = $state(0); let totalJobs = $state(0);
let 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(() => { onMount(() => {
const progressHandler = (event: CustomEvent) => { const progressHandler = (event: CustomEvent) => {
const { completed, total, indexing } = event.detail; const { completed, total, indexing } = event.detail;
@@ -42,12 +120,18 @@
window.addEventListener('indexing-progress', progressHandler as EventListener); window.addEventListener('indexing-progress', progressHandler as EventListener);
const itemsUpdatedHandler = () => { const itemsUpdatedHandler = () => {
console.log('Search Bar received items-updated event, re-indexing...');
setupSearchIndexes(); setupSearchIndexes();
performSearch(); performSearch();
}; };
window.addEventListener('dynamic-items-updated', itemsUpdatedHandler); window.addEventListener('dynamic-items-updated', itemsUpdatedHandler);
setupSearchIndexes();
// @ts-ignore - Intentionally adding to window
window.setCommandPalleteOpen = (open: boolean) => {
commandPalleteOpen = open;
};
return () => { return () => {
window.removeEventListener('indexing-progress', progressHandler as EventListener); window.removeEventListener('indexing-progress', progressHandler as EventListener);
window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler); window.removeEventListener('dynamic-items-updated', itemsUpdatedHandler);
@@ -68,58 +152,23 @@
console.debug(`[Global Search] Indexed ${commands.length} command items and ${dynamicItems.length} dynamic items.`); 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 () => { const performSearch = async () => {
isLoading = true; isLoading = true;
selectedIndex = 0; selectedIndex = 0;
tick().then(() => {
const selectedElement = resultsList?.querySelector(`li:nth-child(1)`);
selectedElement?.scrollIntoView({ block: 'nearest' });
});
const term = searchTerm.trim().toLowerCase(); const term = searchTerm.trim().toLowerCase();
if (commandsFuse && dynamicContentFuse) { if (commandsFuse && dynamicContentFuse) {
combinedResults = await doSearch( combinedResults = await doSearch(
term, term,
commandsFuse, commandsFuse,
dynamicContentFuse,
commandIdToItemMap, commandIdToItemMap,
dynamicIdToItemMap,
showRecentFirst
); );
} else { } else {
combinedResults = []; combinedResults = [];
@@ -141,7 +190,6 @@
} else { } else {
searchTerm = ''; searchTerm = '';
selectedIndex = 0; selectedIndex = 0;
prevSearchTerm = '';
combinedResults = []; combinedResults = [];
} }
}); });
@@ -156,16 +204,24 @@
const maxIndex = (calculatorResult ? 1 : 0) + combinedResults.length - 1; const maxIndex = (calculatorResult ? 1 : 0) + combinedResults.length - 1;
if (selectedIndex < maxIndex) { if (selectedIndex < maxIndex) {
selectedIndex++; selectedIndex++;
tick().then(() => {
const selectedElement = resultsList?.querySelector(`li:nth-child(${selectedIndex + 1})`);
selectedElement?.scrollIntoView({ block: 'nearest' });
});
} }
}; };
const selectPrev = () => { const selectPrev = () => {
if (selectedIndex > 0) { if (selectedIndex > 0) {
selectedIndex--; 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') { if ('action' in item && typeof item.action === 'function') {
(item as StaticCommandItem).action(); (item as StaticCommandItem).action();
} else if ('actionId' in item && item.actionId && actionMap[item.actionId]) { } else if ('actionId' in item && item.actionId && actionMap[item.actionId]) {
@@ -188,6 +244,7 @@
}; };
const handleKeyNav = (e: KeyboardEvent) => { const handleKeyNav = (e: KeyboardEvent) => {
// Handle regular navigation
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
selectNext(); selectNext();
@@ -210,18 +267,18 @@
transition:fade={{ duration: 150, easing: quintOut }} transition:fade={{ duration: 150, easing: quintOut }}
></div> ></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} onclick={() => commandPalleteOpen = false}
onkeydown={(e) => e.key === 'Escape' && (commandPalleteOpen = false)} onkeydown={(e: KeyboardEvent) => e.key === 'Escape' && (commandPalleteOpen = false)}
role="button" role="button"
tabindex="0"> tabindex="0">
<div <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 }} transition:scale={{ duration: 100, start: 0.95, opacity: 0, easing: circOut }}
onclick={(e) => { onclick={(e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
}} }}
onkeydown={(e) => { onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
commandPalleteOpen = false; commandPalleteOpen = false;
} }
@@ -242,7 +299,10 @@
/> />
</div> </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 <Calculator
searchTerm={searchTerm} searchTerm={searchTerm}
isSelected={selectedIndex === 0} isSelected={selectedIndex === 0}
@@ -257,40 +317,39 @@
{#if result.type === 'command'} {#if result.type === 'command'}
{@const staticItem = item as StaticCommandItem} {@const staticItem = item as StaticCommandItem}
<button <button
class="w-full flex items-center px-2 py-1.5 rounded-lg select-none cursor-pointer group 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' : '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] dark:shadow' : 'hover:bg-zinc-500/5 dark:hover:bg-white/5 text-zinc-800 dark:text-zinc-200'}"
onclick={() => executeItemAction(staticItem)} 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"> <span class="ml-4 text-lg truncate">
{@html highlightMatch(staticItem.text, searchTerm, result.matches)} <HighlightedText text={staticItem.text} term={searchTerm} matches={result.matches} />
</span> </span>
{#if staticItem.keybindLabel}
<div class="flex-none ml-auto">
{@render Shortcut({ text: '', keybind: staticItem.keybindLabel })}
</div>
{/if}
</button> </button>
{:else if result.type === 'dynamic'} {:else if result.type === 'dynamic'}
{@const dynamicItem = item as HydratedIndexItem} {@const dynamicItem = item as IndexItem}
{#if dynamicItem.renderComponent} {@const RenderComponent = renderComponentMap[dynamicItem.renderComponentId]}
<dynamicItem.renderComponent {#if RenderComponent}
<RenderComponent
item={dynamicItem} item={dynamicItem}
isSelected={isSelected} isSelected={isSelected}
searchTerm={searchTerm} searchTerm={searchTerm}
matches={result.matches} matches={result.matches}
on:click={() => executeItemAction(dynamicItem)} onclick={() => executeItemAction(dynamicItem)}
onkeydown={() => executeItemAction(dynamicItem)}
role="button"
tabindex="0"
/> />
{:else} {:else}
<button <button
class="w-full flex flex-col px-2 py-1.5 rounded-lg select-none cursor-pointer group 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)} onclick={() => executeItemAction(dynamicItem)}
> >
<div class="flex items-center w-full"> <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> <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"> <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>
<span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400"> <span class="flex-none ml-auto text-xs text-zinc-500 dark:text-zinc-400">
{dynamicItem.category} {dynamicItem.category}
@@ -298,7 +357,7 @@
</div> </div>
{#if dynamicItem.content} {#if dynamicItem.content}
<div class="mt-1 ml-12 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2 text-start"> <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> </div>
{/if} {/if}
</button> </button>
@@ -322,18 +381,7 @@
</ul> </ul>
<div class="px-3 py-2 w-full border-t border-zinc-900/5 dark:border-zinc-100/5 bg-white/5"> <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} {#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 justify-between items-center h-7 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 gap-4 items-center"> <div class="flex gap-4 items-center">
{@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})} {@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})}
{#if calculatorResult && selectedIndex === 0} {#if calculatorResult && selectedIndex === 0}
@@ -356,7 +404,6 @@
</div> </div>
{/if} {/if}
</div> </div>
</div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -368,23 +415,9 @@
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<div class="flex gap-1 items-center"> <div class="flex gap-1 items-center">
{#each keybind as key} {#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} {/each}
</div> </div>
<span>{text}</span> <span>{text}</span>
</div> </div>
{/snippet} {/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 { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { waitForElm } from "@/seqta/utils/waitForElm";
export interface BaseCommandItem { export interface BaseCommandItem {
id: string; id: string;
@@ -16,14 +17,127 @@ export interface StaticCommandItem extends BaseCommandItem {
keybindLabel?: string[]; keybindLabel?: string[];
} }
// Function to get current lesson
async function getCurrentLesson() {
const date = new Date();
const todayFormatted = formatDate(date);
try {
const response = await fetch(`${location.origin}/seqta/student/load/timetable?`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from: todayFormatted,
until: todayFormatted,
student: 69,
}),
});
const timetableData = await response.json();
if (!timetableData.payload.items.length) {
alert("No lessons today!");
return null;
}
const lessons = timetableData.payload.items.sort((a: any, b: any) =>
a.from.localeCompare(b.from)
);
const currentTime = new Date();
for (const lesson of lessons) {
const [startHour, startMinute] = lesson.from.split(":").map(Number);
const [endHour, endMinute] = lesson.until.split(":").map(Number);
const startDate = new Date(currentTime);
startDate.setHours(startHour, startMinute, 0);
const endDate = new Date(currentTime);
endDate.setHours(endHour, endMinute, 0);
if (startDate <= currentTime && endDate > currentTime) {
return lesson;
}
}
alert("There is no current lesson!");
return null;
} catch (error) {
console.error("Error fetching current lesson:", error);
alert("Error getting current lesson. Please try again.");
return null;
}
}
async function navigateToSpecificLesson(lesson: any) {
try {
await waitForElm(".course .navigator", true, 100, 100);
const today = new Date();
const todayDateString = today.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short'
});
const weeks = document.querySelectorAll(".course .navigator .week");
for (const week of weeks) {
// Look for lessons in this week
const lessons = week.querySelectorAll(".lesson");
for (const lessonElement of lessons) {
const metaElement = lessonElement.querySelector(".meta");
if (!metaElement) continue;
const dateElement = metaElement.querySelector(".date");
const periodElement = metaElement.querySelector(".period");
if (!dateElement || !periodElement) continue;
const lessonDate = dateElement.textContent?.trim();
const lessonPeriod = periodElement.textContent?.trim().match(/\d+/)?.[0];
// extract the number from the period
const normalizedLessonPeriod = lesson.period?.match(/\d+/)?.[0];
// Check if this lesson matches today's date and the current lesson's period
if (lessonDate === todayDateString && lessonPeriod === normalizedLessonPeriod) {
// Found the exact matching lesson, click it
(lessonElement as HTMLElement).click();
console.log(`Navigated to exact lesson: ${lessonDate} ${lessonPeriod}`);
return true;
}
}
}
const todayButton = Array.from(document.querySelectorAll('#toolbar .uiButton'))
.find(button => button.textContent?.trim() === 'Today') as HTMLElement;
if (todayButton) {
todayButton.click();
}
return true;
} catch (error) {
console.error("Error navigating to specific lesson:", error);
return false;
}
}
function formatDate(date: Date): string {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
const staticCommands: StaticCommandItem[] = [ const staticCommands: StaticCommandItem[] = [
{ {
id: "home", id: "home",
icon: "\ueb4c", icon: "\ueb4c",
category: "navigation", category: "navigation",
text: "Home", text: "Home",
keybind: ["alt+h"],
keybindLabel: ["Alt", "H"],
action: () => { action: () => {
window.location.hash = "?page=/home"; window.location.hash = "?page=/home";
loadHomePage(); loadHomePage();
@@ -35,8 +149,6 @@ const staticCommands: StaticCommandItem[] = [
icon: "\uebfd", icon: "\uebfd",
category: "navigation", category: "navigation",
text: "Direct Messages", text: "Direct Messages",
keybind: ["alt+m"],
keybindLabel: ["Alt", "M"],
action: () => { action: () => {
window.location.hash = "?page=/messages"; window.location.hash = "?page=/messages";
}, },
@@ -47,13 +159,27 @@ const staticCommands: StaticCommandItem[] = [
icon: "\ue9cd", icon: "\ue9cd",
category: "navigation", category: "navigation",
text: "Timetable", text: "Timetable",
keybind: ["alt+t"],
keybindLabel: ["Alt", "T"],
action: () => { action: () => {
window.location.hash = "?page=/timetable"; window.location.hash = "?page=/timetable";
}, },
priority: 4, 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", id: "assessments",
icon: "\ueac3", icon: "\ueac3",
@@ -62,17 +188,44 @@ const staticCommands: StaticCommandItem[] = [
keybind: ["alt+a"], keybind: ["alt+a"],
keybindLabel: ["Alt", "A"], keybindLabel: ["Alt", "A"],
action: () => { action: () => {
window.location.hash = "?page=/assessments"; window.location.hash = "?page=/assessments/upcoming";
}, },
priority: 4, 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", id: "toggle-dark-mode",
icon: "\uecfe", icon: "\uecfe",
category: "action", category: "action",
text: "Toggle Dark Mode", text: "Toggle Dark Mode",
action: () => (settingsState.DarkMode = !settingsState.DarkMode), action: () => (settingsState.DarkMode = !settingsState.DarkMode),
priority: 2, priority: 3,
keywords: ["theme", "appearance"], keywords: ["theme", "appearance"],
}, },
]; ];
@@ -2,21 +2,30 @@ import type { Plugin } from "@/plugins/core/types";
import { BasePlugin } from "@/plugins/core/settings"; import { BasePlugin } from "@/plugins/core/settings";
import { import {
booleanSetting, booleanSetting,
buttonSetting,
defineSettings, defineSettings,
Setting, Setting,
stringSetting, hotkeySetting,
} from "@/plugins/core/settingsHelpers"; } from "@/plugins/core/settingsHelpers";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import { runIndexing } from "../indexing/indexer"; import { runIndexing } from "../indexing/indexer";
import { initVectorSearch } from "../search/vector/vectorSearch"; import { initVectorSearch } from "../search/vector/vectorSearch";
import { cleanupSearchBar, mountSearchBar } from "./mountSearchBar"; 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({ const settings = defineSettings({
searchHotkey: stringSetting({ searchHotkey: hotkeySetting({
default: "ctrl+k", default: getDefaultHotkey(),
title: "Search Hotkey", title: "Search Hotkey",
description: "Keyboard shortcut to open the search (cmd on Mac)", description: "Keyboard shortcut to open the search",
}), }),
showRecentFirst: booleanSetting({ showRecentFirst: booleanSetting({
default: true, default: true,
@@ -33,6 +42,43 @@ const settings = defineSettings({
title: "Index on Page Load", title: "Index on Page Load",
description: "Run content indexing when SEQTA loads", 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> { class GlobalSearchPlugin extends BasePlugin<typeof settings> {
@@ -47,6 +93,9 @@ class GlobalSearchPlugin extends BasePlugin<typeof settings> {
@Setting(settings.runIndexingOnLoad) @Setting(settings.runIndexingOnLoad)
runIndexingOnLoad!: boolean; runIndexingOnLoad!: boolean;
@Setting(settings.resetIndex)
resetIndex!: () => void;
} }
const settingsInstance = new GlobalSearchPlugin(); const settingsInstance = new GlobalSearchPlugin();
@@ -58,13 +107,59 @@ const globalSearchPlugin: Plugin<typeof settings> = {
version: "1.0.0", version: "1.0.0",
settings: settingsInstance.settings, settings: settingsInstance.settings,
disableToggle: true, disableToggle: true,
defaultEnabled: false,
beta: true,
styles: styles, styles: styles,
run: async (api) => { run: async (api) => {
const appRef = { current: null }; 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(); 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) { if (api.settings.runIndexingOnLoad) {
setTimeout(async () => { setTimeout(async () => {
await runIndexing(); await runIndexing();
@@ -76,8 +171,8 @@ const globalSearchPlugin: Plugin<typeof settings> = {
if (title) { if (title) {
mountSearchBar(title, api, appRef); mountSearchBar(title, api, appRef);
} else { } else {
await waitForElm("#title", true, 100, 60); const titleElement = await waitForElm("#title", true, 100, 60);
mountSearchBar(document.querySelector("#title") as Element, api, appRef); mountSearchBar(titleElement, api, appRef);
} }
return () => { return () => {
@@ -2,29 +2,53 @@ import renderSvelte from "@/interface/main";
import SearchBar from "../components/SearchBar.svelte"; import SearchBar from "../components/SearchBar.svelte";
import { unmount } from "svelte"; import { unmount } from "svelte";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager"; import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
import browser from "webextension-polyfill";
export function mountSearchBar( export function mountSearchBar(
titleElement: Element, titleElement: Element,
api: any, api: any,
appRef: { current: any } appRef: { current: any },
) { ) {
if (titleElement.querySelector(".search-trigger")) { if (titleElement.querySelector(".search-trigger")) {
return; 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"); const searchButton = document.createElement("div");
searchButton.className = "search-trigger"; searchButton.className = "search-trigger";
const updateSearchButtonDisplay = () => {
searchButton.innerHTML = /* html */ ` searchButton.innerHTML = /* html */ `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle> <circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg> </svg>
<p>Quick search...</p> <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); 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"); const searchRoot = document.createElement("div");
document.body.appendChild(searchRoot); document.body.appendChild(searchRoot);
const searchRootShadow = searchRoot.attachShadow({ mode: "open" }); const searchRootShadow = searchRoot.attachShadow({ mode: "open" });
@@ -38,6 +62,7 @@ export function mountSearchBar(
appRef.current = renderSvelte(SearchBar, searchRootShadow, { appRef.current = renderSvelte(SearchBar, searchRootShadow, {
transparencyEffects: api.settings.transparencyEffects ? true : false, transparencyEffects: api.settings.transparencyEffects ? true : false,
showRecentFirst: api.settings.showRecentFirst, showRecentFirst: api.settings.showRecentFirst,
searchHotkey: currentHotkey,
}); });
} catch (error) { } catch (error) {
console.error("Error rendering Svelte component:", error); console.error("Error rendering Svelte component:", error);
@@ -45,12 +70,30 @@ export function mountSearchBar(
} }
export function cleanupSearchBar(appRef: { current: any }) { export function cleanupSearchBar(appRef: { current: any }) {
const searchButton = document.querySelector(".search-trigger"); if (appRef.current) {
const searchRoot = document.querySelector(".global-search-root"); try {
if (searchButton) searchButton.remove();
if (searchRoot) searchRoot.remove();
// Clean up workers
VectorWorkerManager.getInstance().terminate();
unmount(appRef.current); 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; 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 { StaticCommandItem } from "./commands";
import type { HydratedIndexItem } from "../indexing/types"; import type { IndexItem } from "../indexing/types";
export interface MatchIndices { export interface MatchIndices {
readonly 0: number; readonly 0: number;
@@ -16,7 +16,7 @@ export interface CombinedResult {
id: string; id: string;
type: "command" | "dynamic"; type: "command" | "dynamic";
score: number; score: number;
item: StaticCommandItem | HydratedIndexItem; item: StaticCommandItem | IndexItem;
matches?: readonly FuseResultMatch[]; matches?: readonly FuseResultMatch[];
} }
@@ -1,4 +1,7 @@
import { waitForElm } from "@/seqta/utils/waitForElm";
import type { IndexItem } from "./types"; import type { IndexItem } from "./types";
import ReactFiber from "@/seqta/utils/ReactFiber";
import { delay } from "@/seqta/utils/delay";
interface MessageMetadata { interface MessageMetadata {
messageId: number; messageId: number;
@@ -26,15 +29,59 @@ interface AssessmentMetadata {
type ActionHandler<T = any> = (item: IndexItem & { metadata: T }) => void; type ActionHandler<T = any> = (item: IndexItem & { metadata: T }) => void;
export const actionMap: Record<string, ActionHandler<any>> = { export const actionMap: Record<string, ActionHandler<any>> = {
message: ((item: IndexItem & { metadata: MessageMetadata }) => { message: (async (item: IndexItem & { metadata: MessageMetadata }) => {
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]),
});
// 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>, }) as ActionHandler<any>,
assessment: ((item: IndexItem & { metadata: AssessmentMetadata }) => { assessment: (async (item: IndexItem & { metadata: AssessmentMetadata }) => {
if (item.metadata.isMessageBased) { 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 { } else {
window.location.hash = `#?page=/assessments&id=${item.metadata.assessmentId}`; window.location.hash = `#?page=/assessments&id=${item.metadata.assessmentId}`;
} }
}) as ActionHandler<any>, }) 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 { jobs } from "./jobs";
import { renderComponentMap } from "./renderComponents"; 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 { VectorWorkerManager } from "./worker/vectorWorkerManager";
import { loadDynamicItems } from "../utils/dynamicItems";
const META_STORE = "meta"; const META_STORE = "meta";
const LOCK_KEY = "bsq-indexer-lock"; const LOCK_KEY = "bsq-indexer-lock";
const HEARTBEAT_INTERVAL = 10000; const HEARTBEAT_INTERVAL = 10000;
const LOCK_TIMEOUT = 20000; 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 heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let isIndexingActive = false;
function shouldRun(job: Job, lastRun?: number): boolean { function shouldRun(job: Job, lastRun?: number): boolean {
const now = Date.now(); const now = Date.now();
@@ -39,67 +53,117 @@ async function updateLastRunMeta(jobId: string): Promise<void> {
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId); await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
} }
function shouldIndex(): boolean { async function acquireLock(): Promise<boolean> {
const last = parseInt(localStorage.getItem(LOCK_KEY) || "0", 10); if (isIndexingActive) {
return isNaN(last) || Date.now() - last > LOCK_TIMEOUT; 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() { function startHeartbeat() {
localStorage.setItem(LOCK_KEY, `${Date.now()}`); const lockId = localStorage.getItem(LOCK_KEY);
if (!lockId) return;
heartbeatTimer = setInterval(() => { 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); }, HEARTBEAT_INTERVAL);
} }
function stopHeartbeat() { function stopHeartbeat() {
if (heartbeatTimer) clearInterval(heartbeatTimer); if (heartbeatTimer) clearInterval(heartbeatTimer);
localStorage.removeItem(LOCK_KEY); 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", { const event = new CustomEvent("indexing-progress", {
detail: { completed, total, indexing, status, detail }, detail: { completed, total, indexing, status, detail },
}); });
window.dispatchEvent(event); window.dispatchEvent(event);
} }
export async function loadAllStoredItems(): Promise<HydratedIndexItem[]> { export async function loadAllStoredItems(): Promise<IndexItem[]> {
const all: HydratedIndexItem[] = []; const all: IndexItem[] = [];
const jobIds = Object.keys(jobs); const jobIds = Object.keys(jobs);
for (const jobId of jobIds) { for (const jobId of jobIds) {
try { try {
const items = await getAll(jobId) as IndexItem[]; const items = (await getAll(jobId)) as IndexItem[];
const job = jobs[jobId]; 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) { for (const item of items) {
// Ensure item has all required fields before pushing if (
if (item && item.id && item.text && item.category && item.actionId && job.renderComponentId) { item &&
all.push({ item.id &&
...item, item.text &&
renderComponent: renderComponent || undefined, // Assign undefined if not found item.category &&
}); item.actionId &&
job.renderComponentId // job might not be defined if store exists but job was removed
) {
all.push(item);
} else { } else {
console.warn(`Skipping invalid item from job ${jobId}:`, item); console.warn(`Skipping invalid item from job store ${jobId}:`, item);
} }
} }
} catch (error) { } 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; return all;
} }
export async function runIndexing(): Promise<void> { export async function runIndexing(): Promise<void> {
if (!shouldIndex()) { if (!(await acquireLock())) {
console.debug( 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", "color: gray",
); );
return; return;
@@ -110,15 +174,18 @@ export async function runIndexing(): Promise<void> {
const jobIds = Object.keys(jobs); const jobIds = Object.keys(jobs);
let completedJobs = 0; let completedJobs = 0;
// Add an extra step for vectorization
const totalSteps = jobIds.length + 1; const totalSteps = jobIds.length + 1;
dispatchProgress(completedJobs, totalSteps, true, "Starting jobs"); 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) { 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 job = jobs[jobId];
const lastRun = await getLastRunMeta(jobId); const lastRun = await getLastRunMeta(jobId);
@@ -128,30 +195,42 @@ export async function runIndexing(): Promise<void> {
"color: gray", "color: gray",
); );
completedJobs++; completedJobs++;
dispatchProgress(completedJobs, totalSteps, true, `Skipped job: ${job.label}`); dispatchProgress(
completedJobs,
totalSteps,
true,
`Skipped job: ${job.label}`,
);
continue; continue;
} }
// These DB operations happen on the main thread (acceptable per request) const getStoredItems = async (storeId?: string) =>
const getStoredItems = async () => await getAll(jobId); await getAll(storeId ?? jobId);
const setStoredItems = async (items: IndexItem[]) => { const setStoredItems = async (items: IndexItem[], storeId?: string) => {
await clear(jobId); const targetStore = storeId ?? jobId;
// Add validation before putting await clear(targetStore);
const validItems = items.filter(i => i && i.id); const validItems = items.filter((i) => i && i.id);
if (validItems.length !== items.length) { 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) => { const addItem = async (item: IndexItem, storeId?: string) => {
if (item && item.id) { // Add validation const targetStore = storeId ?? jobId;
await put(jobId, item, item.id); if (item && item.id) {
await put(targetStore, item, item.id);
} else { } 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) => { const removeItem = async (id: string, storeId?: string) => {
await remove(jobId, id); const targetStore = storeId ?? jobId;
await remove(targetStore, id);
}; };
const ctx: JobContext = { const ctx: JobContext = {
@@ -159,6 +238,8 @@ export async function runIndexing(): Promise<void> {
setStoredItems, setStoredItems,
addItem, addItem,
removeItem, removeItem,
getProgress: () => loadProgress(jobId),
setProgress: (p) => saveProgress(jobId, p),
}; };
console.debug(`%c[Indexer] Running job "${jobId}"...`, "color: #4ea1ff"); console.debug(`%c[Indexer] Running job "${jobId}"...`, "color: #4ea1ff");
@@ -170,110 +251,158 @@ export async function runIndexing(): Promise<void> {
let merged = mergeItems(stored, newItemsRaw); let merged = mergeItems(stored, newItemsRaw);
if (job.purge) merged = job.purge(merged); if (job.purge) merged = job.purge(merged);
await setStoredItems(merged); // Store merged non-vector data await setStoredItems(merged);
await updateLastRunMeta(jobId); await updateLastRunMeta(jobId);
// Hydrate items for vector processing if (jobId === 'messages' || jobId === 'notifications') {
const renderComponent = renderComponentMap[job.renderComponentId]; hasStreamingJobs = true;
if (!renderComponent) {
console.warn(`Render component not found for job ${jobId} (ID: ${job.renderComponentId}) during hydration`);
} }
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( 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", "color: #00c46f",
); );
} catch (err) { } catch (err) {
console.debug(`%c[Indexer] ${job.label} failed:`, "color: red"); console.debug(`%c[Indexer] Job ${job.label} failed:`, "color: red");
console.error(err); console.error(err);
} }
completedJobs++; 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 (!hasStreamingJobs) {
if (allItemsFromJobs.length > 0) { const allItemsInPrimaryStores = await loadAllStoredItems();
if (allItemsInPrimaryStores.length > 0) {
console.debug( 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", "color: #4ea1ff",
); );
dispatchProgress(completedJobs, totalSteps, true, "Starting vectorization"); dispatchProgress(completedJobs, totalSteps, true, "Starting vectorization of stored items");
try { try {
const workerManager = VectorWorkerManager.getInstance(); const workerManager = VectorWorkerManager.getInstance();
// Pass a progress callback to the worker manager await workerManager.processItems(allItemsInPrimaryStores, (progress) => {
await workerManager.processItems(allItemsFromJobs, (progress) => { let detailMessage = progress.message || "";
// Update overall progress based on worker feedback if (
let detailMessage = progress.message || ''; progress.status === "processing" &&
if (progress.status === 'processing' && progress.total && progress.processed !== undefined) { progress.total &&
progress.processed !== undefined
) {
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`; detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
// You could potentially update the 'completed' count more granularly here } else if (progress.status === "complete") {
// For simplicity, we'll just update the detail message
} else if (progress.status === 'complete') {
detailMessage = "Vectorization complete"; detailMessage = "Vectorization complete";
// Mark the vectorization step as complete completedJobs++;
dispatchProgress(totalSteps, totalSteps, true, "Vectorization finished"); dispatchProgress(
} else if (progress.status === 'error') { completedJobs,
totalSteps,
false,
"Indexing finished",
detailMessage
);
} else if (progress.status === "error") {
detailMessage = `Vectorization error: ${progress.message}`; detailMessage = `Vectorization error: ${progress.message}`;
dispatchProgress(completedJobs, totalSteps, true, "Vectorization failed", detailMessage); // Show error dispatchProgress(
} else if (progress.status === 'started') { completedJobs,
totalSteps,
false,
"Vectorization failed",
detailMessage,
);
} else if (progress.status === "started") {
detailMessage = `Vectorization started for ${progress.total} items`; detailMessage = `Vectorization started for ${progress.total} items`;
} else if (progress.status === 'cancelled') { } else if (progress.status === "cancelled") {
detailMessage = `Vectorization cancelled: ${progress.message}`; detailMessage = `Vectorization cancelled: ${progress.message}`;
dispatchProgress(completedJobs, totalSteps, true, "Vectorization cancelled", detailMessage); dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization cancelled",
detailMessage,
);
} }
// Update the status detail if (progress.status !== "complete" && progress.status !== "error" && progress.status !== "cancelled") {
dispatchProgress(completedJobs, totalSteps, true, "Vectorization in progress", detailMessage); dispatchProgress(
completedJobs,
// When worker signals completion of *its* task, mark the final step complete totalSteps,
if (progress.status === 'complete') { true,
completedJobs++; // Increment completion count *after* vectorization finishes "Vectorization in progress",
dispatchProgress(completedJobs, totalSteps, false, "Indexing finished"); // Set indexing to false detailMessage,
} 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");
} }
}); });
console.debug("%c[Indexer] Vectorization task sent to worker.", "color: green"); console.debug(
// Note: runIndexing might return *before* vectorization is complete now. "%c[Indexer] Vectorization task for stored items sent to worker.",
// The progress updates will signal the true end state. "color: green",
);
} catch (error) { } catch (error) {
console.error(`%c[Indexer] ❌ Failed to send items to vector worker:`, "color: red", error); console.error(
dispatchProgress(completedJobs, totalSteps, false, "Vectorization failed", String(error)); // Stop indexing indicator `%c[Indexer] ❌ Failed to send items to vector worker:`,
"color: red",
error,
);
dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization failed",
String(error),
);
} }
} else { } else {
console.debug("%c[Indexer] No items to send for vectorization.", "color: gray"); console.debug(
// If no vectorization needed, indexing is done here. "%c[Indexer] No items found in primary stores to send for vectorization.",
completedJobs++; // Count the "skipped" vectorization step "color: gray",
dispatchProgress(completedJobs, totalSteps, false, "Indexing finished (no vectorization needed)"); );
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(); 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[] { function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] {
const map = new Map<string, IndexItem>(); const map = new Map<string, IndexItem>();
// Prioritize incoming items if IDs clash
for (const item of existing) { for (const item of existing) {
if (item && item.id) map.set(item.id, item); if (item && item.id) map.set(item.id, item);
} }
@@ -1,351 +1,12 @@
import type { Job } from "./types"; import type { Job } from "./types";
import type { IndexItem } from "./types"; import { messagesJob } from "./jobs/messages";
import { notificationsJob } from "./jobs/notifications";
interface MessageNotification { import { forumsJob } from "./jobs/forums";
notificationID: number; import { subjectsJob } from "./jobs/subjects";
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 ?? [];
}
export const jobs: Record<string, Job> = { export const jobs: Record<string, Job> = {
messages: { messages: messagesJob,
id: "messages", notifications: notificationsJob,
label: "Messages", forums: forumsJob,
renderComponentId: "message", subjects: subjectsJob,
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.
}; };
@@ -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 type { SvelteComponent } from "svelte";
import AssessmentComponent from "../components/AssessmentItem.svelte"; import AssessmentItem from "../components/items/AssessmentItem.svelte";
// import other components as needed import ForumItem from "../components/items/ForumItem.svelte";
import SubjectItem from "../components/items/SubjectItem.svelte";
export const renderComponentMap: Record<string, typeof SvelteComponent> = { export const renderComponentMap: Record<string, typeof SvelteComponent> = {
assessment: AssessmentComponent as unknown as typeof SvelteComponent, assessment: AssessmentItem as unknown as typeof SvelteComponent,
// messages: MessageComponent, message: AssessmentItem as unknown as typeof SvelteComponent,
// subject: SubjectComponent, forum: ForumItem as unknown as typeof SvelteComponent,
// etc... subject: SubjectItem as unknown as typeof SvelteComponent,
}; };
@@ -9,10 +9,7 @@ export interface IndexItem {
metadata: Record<string, any>; metadata: Record<string, any>;
actionId: string; actionId: string;
renderComponentId: string; renderComponentId: string;
} renderComponent?: typeof SvelteComponent;
export interface HydratedIndexItem extends IndexItem {
renderComponent: typeof SvelteComponent;
} }
export type Frequency = export type Frequency =
@@ -21,10 +18,12 @@ export type Frequency =
| { type: "expiry"; afterMs: number }; | { type: "expiry"; afterMs: number };
export interface JobContext { export interface JobContext {
getStoredItems: () => Promise<IndexItem[]>; getStoredItems: (storeId?: string) => Promise<IndexItem[]>;
setStoredItems: (items: IndexItem[]) => Promise<void>; setStoredItems: (items: IndexItem[], storeId?: string) => Promise<void>;
addItem: (item: IndexItem) => Promise<void>; addItem: (item: IndexItem, storeId?: string) => Promise<void>;
removeItem: (id: 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 { export interface Job {
@@ -34,4 +33,5 @@ export interface Job {
renderComponentId: string; renderComponentId: string;
run: (ctx: JobContext) => Promise<IndexItem[]>; run: (ctx: JobContext) => Promise<IndexItem[]>;
purge?: (items: IndexItem[]) => 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 { import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
EmbeddingIndex, import type { IndexItem } from "../types";
getEmbedding,
initializeModel,
} from "client-vector-search";
import type { HydratedIndexItem } from "../types";
let vectorIndex: EmbeddingIndex | null = null; let vectorIndex: EmbeddingIndex | null = null;
let isInitialized = false; let isInitialized = false;
let currentAbortController: AbortController | null = null; 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() { async function initWorker() {
if (isInitialized) { if (isInitialized) {
@@ -19,11 +26,24 @@ async function initWorker() {
await initializeModel(); await initializeModel();
vectorIndex = new EmbeddingIndex([]); vectorIndex = new EmbeddingIndex([]);
// Load existing items but track them to prevent duplicates
const stored = await vectorIndex.getAllObjectsFromIndexedDB(); const stored = await vectorIndex.getAllObjectsFromIndexedDB();
if (stored.length > 0) { 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( console.debug(
`Vector index loaded ${stored.length} items from IndexedDB.`, `Vector index loaded ${loadedItemIds.size} unique items from IndexedDB.`,
); );
} else { } else {
console.debug("No existing vector index found in IndexedDB."); console.debug("No existing vector index found in IndexedDB.");
@@ -32,16 +52,14 @@ async function initWorker() {
console.debug("Vector worker initialized successfully."); console.debug("Vector worker initialized successfully.");
} catch (e) { } catch (e) {
console.error("Failed to initialize vector worker:", e); console.error("Failed to initialize vector worker:", e);
// Set as initialized even on error to prevent retries, but index will be null
isInitialized = true; isInitialized = true;
vectorIndex = null; // Ensure index is null on error vectorIndex = null;
} }
} }
async function vectorizeItem( async function vectorizeItem(
item: HydratedIndexItem, item: IndexItem,
): Promise<(HydratedIndexItem & { embedding: number[] }) | null> { ): Promise<(IndexItem & { embedding: number[] }) | null> {
// Simplified for brevity - assumes embedding function doesn't need cancellation signal
try { try {
const textToEmbed = [ const textToEmbed = [
item.text, item.text,
@@ -57,19 +75,247 @@ async function vectorizeItem(
return { ...item, embedding }; return { ...item, embedding };
} catch (error) { } catch (error) {
console.error(`Error vectorizing item ${item.id}:`, 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."); console.debug("Worker received process request.");
if (!vectorIndex) { if (!vectorIndex) {
console.warn( console.warn(
"Processing requested but vector index not ready. Attempting init.", "Processing requested but vector index not ready. Attempting init.",
); );
await initWorker(); // Attempt initialization if not ready await initWorker();
if (!vectorIndex) { if (!vectorIndex) {
// Check again after attempt
self.postMessage({ self.postMessage({
type: "progress", type: "progress",
data: { 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) => { const unprocessedItems = items.filter((item) => {
if (signal.aborted) return false; // Check cancellation during filtering if (signal.aborted) return false;
try { return item.id && !loadedItemIds.has(item.id);
// 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) { if (signal.aborted) {
@@ -107,15 +347,15 @@ async function processItems(items: HydratedIndexItem[], signal: AbortSignal) {
} }
if (unprocessedItems.length === 0) { 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({ self.postMessage({
type: "progress", 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; 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({ self.postMessage({
type: "progress", type: "progress",
data: { data: {
@@ -141,11 +381,10 @@ async function processItems(items: HydratedIndexItem[], signal: AbortSignal) {
} }
const batch = unprocessedItems.slice(i, i + BATCH_SIZE); const batch = unprocessedItems.slice(i, i + BATCH_SIZE);
// Vectorize batch
const vectorizationResults = await Promise.all(batch.map(vectorizeItem)); const vectorizationResults = await Promise.all(batch.map(vectorizeItem));
const successfullyVectorized = vectorizationResults.filter( const successfullyVectorized = vectorizationResults.filter(
(result) => result !== null, (result) => result !== null,
) as (HydratedIndexItem & { embedding: number[] })[]; ) as (IndexItem & { embedding: number[] })[];
if (signal.aborted) { if (signal.aborted) {
console.debug("Processing cancelled after vectorization batch."); console.debug("Processing cancelled after vectorization batch.");
@@ -159,18 +398,18 @@ async function processItems(items: HydratedIndexItem[], signal: AbortSignal) {
return; return;
} }
// Add successfully vectorized items to index
if (successfullyVectorized.length > 0) { if (successfullyVectorized.length > 0) {
try { try {
successfullyVectorized.forEach((item) => vectorIndex!.add(item)); successfullyVectorized.forEach((item) => {
vectorIndex!.add(item);
loadedItemIds.add(item.id); // Track the added item
});
} catch (e) { } catch (e) {
console.error("Error adding batch to index:", e); console.error("Error adding batch to index:", e);
self.postMessage({ self.postMessage({
type: "progress", type: "progress",
data: { status: "error", message: `Error adding to index: ${e}` }, 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; return;
} }
// Save index after processing the batch
try { try {
await vectorIndex!.saveIndex("indexedDB"); 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) { } catch (e) {
console.error("Error saving index batch:", e); console.error("Error saving index batch:", e);
self.postMessage({ self.postMessage({
type: "progress", type: "progress",
data: { status: "error", message: `Error saving index batch: ${e}` }, 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); processedCount += batch.length;
// Report progress
self.postMessage({ self.postMessage({
type: "progress", type: "progress",
data: { data: {
status: "processing", status: "processing",
total: unprocessedItems.length, total: unprocessedItems.length,
processed: processedCount, 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 complete. Total unique items in index: ${loadedItemIds.size}`);
console.debug("Processing completed successfully.");
self.postMessage({ self.postMessage({
type: "progress", 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: { data: {
messageId, status: "complete",
error: error instanceof Error ? error.message : String(error), total: unprocessedItems.length,
processed: processedCount,
message: `Processing complete: ${processedCount} new items processed (${loadedItemIds.size} total unique items)`,
}, },
}); });
} else {
console.debug(
`Search (ID: ${messageId}) encountered error but was cancelled, suppressing error message.`,
);
self.postMessage({ type: "searchCancelled", data: { messageId } }); // Still notify of cancellation
} }
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);
} }
} }
// Handle messages from the main thread // Reinitialize
isInitialized = false;
vectorIndex = null;
await initWorker();
console.debug(`Vector worker reset complete. Loaded ${loadedItemIds.size} items.`);
self.postMessage({
type: "progress",
data: {
status: "complete",
message: `Worker reset complete. ${loadedItemIds.size} items loaded.`,
},
});
}
self.addEventListener("message", async (e) => { self.addEventListener("message", async (e) => {
// Make sure data and type exist const { type, data } = e.data;
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;
switch (type) { 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": 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(); await initWorker();
self.postMessage({ type: "ready" }); // Signal ready *after* init attempt self.postMessage({ type: "ready" });
} else {
console.debug("Received init message, but worker already initialized.");
self.postMessage({ type: "ready" }); // Signal ready anyway
}
break; 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: 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() initWorker()
.then(() => { .then(() => {
self.postMessage({ type: "ready" }); self.postMessage({ type: "ready" });
}) })
.catch((err) => { .catch((err) => {
console.error("Initial worker initialization failed:", 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" }); self.postMessage({ type: "ready" });
}); });
@@ -1,9 +1,9 @@
import type { HydratedIndexItem } from '../types'; import { refreshVectorCache } from "../../search/vector/vectorSearch";
import vectorWorker from './vectorWorker.ts?inlineWorker'; import type { IndexItem } from "../types";
import type { SearchResult } from 'client-vector-search'; import vectorWorker from "./vectorWorker.ts?inlineWorker";
export type ProgressCallback = (data: { export type ProgressCallback = (data: {
status: 'started' | 'processing' | 'complete' | 'error' | 'cancelled'; status: "started" | "processing" | "complete" | "error" | "cancelled";
total?: number; total?: number;
processed?: number; processed?: number;
message?: string; message?: string;
@@ -13,209 +13,366 @@ export class VectorWorkerManager {
private static instance: VectorWorkerManager; private static instance: VectorWorkerManager;
private worker: Worker | null = null; private worker: Worker | null = null;
private isInitialized = false; private isInitialized = false;
private readyPromise: Promise<void> | null = null; // To await initialization private readyPromise: Promise<void> | null = null;
private progressCallback: ProgressCallback | 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() { private constructor() {}
// Start initialization immediately, but allow awaiting it
this.readyPromise = this.initWorker();
}
static getInstance(): VectorWorkerManager { static getInstance(): VectorWorkerManager {
if (!VectorWorkerManager.instance) { if (!VectorWorkerManager.instance) {
console.debug("Creating new VectorWorkerManager instance");
VectorWorkerManager.instance = new VectorWorkerManager(); VectorWorkerManager.instance = new VectorWorkerManager();
} }
return VectorWorkerManager.instance; return VectorWorkerManager.instance;
} }
private async initWorker(): Promise<void> { private async initWorker(): Promise<void> {
// If already initialized or initializing, return the existing promise
if (this.isInitialized) return Promise.resolve(); if (this.isInitialized) return Promise.resolve();
if (this.readyPromise) return this.readyPromise; if (this.readyPromise) return this.readyPromise;
console.debug("Lazy-loading vector worker...");
return new Promise<void>((resolve, reject) => { 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(); this.worker = vectorWorker();
console.log("Worker initialized", this.worker);
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
console.error('Vector worker initialization timed out'); 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
if (this.worker) { if (this.worker) {
this.worker.terminate(); this.worker.terminate();
this.worker = null; this.worker = null;
} }
this.isInitialized = false; 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; this.progressCallback = null;
// Clear the static instance? Or assume app lifecycle handles this? if (this.streamingSession?.isActive) {
// VectorWorkerManager.instance = null; // Uncomment if needed 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 { getStaticCommands, type StaticCommandItem } from "../core/commands";
import { getDynamicItems } from "../utils/dynamicItems"; import { getDynamicItems } from "../utils/dynamicItems";
import type { CombinedResult } from "../core/types"; import type { CombinedResult } from "../core/types";
import type { HydratedIndexItem } from "../indexing/types"; import type { IndexItem } from "../indexing/types";
import { searchVectors } from "./vector/vectorSearch"; import { searchVectors } from "./vector/vectorSearch";
import type { VectorSearchResult } from "./vector/vectorTypes"; import type { VectorSearchResult } from "./vector/vectorTypes";
import { jobs } from "../indexing/jobs";
export function createSearchIndexes() { export function createSearchIndexes() {
const commands = getStaticCommands(); const commands = getStaticCommands();
@@ -14,26 +15,23 @@ export function createSearchIndexes() {
keys: ["text", "category", "keywords"], keys: ["text", "category", "keywords"],
includeScore: true, includeScore: true,
includeMatches: true, includeMatches: true,
threshold: 0.6, threshold: 0.4,
minMatchCharLength: 1, minMatchCharLength: 2,
ignoreLocation: true,
useExtendedSearch: false, useExtendedSearch: false,
}; };
const dynamicOptions = { const dynamicOptions = {
keys: [ keys: [
"text", { name: "text", weight: 2 },
"content", { name: "content", weight: 1 },
"category", { name: "category", weight: 1 },
"metadata.author",
"metadata.subject",
], ],
includeScore: true, includeScore: true,
includeMatches: true, includeMatches: true,
threshold: 0.6, threshold: 0.4,
minMatchCharLength: 3, minMatchCharLength: 2,
distance: 50, distance: 100,
useExtendedSearch: false, useExtendedSearch: true,
}; };
return { return {
@@ -41,7 +39,7 @@ export function createSearchIndexes() {
dynamicContentFuse: new Fuse( dynamicContentFuse: new Fuse(
dynamicItems, dynamicItems,
dynamicOptions, dynamicOptions,
) as Fuse<HydratedIndexItem>, ) as Fuse<IndexItem>,
commands, commands,
dynamicItems, dynamicItems,
}; };
@@ -85,11 +83,11 @@ export function searchCommands(
} }
export function searchDynamicItems( export function searchDynamicItems(
dynamicContentFuse: Fuse<HydratedIndexItem>, dynamicContentFuse: Fuse<IndexItem>,
query: string, query: string,
dynamicIdToItemMap: Map<string, HydratedIndexItem>, dynamicIdToItemMap: Map<string, IndexItem>,
limit = 10, limit = 10,
sortByRecent: boolean = true, // Added option to control sorting sortByRecent: boolean = true,
): CombinedResult[] { ): CombinedResult[] {
if (!dynamicContentFuse) return []; if (!dynamicContentFuse) return [];
@@ -101,7 +99,7 @@ export function searchDynamicItems(
return items.slice(0, limit).map((item) => ({ return items.slice(0, limit).map((item) => ({
id: item.id, id: item.id,
type: "dynamic" as const, type: "dynamic" as const,
score: 80, // Assign a default score for non-searched items score: 80,
item, item,
})); }));
} }
@@ -109,12 +107,15 @@ export function searchDynamicItems(
const now = Date.now(); const now = Date.now();
const searchResults = dynamicContentFuse.search(query, { limit }); const searchResults = dynamicContentFuse.search(query, { limit });
return searchResults.map((result: FuseResult<HydratedIndexItem>) => { return searchResults.map((result: FuseResult<IndexItem>) => {
const item = result.item; const item = result.item;
const fuseScore = 10 * (1 - (result.score || 0.5)); const fuseScore = 10 * (1 - (result.score || 0.5));
let score = fuseScore;
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24); const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0; // Apply boost only if sorting by recent const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
const score = fuseScore + recencyBoost; score += recencyBoost;
return { return {
id: item.id, id: item.id,
@@ -129,44 +130,20 @@ export function searchDynamicItems(
export async function performSearch( export async function performSearch(
query: string, query: string,
commandsFuse: Fuse<StaticCommandItem>, commandsFuse: Fuse<StaticCommandItem>,
dynamicContentFuse: Fuse<HydratedIndexItem>,
commandIdToItemMap: Map<string, StaticCommandItem>, commandIdToItemMap: Map<string, StaticCommandItem>,
dynamicIdToItemMap: Map<string, HydratedIndexItem>,
showRecentFirst: boolean,
): Promise<CombinedResult[]> { ): Promise<CombinedResult[]> {
const startTime = performance.now();
// Get all results first // Get all results first
const commandResults = searchCommands( const commandResults = searchCommands(
commandsFuse, commandsFuse,
query, query,
commandIdToItemMap, commandIdToItemMap,
); );
const commandEndTime = performance.now();
const dynamicResults = searchDynamicItems(
dynamicContentFuse,
query,
dynamicIdToItemMap,
10,
showRecentFirst,
);
const fuseEndTime = performance.now();
// Get vector results in parallel // Get vector results in parallel
let vectorResults: VectorSearchResult[] = []; let vectorResults: VectorSearchResult[] = [];
try { try {
vectorResults = await searchVectors(query, 10); vectorResults = await searchVectors(query);
} catch (e) {} } 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 // Create a map to store our final results, using ID as key to avoid duplicates
const resultMap = new Map<string, CombinedResult>(); const resultMap = new Map<string, CombinedResult>();
@@ -177,31 +154,23 @@ export async function performSearch(
// Process dynamic results and vector results together // Process dynamic results and vector results together
const seenIds = new Set<string>(); 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) => { vectorResults.forEach((v) => {
const id = v.object.id; const id = v.object.id;
if (!seenIds.has(id)) { if (!seenIds.has(id)) {
// This is a semantic match that Fuse missed - add it with the vector similarity as score // 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, { resultMap.set(id, {
id, id,
type: "dynamic" as const, type: "dynamic" as const,
score: v.similarity * 0.9, // High base score for semantic matches score,
item: v.object, item: v.object,
}); });
} }
@@ -1,6 +1,6 @@
import { EmbeddingIndex, getEmbedding, initializeModel } from 'client-vector-search'; import { EmbeddingIndex, getEmbedding, initializeModel } from "embeddia";
import type { HydratedIndexItem } from '../../indexing/types'; import type { IndexItem } from "../../indexing/types";
import type { SearchResult } from 'client-vector-search'; import type { SearchResult } from "embeddia";
let vectorIndex: EmbeddingIndex | null = null; let vectorIndex: EmbeddingIndex | null = null;
@@ -10,24 +10,36 @@ export async function initVectorSearch() {
vectorIndex = new EmbeddingIndex([]); vectorIndex = new EmbeddingIndex([]);
vectorIndex.preloadIndexedDB(); vectorIndex.preloadIndexedDB();
} catch (e) { } catch (e) {
console.error('Error initializing vector search', e); console.error("Error initializing vector search", e);
} }
} }
export interface VectorSearchResult extends SearchResult { 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(); if (!vectorIndex) await initVectorSearch();
const queryEmbedding = await getEmbedding(query.slice(0, 100)); const queryEmbedding = await getEmbedding(query.slice(0, 100));
const results = await vectorIndex!.search(queryEmbedding, { const results = await vectorIndex!.search(queryEmbedding, {
topK, topK,
useStorage: 'indexedDB', useStorage: "indexedDB",
dedupeEntries: true 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 { SearchResult } from "embeddia";
import type { HydratedIndexItem } from "../../indexing/types"; import type { IndexItem } from "../../indexing/types";
export interface VectorSearchResult extends SearchResult { export interface VectorSearchResult extends SearchResult {
object: HydratedIndexItem & { embedding: number[] }; object: IndexItem & { embedding: number[] };
} }
@@ -0,0 +1,80 @@
<script lang="ts">
import type { FuseResultMatch } from '../core/types';
const { text, term, matches } = $props<{
text: string;
term: string;
matches?: readonly FuseResultMatch[];
}>();
const segments = $derived(getSegments(text, term, matches));
// Build highlight map (copied and adapted from highlightMatch)
function getSegments(text: string, term: string, matches?: readonly FuseResultMatch[]) {
if (!term.trim() || !matches || matches.length === 0) return [{ text, highlight: false }];
try {
const fieldMatches = matches.find(
(match) =>
match.key === 'text' ||
(match.key === 'allContent' && match.value?.includes(text)),
);
if (!fieldMatches || !fieldMatches.indices || fieldMatches.indices.length === 0) {
return [{ text, highlight: false }];
}
const highlightMap = new Array(text.length).fill(false);
fieldMatches.indices.forEach((indices) => {
const start = indices[0];
const end = indices[1];
if (fieldMatches.key === 'allContent') {
const allContent = fieldMatches.value;
const textPos = allContent?.indexOf(text) ?? -1;
if (textPos >= 0) {
const relStart = start - textPos;
const relEnd = end - textPos;
if (relEnd >= 0 && relStart < text.length) {
for (let i = Math.max(0, relStart); i <= Math.min(text.length - 1, relEnd); i++) {
highlightMap[i] = true;
}
}
}
} else {
if (start >= 0 && end < text.length) {
for (let i = start; i <= end; i++) {
highlightMap[i] = true;
}
}
}
});
// Build segments
const segments: { text: string; highlight: boolean }[] = [];
let current = '';
let currentHighlight = highlightMap[0] || false;
for (let i = 0; i < text.length; i++) {
const isHighlight = highlightMap[i] || false;
if (isHighlight !== currentHighlight) {
segments.push({ text: current, highlight: currentHighlight });
current = '';
currentHighlight = isHighlight;
}
current += text[i];
}
if (current) {
segments.push({ text: current, highlight: currentHighlight });
}
return segments;
} catch (e) {
return [{ text, highlight: false }];
}
}
</script>
<span>
{#each segments as segment}
{#if segment.highlight}
<span class="highlight">{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</span>
@@ -0,0 +1,223 @@
import * as math from 'mathjs';
import { unitFullNames } from './unitMap';
export interface CalculatorResult {
result: string | null;
isValid: boolean;
isPartial: boolean;
inputUnit: string;
outputUnit: string;
error?: string;
}
const expandedMath = math.create(math.all);
expandedMath.import({
five: 5,
ten: 10,
three: 3,
four: 4,
eight: 8,
sixteen: 16,
twenty: 20,
twentyfive: 25,
fifty: 50,
hundred: 100,
plus: (a: number, b: number) => a + b,
minus: (a: number, b: number) => a - b,
times: (a: number, b: number) => a * b,
divided: (a: number, b: number) => a / b,
power: (a: number, b: number) => Math.pow(a, b),
half: (a: number) => a / 2,
double: (a: number) => a * 2,
quarter: (a: number) => a / 4,
// String functions
length: (str: string) => str.length,
concat: (...args: string[]) => args.join(''),
uppercase: (str: string) => str.toUpperCase(),
lowercase: (str: string) => str.toLowerCase(),
substr: (str: string, start: number, length: number) => str.substr(start, length),
// Random functions
randomInt: (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min,
// Comparison and Boolean operations
and: (a: boolean, b: boolean) => a && b,
or: (a: boolean, b: boolean) => a || b,
not: (a: boolean) => !a,
// Combinatorics
permutations: (n: number, r: number) => expandedMath.combinations(n, r) * expandedMath.factorial(r),
nPr: (n: number, r: number) => expandedMath.combinations(n, r) * expandedMath.factorial(r),
nCr: (n: number, r: number) => expandedMath.combinations(n, r),
// Number theory
gcd: (a: number, b: number) => expandedMath.gcd(a, b),
lcm: (a: number, b: number) => expandedMath.lcm(a, b),
// Precision functions
precision: (num: number, digits: number) => parseFloat(num.toPrecision(digits)),
fix: (num: number, digits: number) => parseFloat(num.toFixed(digits)),
// Percentage operations
percent: (value: number) => value / 100,
// Financial operations
compound: (principal: number, rate: number, time: number) => principal * Math.pow(1 + rate, time),
}, { override: true });
function detectUnit(expression: string): string {
try {
const unit = expandedMath.unit(expression);
if (unit) {
const unitStr = unit.formatUnits();
return unitFullNames[unitStr] || unitStr;
}
} catch (e) {
// Not a unit or invalid expression
}
return '';
}
function isLikelyMathExpression(input: string): boolean {
const trimmed = input.trim();
// Must contain at least one digit or mathematical operator
if (!/[\d+\-*/^()=.]/.test(trimmed)) {
return false;
}
// Check for common non-math words that shouldn't trigger calculator
const nonMathWords = ['abs', 'function', 'class', 'const', 'let', 'var', 'if', 'else', 'while', 'for', 'return', 'import', 'export'];
const words = trimmed.toLowerCase().split(/\s+/);
// If it's just a single non-math word, skip it
if (words.length === 1 && nonMathWords.includes(words[0]) && !/\d/.test(trimmed)) {
return false;
}
// Must have some mathematical content
const mathPattern = /(\d+\.?\d*|\+|\-|\*|\/|\^|\(|\)|sin|cos|tan|log|sqrt|pi|e|=)/i;
return mathPattern.test(trimmed);
}
function tryCompleteExpression(expression: string): string | null {
const trimmed = expression.trim();
// Common patterns for incomplete expressions
const incompletePatterns = [
/[\+\-\*\/\^]\s*$/, // ends with operator
/\(\s*$/, // ends with opening parenthesis
/[\+\-\*\/\^]\s*\(/, // operator followed by opening parenthesis
];
for (const pattern of incompletePatterns) {
if (pattern.test(trimmed)) {
// Try to evaluate what we have so far by removing the incomplete part
let partial = trimmed.replace(/[\+\-\*\/\^]\s*$/, '').trim();
// Handle cases like "4 + 3 *" -> evaluate "4 + 3"
if (partial && !partial.match(/[\+\-\*\/\^]\s*$/)) {
try {
const result = expandedMath.evaluate(partial);
if (typeof result === 'number' && !isNaN(result)) {
return expandedMath.format(result, { precision: 14, lowerExp: -15, upperExp: 15 });
}
} catch (e) {
// Continue to other attempts
}
}
}
}
return null;
}
export function calculateExpression(input: string): CalculatorResult {
const trimmed = input.trim();
// Early exit for empty or very short inputs
if (!trimmed || (trimmed.length <= 2 && !/\d/.test(trimmed))) {
return {
result: null,
isValid: false,
isPartial: false,
inputUnit: '',
outputUnit: '',
};
}
// Check if this looks like a math expression at all
if (!isLikelyMathExpression(trimmed)) {
return {
result: null,
isValid: false,
isPartial: false,
inputUnit: '',
outputUnit: '',
};
}
try {
// First try to evaluate the expression as-is
const evaluated = expandedMath.evaluate(trimmed.replace('**', '^'));
if (evaluated !== undefined) {
let result: string;
let inputUnit = '';
let outputUnit = '';
if (math.typeOf(evaluated) === 'Unit') {
// Handle unit conversion results
result = expandedMath.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
inputUnit = detectUnit(trimmed);
outputUnit = detectUnit(result);
} else if (typeof evaluated === 'number') {
// Handle regular numbers
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
} else {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
}
return {
result,
isValid: true,
isPartial: false,
inputUnit,
outputUnit,
};
}
} catch (error) {
// Try to handle incomplete expressions
const partialResult = tryCompleteExpression(trimmed);
if (partialResult) {
return {
result: partialResult,
isValid: true,
isPartial: true,
inputUnit: '',
outputUnit: '',
};
}
// If it still looks like math but failed, return the error
return {
result: null,
isValid: false,
isPartial: false,
inputUnit: '',
outputUnit: '',
error: error instanceof Error ? error.message : 'Invalid expression',
};
}
return {
result: null,
isValid: false,
isPartial: false,
inputUnit: '',
outputUnit: '',
};
}
@@ -1,5 +1,5 @@
import type { SvelteComponent } from "svelte"; import type { SvelteComponent } from "svelte";
import type { HydratedIndexItem } from "./indexing/types"; import type { IndexItem } from "../indexing/types";
export interface DynamicContentItem { export interface DynamicContentItem {
id: string; id: string;
@@ -13,18 +13,18 @@ export interface DynamicContentItem {
renderComponent?: typeof SvelteComponent; renderComponent?: typeof SvelteComponent;
} }
let dynamicItems: HydratedIndexItem[] = []; let dynamicItems: IndexItem[] = [];
/** /**
* Loads a new set of dynamic items. * Loads a new set of dynamic items.
*/ */
export function loadDynamicItems(items: HydratedIndexItem[]) { export function loadDynamicItems(items: IndexItem[]) {
dynamicItems = items; dynamicItems = items;
} }
/** /**
* Returns all currently loaded dynamic items. * Returns all currently loaded dynamic items.
*/ */
export function getDynamicItems(): HydratedIndexItem[] { export function getDynamicItems(): IndexItem[] {
return dynamicItems; return dynamicItems;
} }

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