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
+102 -119
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 */
@@ -224,7 +206,7 @@ module.exports = {
As in practice only commonjs ('cjs') and ecmascript modules ('es6') As in practice only commonjs ('cjs') and ecmascript modules ('es6')
are widely used, you can limit the moduleSystems to those. are widely used, you can limit the moduleSystems to those.
*/ */
// moduleSystems: ['cjs', 'es6'], // moduleSystems: ['cjs', 'es6'],
/* /*
@@ -249,7 +231,7 @@ module.exports = {
"specify": for each dependency identify whether it only exists before compilation or also after "specify": for each dependency identify whether it only exists before compilation or also after
*/ */
tsPreCompilationDeps: true, tsPreCompilationDeps: true,
/* list of extensions to scan that aren't javascript or compile-to-javascript. /* list of extensions to scan that aren't javascript or compile-to-javascript.
Empty by default. Only put extensions in here that you want to take into Empty by default. Only put extensions in here that you want to take into
account that are _not_ parsable. account that are _not_ parsable.
@@ -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.
@@ -306,7 +288,7 @@ module.exports = {
a hack. a hack.
*/ */
// exoticRequireStrings: [], // exoticRequireStrings: [],
/* options to pass on to enhanced-resolve, the package dependency-cruiser /* options to pass on to enhanced-resolve, the package dependency-cruiser
uses to resolve module references to disk. The values below should be uses to resolve module references to disk. The values below should be
suitable for most situations suitable for most situations
@@ -315,7 +297,7 @@ module.exports = {
there will override the ones specified here. there will override the ones specified here.
*/ */
enhancedResolveOptions: { enhancedResolveOptions: {
/* What to consider as an 'exports' field in package.jsons */ /* What to consider as an 'exports' field in package.jsons */
exportsFields: ["exports"], exportsFields: ["exports"],
/* List of conditions to check for in the exports field. /* List of conditions to check for in the exports field.
Only works when the 'exportsFields' array is non-empty. Only works when the 'exportsFields' array is non-empty.
@@ -348,13 +330,13 @@ module.exports = {
for details for details
*/ */
skipAnalysisNotInRules: true, skipAnalysisNotInRules: true,
/* List of built-in modules to use on top of the ones node declares. /* List of built-in modules to use on top of the ones node declares.
See https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#builtinmodules-influencing-what-to-consider-built-in--core-modules See https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#builtinmodules-influencing-what-to-consider-built-in--core-modules
for details for details
*/ */
builtInModules: { builtInModules: {
add: [ add: [
"bun", "bun",
"bun:ffi", "bun:ffi",
@@ -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
+10 -7
View File
@@ -12,12 +12,15 @@
}, },
"rules": { "rules": {
// allow importing ts extensions // allow importing ts extensions
"sort-imports": ["error", { "sort-imports": [
"ignoreCase": true, "error",
"ignoreDeclarationSort": true, {
"ignoreMemberSort": false, "ignoreCase": true,
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"] "ignoreDeclarationSort": true,
}], "ignoreMemberSort": false,
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
}
],
"import/extensions": [ "import/extensions": [
"error", "error",
"ignorePackages", "ignorePackages",
@@ -29,4 +32,4 @@
] ]
}, },
"plugins": ["import"] "plugins": ["import"]
} }
+47 -47
View File
@@ -3,54 +3,54 @@ description: Report an issue with the modpack in its unmodified state. For other
labels: bug labels: bug
title: "[BUG]" title: "[BUG]"
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Before reporting an issue, [please search](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) to make sure it has not already been reported (make sure to search closed issues as well!). Before reporting an issue, [please search](https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues) to make sure it has not already been reported (make sure to search closed issues as well!).
- type: textarea - type: textarea
attributes: attributes:
label: Describe the bug label: Describe the bug
description: Describe your issue. For general issues and questions you'll get a faster answer [from our Discord.](https://discord.gg/YzmbnCDkat) description: Describe your issue. For general issues and questions you'll get a faster answer [from our Discord.](https://discord.gg/YzmbnCDkat)
validations: validations:
required: true required: true
- type: input - type: input
attributes: attributes:
label: Extension version label: Extension version
description: What version of the extension are you using? description: What version of the extension are you using?
placeholder: Find it by opening the config menu and clicking the about icon in the top right. placeholder: Find it by opening the config menu and clicking the about icon in the top right.
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
attributes: attributes:
label: Browser label: Browser
description: Which Browser are you using? description: Which Browser are you using?
options: options:
- Chrome - Chrome
- Firefox - Firefox
- Brave - Brave
- Safari - Safari
- DuckDuckGO - DuckDuckGO
- Microsoft Edge - Microsoft Edge
- Other Chromium-Based Browser - Other Chromium-Based Browser
- Other Non-Chromium-Based Browser - Other Non-Chromium-Based Browser
validations: validations:
required: true required: true
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Confirm label: Confirm
options: options:
- label: This bug report is about an issue with the extension itself. I have not modified the extension nor added any unsupported plugins. If this is not the case, I know that I should post the issue to the extension's Discord support channel instead. - label: This bug report is about an issue with the extension itself. I have not modified the extension nor added any unsupported plugins. If this is not the case, I know that I should post the issue to the extension's Discord support channel instead.
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Additional context label: Additional context
description: Screenshots, video or any other information. Include photos of the console if possible description: Screenshots, video or any other information. Include photos of the console if possible
placeholder: | placeholder: |
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations: validations:
required: false required: false
+37 -40
View File
@@ -3,52 +3,49 @@ description: Suggest a new Feature to be added or replaced in BetterSeqtaPLUS
labels: enhancement labels: enhancement
title: "[FR]" title: "[FR]"
body: body:
- type: checkboxes
attributes:
label: Confirm
options:
- label: "Is this feature request related to a Bug report?"
required: false
- type: checkboxes - type: input
attributes: attributes:
label: Confirm
options:
- label: "Is this feature request related to a Bug report?"
required: false
- type: input
attributes:
label: Bug report link label: Bug report link
description: "If this feature request is related to a bug report, please insert the link to the bug report here" description: "If this feature request is related to a bug report, please insert the link to the bug report here"
placeholder: "https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/..." placeholder: "https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/..."
validations: validations:
required: false required: false
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
## 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 multiple: false
multiple: false options:
options: - Graphical
- Graphical - Functional
- Functional - Not Sure
- Not Sure 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
+19 -19
View File
@@ -2,9 +2,9 @@ name: NodeJS Build
on: on:
push: push:
branches: [ "main" ] branches: ["main"]
pull_request: pull_request:
branches: [ "main" ] branches: ["main"]
jobs: jobs:
build: build:
@@ -15,24 +15,24 @@ jobs:
node-version: [20.x] node-version: [20.x]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
- name: Build - name: Build
run: | run: |
npm install --legacy-peer-deps npm install --legacy-peer-deps
npm run build npm run build
- name: Zip dist folder - name: Zip dist folder
run: | run: |
zip -r dist.zip dist zip -r dist.zip dist
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: dist-zip name: dist-zip
path: dist.zip path: dist.zip
+11 -11
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
@@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban ### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community **Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals. individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within **Consequence**: A permanent ban from any sort of public interaction within
+9 -10
View File
@@ -1,5 +1,4 @@
#
#
<a href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel"> <a href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel">
<img src="https://socialify.git.ci/betterseqta/betterseqta-plus/image?description=1&font=Inter&forks=1&issues=1&logo=data%3Aimage%2Fsvg%2Bxml%2C%253Csvg%20height%3D%27656pt%27%20fill%3D%27white%27%20preserveAspectRatio%3D%27xMidYMid%20meet%27%20viewBox%3D%270%200%20658%20656%27%20width%3D%27658pt%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%253E%253Cg%20transform%3D%27matrix(.1%200%200%20-.1%200%20656)%27%253E%253Cpath%20d%3D%27m2960%206499c-918-100-1726-561-2278-1299-196-262-374-609-475-925-171-533-203-1109-91-1655%20228-1115%201030-2032%202104-2408%20356-124%20680-177%201080-176%20269%201%20403%2014%20650%2064%20790%20159%201503%20624%201980%201290%20714%20998%20799%202342%20217%203420-488%20902-1361%201515-2382%201671-113%2017-196%2022-430%2024-159%202-328-1-375-6zm566-1443c476-99%20885-385%201134-791%20190-309%20282-696%20250-1045-22-240-73-420-180-635-78-156-159-275-274-401l-77-84h445%20446v-235-236l-1162%204-1163%203-100%2023c-449%20101-812%20337-1071%20697-77%20107-193%20335-233%20459-115%20358-116%20726-1%201078%20209%20644%20766%201101%201446%201187%20128%2016%20405%204%20540-24z%27%2F%253E%253Cpath%20d%3D%27m3065%204604c-250-36-396-89-576-209-280-187-470-478-535-821-25-135-16-395%2019-525%2095-351%20331-644%20651-806%2098-49%20225-93%20331-114%2092-18%20368-18%20460%200%20481%2095%20853%20444%20982%20921%2035%20129%2044%20389%2019%20524-36%20191-121%20387-228%20531-186%20249-476%20428-783%20485-65%2012-291%2021-340%2014z%27%2F%253E%253C%2Fg%253E%253C%2Fsvg%253E&name=1&owner=1&pattern=Signal&stargazers=1&theme=Dark" /> <img src="https://socialify.git.ci/betterseqta/betterseqta-plus/image?description=1&font=Inter&forks=1&issues=1&logo=data%3Aimage%2Fsvg%2Bxml%2C%253Csvg%20height%3D%27656pt%27%20fill%3D%27white%27%20preserveAspectRatio%3D%27xMidYMid%20meet%27%20viewBox%3D%270%200%20658%20656%27%20width%3D%27658pt%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%253E%253Cg%20transform%3D%27matrix(.1%200%200%20-.1%200%20656)%27%253E%253Cpath%20d%3D%27m2960%206499c-918-100-1726-561-2278-1299-196-262-374-609-475-925-171-533-203-1109-91-1655%20228-1115%201030-2032%202104-2408%20356-124%20680-177%201080-176%20269%201%20403%2014%20650%2064%20790%20159%201503%20624%201980%201290%20714%20998%20799%202342%20217%203420-488%20902-1361%201515-2382%201671-113%2017-196%2022-430%2024-159%202-328-1-375-6zm566-1443c476-99%20885-385%201134-791%20190-309%20282-696%20250-1045-22-240-73-420-180-635-78-156-159-275-274-401l-77-84h445%20446v-235-236l-1162%204-1163%203-100%2023c-449%20101-812%20337-1071%20697-77%20107-193%20335-233%20459-115%20358-116%20726-1%201078%20209%20644%20766%201101%201446%201187%20128%2016%20405%204%20540-24z%27%2F%253E%253Cpath%20d%3D%27m3065%204604c-250-36-396-89-576-209-280-187-470-478-535-821-25-135-16-395%2019-525%2095-351%20331-644%20651-806%2098-49%20225-93%20331-114%2092-18%20368-18%20460%200%20481%2095%20853%20444%20982%20921%2035%20129%2044%20389%2019%20524-36%20191-121%20387-228%20531-186%20249-476%20428-783%20485-65%2012-291%2021-340%2014z%27%2F%253E%253C%2Fg%253E%253C%2Fsvg%253E&name=1&owner=1&pattern=Signal&stargazers=1&theme=Dark" />
@@ -53,7 +52,7 @@
## Creating Custom Themes ## Creating Custom Themes
If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://betterseqta.gitbook.io/betterseqta-docs). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator). If you are looking to create custom themes, I would recommend you start at the official documentation [here](https://betterseqta.gitbook.io/betterseqta-docs). You can see some premade examples along with a compilation script that can be used to allow for CSS frameworks and libraries such as SCSS to be used [here](https://github.com/BetterSEQTA/BetterSEQTA-Theme-Generator).
Don't worry- if you get stuck feel free to ask around in the [discord](https://discord.gg/YzmbnCDkat). We're open and happy to help out! Happy creating :) Don't worry- if you get stuck feel free to ask around in the [discord](https://discord.gg/YzmbnCDkat). We're open and happy to help out! Happy creating :)
@@ -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:
@@ -75,20 +72,20 @@ You may install the dependencies like below:
npm install # or your preferred package manager like pnpm or yarn npm install # or your preferred package manager like pnpm or yarn
``` ```
But it is recommended to do it like this: 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
+5 -4
View File
@@ -4,12 +4,13 @@
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
+3 -1
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
@@ -47,4 +49,4 @@ To contribute to the documentation:
## License ## License
BetterSEQTA+ is licensed under the [MIT License](../LICENSE). BetterSEQTA+ is licensed under the [MIT License](../LICENSE).
+7 -1
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
@@ -259,4 +265,4 @@ If you have any questions about contributing, please:
2. Ask in the Discord server 2. Ask in the Discord server
3. Open a GitHub Discussion 3. Open a GitHub Discussion
Thank you for contributing to BetterSEQTA+! Your efforts help make SEQTA better for students and teachers everywhere. Thank you for contributing to BetterSEQTA+! Your efforts help make SEQTA better for students and teachers everywhere.
+3 -1
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
@@ -177,4 +179,4 @@ bun run dev
Now that you have BetterSEQTA+ installed, you can: Now that you have BetterSEQTA+ installed, you can:
- [Getting Started with Plugins](./plugins/getting-started.md) - [Getting Started with Plugins](./plugins/getting-started.md)
- [Contribute to the project](../CONTRIBUTING.md) - [Contribute to the project](../CONTRIBUTING.md)
+86 -46
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,40 +17,43 @@ 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,
// This is where the magic happens! // Optional: Mark your plugin as beta to show a "Beta" tag in settings
beta: true,
// 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);
}); });
// 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
@@ -121,22 +130,22 @@ const settingsInstance = new MyPluginClass();
const myPlugin: Plugin<typeof settings> = { const myPlugin: Plugin<typeof settings> = {
// ... other plugin details ... // ... other plugin details ...
settings: settingsInstance.settings, settings: settingsInstance.settings,
run: async (api) => { run: async (api) => {
// Use the setting // Use the setting
if (api.settings.showMessage) { if (api.settings.showMessage) {
// Show the message // Show the message
} }
// 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
@@ -178,7 +187,7 @@ Want to make your plugin look pretty? You can add CSS styles:
```typescript ```typescript
const myPlugin: Plugin = { const myPlugin: Plugin = {
// ... other plugin details ... // ... other plugin details ...
// Add your CSS here // Add your CSS here
styles: ` styles: `
.my-plugin-message { .my-plugin-message {
@@ -196,10 +205,10 @@ const myPlugin: Plugin = {
to { transform: translateY(0); opacity: 1; } to { transform: translateY(0); opacity: 1; }
} }
`, `,
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,8 +289,9 @@ 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)
Happy coding and feel free to checkout the api reference [here](./api-reference.md) Happy coding and feel free to checkout the api reference [here](./api-reference.md)
+130 -78
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) => {
timetable.appendChild(button); const button = document.createElement("button");
}); button.textContent = "Export";
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,7 +359,8 @@ 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
- Follow the plugin structure shown above - Follow the plugin structure shown above
+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',
],
};
+2 -2
View File
@@ -7,10 +7,10 @@ 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}`;
return `export default '${dataURL}';`; return `export default '${dataURL}';`;
}, },
}; };
+10 -10
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);
}, },
} };
} }
+5 -5
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;
} }
+19 -19
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;
}
+72 -49
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
const match = file.match(/@([\d\.]+)-/); .map((file) => {
console.log('Matching file:', file, 'Version found:', match ? match[1] : 'None'); const match = file.match(/@([\d\.]+)-/);
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",
+34 -29
View File
@@ -1,65 +1,70 @@
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();
} }
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);
} }
} }
} }
+92 -107
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) {
case "reloadTabs":
reloadSeqtaPages();
break;
switch (request.type) { case "extensionPages":
case 'reloadTabs': browser.tabs.query({}).then(function (tabs) {
reloadSeqtaPages(); for (let tab of tabs) {
break; if (tab.url?.includes("chrome-extension://")) {
browser.tabs.sendMessage(tab.id!, request);
case 'extensionPages': }
browser.tabs.query({}).then(function (tabs) {
for (let tab of tabs) {
if (tab.url?.includes('chrome-extension://')) {
browser.tabs.sendMessage(tab.id!, request);
} }
}
});
break;
case 'currentTab':
browser.tabs.query({ active: true, currentWindow: true }).then(function (tabs) {
browser.tabs.sendMessage(tabs[0].id!, request).then(function (response) {
sendResponse(response);
}); });
}); break;
return true;
case 'githubTab': case "currentTab":
browser.tabs.create({ url: 'github.com/BetterSEQTA/BetterSEQTA-Plus' }); browser.tabs
break; .query({ active: true, currentWindow: true })
.then(function (tabs) {
case 'setDefaultStorage': browser.tabs
SetStorageValue(DefaultValues); .sendMessage(tabs[0].id!, request)
break; .then(function (response) {
sendResponse(response);
});
});
return true;
case 'sendNews': case "githubTab":
fetchNews(request.source ?? 'australia', sendResponse); browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" });
return true; break;
default: case "setDefaultStorage":
console.log('Unknown request type'); SetStorageValue(DefaultValues);
} break;
return false; case "sendNews":
}); fetchNews(request.source ?? "australia", sendResponse);
return true;
default:
console.log("Unknown request type");
}
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();
} }
+14 -15
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,11 +60,11 @@ 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`;
fetchAustraliaNews(url, sendResponse); fetchAustraliaNews(url, sendResponse);
@@ -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) => {
+5 -3
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 {
@@ -91,4 +93,4 @@ body:has(.outside-container:not(.hide)) #AddedSettings.tooltip:hover > .tooltipt
top: 115% !important; top: 115% !important;
background: var(--text-primary) !important; background: var(--text-primary) !important;
color: var(--theme-primary) !important; color: var(--theme-primary) !important;
} }
+1 -1
View File
@@ -1 +1 @@
import './documentload.scss'; import "./documentload.scss";
+11 -2
View File
@@ -15,7 +15,7 @@
* along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>. * along with EvenBetterSEQTA. If not, see <https://www.gnu.org/licenses/>.
*/ */
body { body {
background: transparent; background: transparent;
} }
@@ -25,7 +25,9 @@
span, span,
body { body {
color: white !important; color: white !important;
text-shadow: 1px 1px 2px #161616, 0 0 1em #161616; text-shadow:
1px 1px 2px #161616,
0 0 1em #161616;
} }
body { body {
@@ -111,4 +113,11 @@
text-decoration: underline; text-decoration: underline;
transition: text-shadow 0.5s; transition: text-shadow 0.5s;
} }
}
.cke_panel_listItem > a {
&:hover {
background: #3d3d3e !important;
}
} }
+219 -39
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 {
@@ -758,7 +763,7 @@ ol > [data-label] {
margin-left: 4px; margin-left: 4px;
margin-bottom: 4px; margin-bottom: 4px;
} }
[class*="Message__Message___"] > .uiFrameWrapper .iframeWrapper { [class*="Message__Message___"] > .uiFrameWrapper .iframeWrapper {
background: transparent; background: transparent;
} }
[class*="Viewer__newMessage___"] { [class*="Viewer__newMessage___"] {
@@ -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;
color: var(--text-primary) !important; }
.programmeNavigator {
box-shadow: 0 0 40px 0px rgba(0,0,0,0.05);
.navigator {
padding: 6px !important;
.bar.flat::before {
z-index: 10;
left: 8px;
margin: 0;
position: absolute;
top: 50%;
}
&::after {
content: "";
position: fixed;
z-index: 1;
top: 70px;
width: 390px;
height: 60px;
background: linear-gradient(to bottom, var(--background-primary) 50%, rgba(0, 0, 0, 0));
pointer-events: none;
}
.search {
padding: 10px;
padding-left: 30px;
top: 8px;
margin-top: -50px;
z-index: 2;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1);
}
.bar {
top: 13px;
padding-right: 4px;
position: sticky;
pointer-events: none;
button {
position: unset;
pointer-events: auto;
}
}
.meta {
border-radius: 8px;
}
.cover {
padding: 12px;
}
.cover,
.lesson {
border-radius: 8px;
transition: background 0.1s ease-out;
position: relative;
color: var(--text-primary) !important;
z-index: 1;
&:hover {
background: transparent !important;
}
&.selected {
background: transparent !important;
}
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 8px;
background: var(--auto-background);
opacity: 0;
scale: 0.95;
transition: opacity 0.2s ease-out, scale 0.1s ease-out;
z-index: -1;
pointer-events: none;
}
&:hover::before {
opacity: 0.5;
scale: 1;
}
&.selected::before {
opacity: 1;
scale: 1;
}
}
}
}
.pane {
.content:has(.programmeNavigator) {
margin: 0;
}
.programmeNavigator .navigator {
.search {
border-radius: 8px;
}
&::before {
top: 83px;
}
}
}
.dark .programmeNavigator .navigator {
.search {
background: var(--background-secondary) !important;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1), inset 0px 0px 15px 0px rgba(0, 0, 0, 0.1) !important;
}
} }
.dark #main > .course > .content > h1 { .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;
}
+4 -2
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
@@ -25,7 +25,7 @@
padding-top: 2px; padding-top: 2px;
} }
.sub:has(ul>li.hasChildren.active) > .nav > .back { .sub:has(ul > li.hasChildren.active) > .nav > .back {
display: none !important; display: none !important;
} }
@@ -40,4 +40,4 @@
#menu > ul:has(li.hasChildren.active) > li.active { #menu > ul:has(li.hasChildren.active) > li.active {
background: transparent !important; background: transparent !important;
} }
+1 -1
View File
@@ -12,4 +12,4 @@ html:not(.dark) {
--theme-primary: #ffffff; --theme-primary: #ffffff;
--theme-secondary: #e5e7eb; --theme-secondary: #e5e7eb;
--text-primary: black; --text-primary: black;
} }
+4 -4
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,11 +36,12 @@ 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);
} }
.report { .report {
backdrop-filter: blur(10px) !important; backdrop-filter: blur(10px) !important;
} }
@@ -71,4 +71,4 @@ html.transparencyEffects {
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
} }
} }
+8 -8
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;
} }
@@ -33,4 +33,4 @@ declare module "*.gif?base64" {
declare module "*.svg?base64" { declare module "*.svg?base64" {
const value: string; const value: string;
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>
+45 -30
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,37 +35,49 @@ 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);
let updatedPresets; let updatedPresets;
if (existingIndex === 0) { if (existingIndex === 0) {
// No need to update if the selected color is already the first element // No need to update if the selected color is already the first element
return; return;
@@ -78,16 +90,19 @@ export default function Picker({
} else { } else {
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>
+14 -12
View File
@@ -8,15 +8,17 @@
let select: HTMLSelectElement; let select: HTMLSelectElement;
</script> </script>
<select <div class="border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 shadow-2xl rounded-lg w-full overflow-clip">
bind:this={select} <select
value={state} bind:this={select}
onchange={() => onChange(select.value)} value={state}
class="px-4 py-1 text-[0.75rem] dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white rounded-md w-full" onchange={() => onChange(select.value)}
> class="px-4 py-1 text-[0.75rem] dark:text-white w-full border-none bg-transparent focus:ring-0 focus:bg-white/20 dark:focus:bg-black/10"
{#each options as option} >
<option value={option.value}> {#each options as option}
{option.label} <option value={option.value}>
</option> {option.label}
{/each} </option>
</select> {/each}
</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%;
} }
+2 -2
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;
} }
+2 -9
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"
@@ -42,10 +41,4 @@
bind:this={handle} bind:this={handle}
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>
+1 -1
View File
@@ -1,3 +1,3 @@
.tab-width { .tab-width {
width: var(--tab-width); width: var(--tab-width);
} }
@@ -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
+26 -16
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,17 +67,19 @@ 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
return true; return true;
} }
+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());
} }
} }
+3 -3
View File
@@ -1,13 +1,13 @@
type SettingsPopupCallback = () => void; type SettingsPopupCallback = () => void;
/** /**
* This is a singleton that triggers an update when the settings popup is closed. * This is a singleton that triggers an update when the settings popup is closed.
* This is used to close the colour picker. * This is used to close the colour picker.
* Usage: * Usage:
* settingsPopup.addListener(() => { * settingsPopup.addListener(() => {
* console.log('Settings popup closed'); * console.log('Settings popup closed');
* }); * });
*/ */
class SettingsPopup { class SettingsPopup {
private static instance: SettingsPopup; private static instance: SettingsPopup;
private listeners: Set<SettingsPopupCallback> = new Set(); private listeners: Set<SettingsPopupCallback> = new Set();
@@ -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 @@
export let selectedBackground = $state<string | null>(null); export let selectedBackground = $state<string | null>(null);
+2 -2
View File
@@ -1,4 +1,4 @@
@import './components/ColourPicker.css'; @import "./components/ColourPicker.css";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@@ -47,4 +47,4 @@ input {
.editorHeight { .editorHeight {
height: calc(100vh - 58px); height: calc(100vh - 58px);
} }
+2 -2
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 });
+2 -2
View File
@@ -1,6 +1,6 @@
import './index.css'; import "./index.css";
declare module "*.png"; declare module "*.png";
declare module "*.svg"; declare module "*.svg";
declare module "*.jpeg"; declare module "*.jpeg";
declare module "*.jpg"; declare module "*.jpg";
+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} />
+113 -81
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,72 +194,92 @@
{/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">
<p class="text-xs">{plugin.description}</p> Enable {plugin.name}
</div> {#if plugin.beta}
<div> <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">
<Switch Beta
state={pluginSettingsValues[plugin.pluginId]?.enabled ?? true} </span>
onChange={(value) => updatePluginSetting(plugin.pluginId, 'enabled', value)}
/>
</div>
</div>
{/if}
<!-- Only show other settings if plugin is enabled or has no disableToggle -->
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]}
<!-- Skip the 'enabled' setting if it's part of the settings object -->
{#if key !== 'enabled'}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">{setting.title || key}</h2>
<p class="text-xs">{setting.description || ''}</p>
</div>
<div>
{#if setting.type === 'boolean'}
<Switch
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/>
{:else if setting.type === 'number'}
<Slider
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
min={setting.min}
max={setting.max}
step={setting.step}
/>
{:else if setting.type === 'string'}
<input
type="text"
class="px-2 py-1 text-sm rounded-md dark:bg-[#38373D] bg-[#DDDDDD] dark:text-white"
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
oninput={(e) => updatePluginSetting(plugin.pluginId, key, e.currentTarget.value)}
/>
{:else if setting.type === 'select'}
<Select
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
options={(setting.options as string[]).map(opt => ({
value: opt,
label: opt.charAt(0).toUpperCase() + opt.slice(1)
}))}
/>
{/if} {/if}
</div> </h2>
<p class="text-xs">{plugin.description}</p>
</div> </div>
{/if} <div>
{/each} <Switch
{/if} state={pluginSettingsValues[plugin.pluginId]?.enabled ?? true}
</div> onChange={(value) => updatePluginSetting(plugin.pluginId, 'enabled', value)}
/>
</div>
</div>
{/if}
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]}
<!-- Skip the 'enabled' setting if it's part of the settings object -->
{#if key !== 'enabled'}
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">{setting.title || key}</h2>
<p class="text-xs">{setting.description || ''}</p>
</div>
<div>
{#if setting.type === 'boolean'}
<Switch
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/>
{:else if setting.type === 'number'}
<Slider
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
min={setting.min}
max={setting.max}
step={setting.step}
/>
{:else if setting.type === 'string'}
<input
type="text"
class="px-2 py-1 text-sm rounded-md dark:bg-[#38373D]/50 bg-[#DDDDDD] dark:text-white border-none"
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
oninput={(e) => updatePluginSetting(plugin.pluginId, key, e.currentTarget.value)}
/>
{:else if setting.type === 'select'}
<Select
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
options={(setting.options as string[]).map(opt => ({
value: opt,
label: opt.charAt(0).toUpperCase() + opt.slice(1)
}))}
/>
{:else if setting.type === 'button'}
<Button
onClick={() => setting.trigger?.()}
text={setting.title}
/>
{:else if setting.type === 'hotkey'}
<HotkeyInput
value={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
/>
{/if}
</div>
</div>
{/if}
{/each}
{/if}
</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,25 +292,27 @@
})} })}
{#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="pr-4"> <div class="flex justify-between items-center px-4 py-3">
<h2 class="text-sm font-bold">Developer Mode</h2> <div class="pr-4">
<p class="text-xs">Enables developer mode, allowing you to test new features and changes.</p> <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>
</div>
<div>
<Switch state={$settingsState.devMode} onChange={(isOn: boolean) => settingsState.devMode = isOn} />
</div>
</div> </div>
<div> <div class="flex justify-between items-center px-4 py-3">
<Switch state={$settingsState.devMode} onChange={(isOn: boolean) => settingsState.devMode = isOn} /> <div class="pr-4">
</div> <h2 class="text-sm font-bold">Sensitive Hider</h2>
</div> <p class="text-xs">Replace sensitive content with mock data</p>
<div class="flex justify-between items-center px-4 py-3"> </div>
<div class="pr-4"> <div>
<h2 class="text-sm font-bold">Sensitive Hider</h2> <Button
<p class="text-xs">Replace sensitive content with mock data</p> onClick={() => hideSensitiveContent()}
</div> text="Hide"
<div> />
<Button </div>
onClick={() => hideSensitiveContent()}
text="Hide"
/>
</div> </div>
</div> </div>
{/if} {/if}
+18 -16
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 -->
@@ -156,4 +158,4 @@
Loading shortcuts... Loading shortcuts...
</div> </div>
{/if} {/if}
</div> </div>
+2 -2
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
@@ -4,4 +4,4 @@ export type Theme = {
coverImage: string; coverImage: string;
marqueeImage: string; marqueeImage: string;
id: string; id: string;
}; };
+25 -25
View File
@@ -1,36 +1,36 @@
import type { Subscriber, Unsubscriber } from "svelte/store"; import type { Subscriber, Unsubscriber } from "svelte/store";
export class Standalone { export class Standalone {
private static instance: Standalone; private static instance: Standalone;
private _standalone = $state(false); private _standalone = $state(false);
private subscribers = new Set<Subscriber<boolean>>(); private subscribers = new Set<Subscriber<boolean>>();
private constructor() {} private constructor() {}
public static getInstance(): Standalone { public static getInstance(): Standalone {
if (!Standalone.instance) { if (!Standalone.instance) {
Standalone.instance = new Standalone(); Standalone.instance = new Standalone();
} }
return Standalone.instance; return Standalone.instance;
} }
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() {
return this._standalone; return this._standalone;
} }
public subscribe(run: Subscriber<boolean>): Unsubscriber { public subscribe(run: Subscriber<boolean>): Unsubscriber {
this.subscribers.add(run); this.subscribers.add(run);
run(this._standalone); run(this._standalone);
return () => { return () => {
this.subscribers.delete(run); this.subscribers.delete(run);
}; };
} }
} }
export const standalone = Standalone.getInstance(); export const standalone = Standalone.getInstance();
+32 -12
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,35 +34,47 @@ 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);
}); });
} }
return Promise.resolve(theme); return Promise.resolve(theme);
} }
+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");
+61 -39
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", {
response, type: "reactFiberResponse",
messageId, response,
}, "*"); messageId,
},
"*",
);
} else if (event.data.type === "triggerKeyboardEvent") {
// Handle keyboard event triggering from content script
const { key, code, altKey, ctrlKey, metaKey, shiftKey, keyCode } = event.data;
const keyboardEvent = new KeyboardEvent('keydown', {
key,
code,
keyCode: keyCode || 0,
which: keyCode || 0,
altKey: altKey || false,
ctrlKey: ctrlKey || false,
metaKey: metaKey || false,
shiftKey: shiftKey || false,
bubbles: true,
cancelable: true
});
document.dispatchEvent(keyboardEvent);
} }
}); });
@@ -1,7 +1,11 @@
import { BasePlugin } from '../../core/settings'; import { 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,
@@ -34,7 +38,7 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
// Create the background elements // Create the background elements
const container = document.getElementById("container"); const container = document.getElementById("container");
const menu = document.getElementById("menu"); const menu = document.getElementById("menu");
if (!container || !menu) { if (!container || !menu) {
return () => {}; return () => {};
} }
@@ -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,24 +59,27 @@ 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`;
}); });
} }
export default animatedBackgroundPlugin; export default animatedBackgroundPlugin;
@@ -28,4 +28,4 @@
100% { 100% {
transform: translateX(5%) rotate(-60deg); transform: translateX(5%) rotate(-60deg);
} }
} }
@@ -7,18 +7,18 @@ export function CreateBackground() {
// Creating and inserting 3 divs containing the background applied to the pages // Creating and inserting 3 divs containing the background applied to the pages
const container = document.getElementById("container"); const container = document.getElementById("container");
const menu = document.getElementById("menu"); const menu = document.getElementById("menu");
if (!container || !menu) return; if (!container || !menu) return;
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);
}); });
} }
@@ -1,6 +1,6 @@
export function RemoveBackground() { 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 metaClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__meta___');
const simpleResultClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__simpleResult___'); const metaContainerClass = getClassByPattern(
const titleClass = getClassByPattern(sampleAssessmentItem, 'AssessmentItem__title___'); sampleAssessmentItem,
"AssessmentItem__metaContainer___",
);
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[v] = k; (acc, [k, v]) => {
return acc; acc[v] = k;
}, {} as Record<number, string>); return acc;
},
{} 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) => {
isCalculating = true;
try { try {
if ( const calcResult = calculateExpression(input);
!input.trim() ||
(input.trim().length <= 2 && !/\d/.test(input))
) {
result = null;
inputUnit = '';
outputUnit = '';
dispatch('hasResult', null);
return;
}
isCalculating = true; if (calcResult.isValid) {
result = calcResult.result;
// Let mathjs handle everything inputUnit = calcResult.inputUnit;
const evaluated = math.evaluate(input.replace('**', '^')); outputUnit = calcResult.outputUnit;
isPartial = calcResult.isPartial;
// Format the result dispatch('hasResult', calcResult.result);
if (evaluated !== undefined) {
if (math.typeOf(evaluated) === 'Unit') {
// Handle unit conversion results
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
inputUnit = detectUnit(input);
outputUnit = detectUnit(result);
} else if (typeof evaluated === 'number') {
// Handle regular numbers
if (math.round(evaluated) === evaluated) {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
} else {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
}
inputUnit = '';
outputUnit = '';
} else {
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
inputUnit = '';
outputUnit = '';
}
dispatch('hasResult', result);
} 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
}>(); }>();
// Make searchHotkey reactive to setting changes
let currentSearchHotkey = $state(initialSearchHotkey);
let commandsFuse = $state<Fuse<StaticCommandItem>>(); let commandsFuse = $state<Fuse<StaticCommandItem>>();
let dynamicContentFuse = $state<Fuse<HydratedIndexItem>>(); let dynamicContentFuse = $state<Fuse<IndexItem>>();
const dynamicIdToItemMap = $state(new Map<string, HydratedIndexItem>()); 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,40 +381,28 @@
</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"> <div class="flex gap-4 items-center">
{#if !calculatorResult} {@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})}
{#if selectedIndex >= 0 && selectedIndex < combinedResults.length} {#if calculatorResult && selectedIndex === 0}
{@const item = combinedResults[selectedIndex].item} {@render Shortcut({ text: 'Copy result', keybind: ['↵']})}
{#if 'keybind' in item && item.keybind} {:else}
{@render Shortcut({ text: 'Shortcut', keybind: [ ...(item?.keybindLabel ?? []) ] })} {@render Shortcut({ text: 'Select', keybind: ['↵']})}
{/if}
{/if}
{/if} {/if}
</div> </div>
<div> {#if isIndexing}
<div class="flex gap-4 items-center"> <div class="inset-x-0 top-0">
{@render Shortcut({ text: 'Navigate', keybind: ['↑', '↓']})} <div class="absolute right-2 -bottom-4 text-[10px] text-zinc-500 dark:text-zinc-400">
{#if calculatorResult && selectedIndex === 0} Indexing
{@render Shortcut({ text: 'Copy result', keybind: ['↵']})}
{:else}
{@render Shortcut({ text: 'Select', keybind: ['↵']})}
{/if}
</div>
{#if isIndexing}
<div class="inset-x-0 top-0">
<div class="absolute right-2 -bottom-4 text-[10px] text-zinc-500 dark:text-zinc-400">
Indexing
</div>
<div class="overflow-hidden h-0.5 bg-zinc-200 dark:bg-zinc-700">
<div
class="h-full bg-blue-500 transition-all duration-300 ease-out"
style="width: {(completedJobs / totalJobs) * 100}%"
></div>
</div>
</div> </div>
{/if} <div class="overflow-hidden h-0.5 bg-zinc-200 dark:bg-zinc-700">
</div> <div
class="h-full bg-blue-500 transition-all duration-300 ease-out"
style="width: {(completedJobs / totalJobs) * 100}%"
></div>
</div>
</div>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -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";
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"> const updateSearchButtonDisplay = () => {
<circle cx="11" cy="11" r="8"></circle> searchButton.innerHTML = /* html */ `
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <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> <circle cx="11" cy="11" r="8"></circle>
<p>Quick search...</p> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">K</span> </svg>
`; <p>Quick search...</p>
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">${hotkeyDisplay}</span>
`;
};
updateSearchButtonDisplay();
titleElement.appendChild(searchButton); 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(); unmount(appRef.current);
if (searchRoot) searchRoot.remove(); appRef.current = null;
} catch (error) {
console.error("Error unmounting Svelte component:", error);
}
}
// Clean up workers // 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(); VectorWorkerManager.getInstance().terminate();
unmount(appRef.current);
// 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) { for (const item of items) {
console.warn(`Render component not found for job ${jobId} (ID: ${job.renderComponentId})`); if (
} item &&
item.id &&
for (const item of items) { item.text &&
// Ensure item has all required fields before pushing item.category &&
if (item && item.id && item.text && item.category && item.actionId && job.renderComponentId) { item.actionId &&
all.push({ job.renderComponentId // job might not be defined if store exists but job was removed
...item, ) {
renderComponent: renderComponent || undefined, // Assign undefined if not found all.push(item);
}); } else {
} else { console.warn(`Skipping invalid item from job store ${jobId}:`, item);
console.warn(`Skipping invalid item from job ${jobId}:`, item); }
}
}
} catch (error) {
console.error(`Error loading items for job ${jobId}:`, error);
} }
} catch (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");
@@ -169,116 +250,164 @@ 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();
console.debug(
`%c[Indexer] Sending ${allItemsFromJobs.length} items to worker for vectorization...`,
"color: #4ea1ff",
);
dispatchProgress(completedJobs, totalSteps, true, "Starting vectorization");
try { if (allItemsInPrimaryStores.length > 0) {
const workerManager = VectorWorkerManager.getInstance(); console.debug(
// Pass a progress callback to the worker manager `%c[Indexer] Sending ${allItemsInPrimaryStores.length} items from primary stores to worker for vectorization check...`,
await workerManager.processItems(allItemsFromJobs, (progress) => { "color: #4ea1ff",
// Update overall progress based on worker feedback );
let detailMessage = progress.message || ''; dispatchProgress(completedJobs, totalSteps, true, "Starting vectorization of stored items");
if (progress.status === 'processing' && progress.total && progress.processed !== undefined) {
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`; try {
// You could potentially update the 'completed' count more granularly here const workerManager = VectorWorkerManager.getInstance();
// For simplicity, we'll just update the detail message await workerManager.processItems(allItemsInPrimaryStores, (progress) => {
} else if (progress.status === 'complete') { let detailMessage = progress.message || "";
detailMessage = "Vectorization complete"; if (
// Mark the vectorization step as complete progress.status === "processing" &&
dispatchProgress(totalSteps, totalSteps, true, "Vectorization finished"); progress.total &&
} else if (progress.status === 'error') { progress.processed !== undefined
detailMessage = `Vectorization error: ${progress.message}`; ) {
dispatchProgress(completedJobs, totalSteps, true, "Vectorization failed", detailMessage); // Show error detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
} else if (progress.status === 'started') { } else if (progress.status === "complete") {
detailMessage = `Vectorization started for ${progress.total} items`; detailMessage = "Vectorization complete";
} else if (progress.status === 'cancelled') { completedJobs++;
detailMessage = `Vectorization cancelled: ${progress.message}`; dispatchProgress(
dispatchProgress(completedJobs, totalSteps, true, "Vectorization cancelled", detailMessage); completedJobs,
totalSteps,
false,
"Indexing finished",
detailMessage
);
} else if (progress.status === "error") {
detailMessage = `Vectorization error: ${progress.message}`;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization failed",
detailMessage,
);
} else if (progress.status === "started") {
detailMessage = `Vectorization started for ${progress.total} items`;
} else if (progress.status === "cancelled") {
detailMessage = `Vectorization cancelled: ${progress.message}`;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization cancelled",
detailMessage,
);
} }
// 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 for stored items sent to worker.",
console.debug("%c[Indexer] Vectorization task sent to worker.", "color: green"); "color: green",
// Note: runIndexing might return *before* vectorization is complete now. );
// The progress updates will signal the true end state. } catch (error) {
} catch (error) { console.error(
console.error(`%c[Indexer] ❌ Failed to send items to vector worker:`, "color: red", error); `%c[Indexer] ❌ Failed to send items to vector worker:`,
dispatchProgress(completedJobs, totalSteps, false, "Vectorization failed", String(error)); // Stop indexing indicator "color: red",
error,
);
dispatchProgress(
completedJobs,
totalSteps,
false,
"Vectorization failed",
String(error),
);
}
} else {
console.debug(
"%c[Indexer] No items found in primary stores to send for vectorization.",
"color: gray",
);
completedJobs++;
dispatchProgress(
completedJobs,
totalSteps,
false,
"Indexing finished (no items for vectorization)",
);
} }
} else { } 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] Skipping bulk vectorization - streaming jobs will handle vectorization",
completedJobs++; // Count the "skipped" vectorization step "color: #4ea1ff",
dispatchProgress(completedJobs, totalSteps, false, "Indexing finished (no vectorization needed)"); );
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);
} }
for (const item of incoming) { for (const item of incoming) {
if (item && item.id) map.set(item.id, item); if (item && item.id) map.set(item.id, item);
} }
return Array.from(map.values()); return Array.from(map.values());
} }
@@ -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;
}

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