1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2026-02-06 23:32:58 +00:00

430 Commits
0.1.0 ... 0.5.1

Author SHA1 Message Date
f3d80b58b1 Merge pull request #130 from koloml/release/0.5.1
Release: 0.5.1
2025-08-13 18:23:14 +04:00
d567ab4dec Merge pull request #133 from koloml/feature/derpibooru-smaller-tags
Derpibooru: Making tags slightly smaller in height to fit the styling used by the site
2025-08-13 17:55:39 +04:00
e4322b3021 Derpibooru: Making tags slightly smaller inside popup 2025-08-13 17:54:40 +04:00
4907efdaab Bumped version to 0.5.1 2025-08-13 17:50:31 +04:00
c6b9250d71 Merge pull request #132 from koloml/feature/add-to-profile-icon
Derpibooru: Added icon to the tag dropdown option
2025-08-13 17:27:05 +04:00
c330aa303a Derpibooru: Added icon to the tag dropdown option 2025-08-13 17:24:36 +04:00
9ed3f6939d Merge pull request #129 from koloml/bugfix/derpibooru-tag-editor-styling
Fixed tag categories headlines having inconsistent spacing between Derpibooru and Furbooru
2025-08-13 16:52:39 +04:00
5584733b17 Merge pull request #131 from koloml/bugfix/inconsistent-auto-run
Firefox: Fixed content scripts randomly loading asynchronously and not auto-running
2025-08-13 16:52:23 +04:00
91947b8cc7 Merge remote-tracking branch 'origin/release/0.5.1' into bugfix/inconsistent-auto-run
# Conflicts:
#	src/content/deps/amd.ts
2025-08-13 16:49:18 +04:00
df61c812fe Updated autorun logic to resolve issues with loading modules on Firefox
Sometimes Firefox decides to load different groups of content scripts
asynchronously, causing our trick with `requestAnimationFrame` to miss
everything. To prevent this, I decided to just attempt to autorun
everything on each definition using `setTimeout`.

I've also tried to use `queueMicrotask` to put autorun logic right
between different groups of modules, but this trick was only working on
Firefox and completely breaking on Chromium. I sure love browsers!
2025-08-13 16:48:27 +04:00
65c420c36c Merge pull request #128 from koloml/bugfix/ignore-duplicated-modules
Firefox: Fixed an error message appearing when single chunk is trying to execute multiple times
2025-08-13 16:42:16 +04:00
79cd9bc44d Reduced the space used by the tag category headline
This is mainly affecting the Derpibooru version of the extension. Tags
list on Derpibooru is using flex with gaps instead of flex with margins
appearing like gaps (what currently Furbooru uses). This change would
likely be applied to the Furbooru as well.
2025-08-13 15:56:31 +04:00
cf28d2d131 AMD Loader: Ignore duplicated module definitions
This fixes an error appearing when chunk is mention multiple times for
different entry content scripts.
2025-08-13 15:27:25 +04:00
50238d8ef4 Added links to the Derpibooru extension 2025-08-13 14:56:44 +04:00
98b5311cfc Derpibooru: Added screenshot about tag colors in editor 2025-08-12 13:58:34 +04:00
e60d20fd60 Added showcase screenshots for Derpibooru 2025-08-11 09:11:52 +04:00
50a6d8ce32 Merge pull request #120 from koloml/release/0.5
Release: 0.5
2025-08-09 16:05:02 +04:00
7b532735ec Bumped version to 0.5.0 2025-08-09 16:01:23 +04:00
f139b76276 Merge pull request #127 from koloml/feature/multiple-sites-build
Support building the extension for both Derpibooru and Furbooru
2025-08-09 15:58:07 +04:00
eba81b72d4 Derpibooru: Added border to tags, added border colors variables 2025-08-09 15:51:33 +04:00
5b93eb413b Derpibooru: Different list of black-listed tags 2025-08-09 15:35:36 +04:00
81247c1761 Updating README with instructions and showcases
This extension will now be built and released for both Furbooru and
Derpibooru as 2 separate extensions.
2025-08-09 15:25:15 +04:00
8d042a80a0 Derpibooru: Fixed incorrect padding on media box tags 2025-08-09 01:23:06 +04:00
9c162bc2ce Fixed defines not being correctly passed to content scripts & styles 2025-08-09 01:21:02 +04:00
0deafb4a00 Warn user of potentially different/unknown site imports 2025-08-08 20:41:55 +04:00
8822a2581b Store last same site status of the imported object 2025-08-08 20:40:57 +04:00
83d27cc966 Add $site property to identify what site entity was created for 2025-08-08 19:47:44 +04:00
ae3c77031f Added modifications to manifest for Derpibooru variant of the extension 2025-08-08 19:41:23 +04:00
f39e060a6a Updating manifest description, updated About page in popup 2025-08-08 19:40:48 +04:00
c811c13b70 Use site name constant in header component 2025-08-08 19:40:13 +04:00
234f80b992 Added defined constants to typedefinition for TypeScript 2025-08-08 19:39:48 +04:00
4837184d40 Reading constants in SCSS, modifying colors for Derpibooru variant 2025-08-08 19:39:30 +04:00
c37f680e9f Provide current site name & identifier for build-time modifications 2025-08-08 19:38:52 +04:00
2eefbf96ca Implemented plugin to provide constants to SCSS through custom function 2025-08-08 19:37:48 +04:00
efd6522532 Implemented plugin for swaping defined build-time constants 2025-08-08 19:37:12 +04:00
b321c1049c Installed cross-env to support quickly adding ENV variables to builder 2025-08-08 19:36:19 +04:00
ce9a2b5f9b Fixed typo in content script entry 2025-08-08 06:05:13 +04:00
8fe1ca4914 Merge pull request #98 from koloml/feature/bulk-import-and-export
Bulk import/export of tagging profiles and groups
2025-08-04 13:46:03 +04:00
48f278ae95 Added TODO about missing "migration" progress for entities 2025-08-04 13:40:19 +04:00
19ab302b54 Implemented the bulk import interface 2025-08-04 13:39:48 +04:00
189fda59c8 Added separate event listener endpoint for menu item clicks 2025-08-04 13:39:06 +04:00
6bd7116df2 Added validation logic for the group entity 2025-08-04 13:32:35 +04:00
470021ee8c Validators: Using functions for common value checks 2025-08-04 13:32:19 +04:00
e27257516d Validator: Support up to version 2 of profiles entity 2025-07-27 21:44:52 +04:00
77293ba30c Fixed "Export All" checkbox toggling incorrect items 2025-07-27 19:58:19 +04:00
b956b6f7bc Added separate menu for bulk exporting of all entities 2025-07-27 19:02:19 +04:00
fcca26e128 Make checkbox menu item toggle checkbox if no link is set 2025-07-27 19:00:20 +04:00
69dc645de2 Getter for the type of the entity 2025-07-27 18:27:19 +04:00
0781742dab Merge remote-tracking branch 'origin/master' into feature/bulk-import-and-export 2025-07-12 19:24:49 +04:00
71b067a77d Merge branch 'master' into release/0.5 2025-06-25 18:42:44 +04:00
6098a11115 Bumped version to 0.4.5
Woops, forgot to bump the version.
2025-06-03 13:48:49 +04:00
a87d8b94b8 Merge pull request #125 from koloml/release/0.4.5
Release: 0.4.5
2025-06-03 13:46:57 +04:00
c283b96285 Updating dependencies (#126)
* Updated `sass` from 1.86.3 to 1.89.1

* Updated `svelte` from 5.25.6 to 5.33.14

* Updated `svelte-check` from 4.1.5 to 4.2.1

* Updated `@sveltejs/kit` from 2.20.3 to 2.21.1

* Removed `@sveltejs/adapter-auto`

Looks like it was left over from initial commit. We're using static
adapter.

* Updated `vite` from 6.2.5 to 6.3.5

* Updated `vitest` from 3.1.1 to 3.2.0

* Updated `@vitest/coverage-v8` from 3.1.1 to 3.2.0

* Updated `typescript` from 5.8.2 to 5.8.3

* Updated `jsdom` from 26.0.0 to 26.1.0

* Updated `@types/node` from 22.14.0 to 22.15.29

* Updated `@types/chrome` from 0.0.313 to 0.0.326
2025-06-03 13:45:17 +04:00
02478f0bf0 Merge pull request #124 from koloml/bugfix/popup-header-z-index
Fixed header in popup having wrong z-index
2025-06-03 02:41:23 +04:00
59c15f27eb Merge pull request #123 from koloml/feature/quick-query-untagged-implications
Tag Page: Added link for quick search of untagged implications
2025-06-03 02:41:01 +04:00
a58d8f0e15 Merge pull request #122 from koloml/feature/removing-properties-suggestions
Properties Suggestions: Removing feature since it'll be integrated into Philomena
2025-06-03 02:29:53 +04:00
2453bdf7b9 Removing the properties suggestions feature
This feature is moved directly into Philomena. This implementation is
now outdated and depends on the previous version of suggestions popup.
2025-06-03 02:24:20 +04:00
134e96bc4c Added link for a quick search of untagged implications 2025-06-03 02:14:00 +04:00
1c05159ddf Fixed popup's header z-index position appearing behind some elements 2025-04-15 23:50:02 +04:00
39c3f97846 Merge pull request #121 from koloml/feature/minify-amd-exports-in-content-scripts
Content Scripts: Enabled minification of exported properties
2025-04-09 01:01:45 +04:00
9c19bd70c2 Enable minification of exports for content scripts 2025-04-09 00:48:40 +04:00
d158b46dc6 Merge pull request #119 from koloml/feature/amd-loaded-content-scripts
Reworked build step for content scripts & styles to use AMD as module system
2025-04-06 15:55:24 +04:00
966100d606 Added AMD loader initialization as separate entry, autoload everything 2025-04-06 15:13:31 +04:00
7cf2730402 Reworked build step for the content scripts
Main changes:

- Scripts are now built in 2 steps instead of building every script and
style one at a time;
- Scripts are built as AMD modules;
- Dependencies are automatically injected into resulting manifest.json
file.
2025-04-06 15:12:12 +04:00
bdbe49b419 Installed amd-lite 1.0.1 2025-04-06 15:08:14 +04:00
ed779a8481 Merge remote-tracking branch 'origin/master' into feature/bulk-import-and-export
# Conflicts:
#	src/lib/extension/transporting/exporters.ts
2025-04-04 18:25:27 +04:00
bb14492578 Merge pull request #116 from koloml/release/0.4.4
Release: 0.4.4
2025-04-04 14:38:37 +04:00
30320e7283 Bumped version to 0.4.4 2025-04-04 14:37:20 +04:00
8839373292 Updating dependencies (#118)
* Updated `vite` to 6.2.5

* Updated `sass` to 1.86.3

* Updated `svelte` to 5.25.6

* Updated `@sveltejs/kit` to 2.20.3

* Updated `vitest` to 3.1.1

* Updated `@vitest/coverage-v8` to 3.1.1

* Updated `@sveltejs/adapter-auto` to 6.0.0

* Updated `@types/chrome` to 0.0.313

* Updated `@types/node` to 22.14.0
2025-04-04 14:35:54 +04:00
0e35d1d0ba Merge pull request #117 from koloml/feature/name-events-as-constants
Changing the naming of custom events to be different from the usual variables
2025-04-04 14:15:45 +04:00
bca21da6d1 Merge remote-tracking branch 'origin/release/0.4.4' into feature/name-events-as-constants
# Conflicts:
#	src/lib/components/TagsListBlock.ts
2025-04-04 14:15:02 +04:00
60491f57d4 Merge pull request #115 from koloml/feature/grouping-button
Tag Groups: Added button to the tags list component to quckly toggle the sepeartion on and off
2025-03-26 21:06:25 +04:00
c26c4bcf62 Merge pull request #114 from koloml/bugfix/missing-re-initialization-for-tags-list
Tags List: Fixed re-initialization of the component after tags were submitted
2025-03-26 21:06:15 +04:00
1b4b646024 Merge pull request #113 from koloml/bugfix/last-mediabox-position
Fixed last media box on the page showing its popup outside of the viewport
2025-03-26 21:05:29 +04:00
928fe5ddb0 Removed unnecessary import 2025-03-26 20:46:51 +04:00
6586141134 Fixed missed re-initialization of tags list after tag form was submitted 2025-03-26 20:43:19 +04:00
d587bd2453 Added button to the tags list to toggle separation of groups 2025-03-26 20:39:30 +04:00
e2eb8a0ca7 Fixed last media box on the page not being marked as the last in a row 2025-03-26 20:03:04 +04:00
0876e5f001 Changed naming for event name constants to differentiate them with variables 2025-03-26 19:01:18 +04:00
d5ad66d902 Merge pull request #112 from koloml/release/0.4.3.1
Release: 0.4.3.1
2025-03-12 19:34:05 +04:00
cb6b5f4f9d Bumped version to 0.4.3.1 2025-03-12 19:32:27 +04:00
193941b0ac Merge pull request #111 from koloml/hotfix/tag-groups-not-applying-for-tag-editor
Fixed tag group colors & grouping not applying in Firefox
2025-03-12 19:31:52 +04:00
562274b3d8 Fixed Firefox not applying tag groups due to invalid scripts order 2025-03-12 19:27:24 +04:00
6faf5c8582 Merge pull request #105 from koloml/release/0.4.3
Release: 0.4.3
2025-03-12 18:55:40 +04:00
e591751406 Bumped version to 0.4.3 2025-03-12 18:48:50 +04:00
c9347c375d Merge pull request #110 from koloml/bugfix/group-editor-back-link
Tag Groups: Fixed incorrect path on the "Back" link for group editor view
2025-03-12 18:46:50 +04:00
68e134f2e4 Updated dependencies (#109)
* Updated `@sveltejs/kit` to 2.19.0

* Updated `svelte` to 5.23.0

* Updated `typescript` to 5.8.2

* Updated `svelte-check` to 4.1.5

* Updated `sass` to 1.85.1

* Updated `@types/chrome` to 0.0.309

* Updated `vite` to 6.1.1

* Updated `vitest` to 3.0.8

* Updated `@vitest/coverage-v8` to 3.0.8

* Updated `@types/node` to 22.13.10
2025-03-12 18:45:21 +04:00
338eb2bbb1 Fixed incorrect path on the "Back" link for group editor 2025-03-12 18:43:41 +04:00
2933cd379e Merge pull request #108 from koloml/feature/option-to-display-groups-separately
Tag Groups: Added option to display the tags captured by the group in the separate list
2025-03-10 23:58:56 +04:00
8fe2d718ff Default global group separation to turned on 2025-03-10 06:49:41 +04:00
b1ca67fc5b Implemented grouping of tags marked for separation in settings 2025-03-10 06:48:56 +04:00
37095a2f22 Fixed instances not resolving on different content scripts 2025-03-10 06:08:08 +04:00
c1ed23dee5 Added event for resolved categories, coloring from the tag wrapper 2025-03-10 06:07:36 +04:00
8c51d2d482 Store references to the tag group instead of category name 2025-03-10 03:39:43 +04:00
16b72300a9 Added option to separate the specific group of choice in tags 2025-03-10 03:24:23 +04:00
11af0f6484 Fixed category missing in the export for groups 2025-03-10 03:22:55 +04:00
4f302faf45 Added option to turn on/off separation of tags by custom category 2025-03-10 03:18:03 +04:00
bedb18a6aa Merge pull request #107 from koloml/feature/groups-suffix
Tag Groups: Support matching custom categories by suffix
2025-03-09 03:58:19 +04:00
ea791838bf Display stars in the tag editor for prefixes/suffixes 2025-03-02 18:55:51 +04:00
ff16c62e26 Added support for suffix-matching for groups 2025-03-02 18:44:53 +04:00
45cc5b0eb3 Merge pull request #106 from koloml/feature/workaronud-for-opening-in-new-tab
Fixed popup links being unusable when opened in new tab
2025-02-28 03:57:38 +04:00
a2d884c969 Added tests for the link replacement logic 2025-02-28 03:50:08 +04:00
74f987b5c9 Merge remote-tracking branch 'refs/remotes/origin/release/0.4.3' into feature/workaronud-for-opening-in-new-tab 2025-02-28 03:28:13 +04:00
f687389516 Implemented routing to be more compatible for extension popup 2025-02-28 03:18:18 +04:00
92854f4d6b Renamed hooks to TS 2025-02-28 02:40:19 +04:00
4ca9ff029b Merge pull request #104 from koloml/feature/testing-configuration-controller
Added tests for ConfigurationController class
2025-02-28 02:01:00 +04:00
70e573ddc8 Merge pull request #103 from koloml/bugfix/fixing-type-errors
Fixed type errors reported by the TypeScript
2025-02-28 02:00:30 +04:00
8e843c2b19 Fixed element types not being set up for queries 2025-02-27 00:54:00 +04:00
76e7bf1542 Fixed missing empty checks for required components 2025-02-27 00:53:44 +04:00
d5ed86fb40 Exposing timer return type globally 2025-02-27 00:50:05 +04:00
dc0a9f0aa8 Imported utils function for random string 2025-02-25 03:39:30 +04:00
09edc44af8 Added tests for configuration controller 2025-02-25 03:38:49 +04:00
a9d53afdbe Mocked storage change events for mocked storage area 2025-02-25 03:20:43 +04:00
ed263d2da4 Installed types for NodeJS for testing 2025-02-25 03:19:49 +04:00
9586d121e4 Moved storage definition to constructor for testability 2025-02-25 03:19:22 +04:00
92afd10b81 Merge pull request #92 from koloml/release/0.4.2
Release: 0.4.2
2025-02-22 19:48:40 -05:00
cf8be2589d Bumped version to 0.4.2 2025-02-23 04:30:41 +04:00
dbe164b444 Merge pull request #101 from koloml/bugfix/detect-and-init-tags-and-tags-form
Fixed custom tags categories not being reapplied to the tags form
2025-02-22 19:26:16 -05:00
5613c6fdca Wait for new element to be inserted and notify tags dropdown about it 2025-02-23 04:22:29 +04:00
dfab625999 Added event returned from booru on form submissions 2025-02-23 03:38:06 +04:00
b25758c294 Merge pull request #100 from koloml/feature/converting-content-components-to-ts
Converting all content scripts components to TypeScript, adding minor value checks and bug fixes found during conversion
2025-02-22 10:40:59 -05:00
45a8c436be Added tests for the base component class 2025-02-21 22:03:35 +04:00
7aabb683cf Making component not abstract to run tests on it 2025-02-21 22:03:20 +04:00
c4a904c046 Converting content script components to TS 2025-02-21 20:54:56 +04:00
3a010f9303 Renaming all content scripts to TS 2025-02-21 03:04:11 +04:00
878f3eb878 Merge pull request #99 from koloml/bugfix/dedupe-test-runs-on-release-prs
Fixed tests running twice on release PRs
2025-02-20 15:58:31 -05:00
b9165302e7 Removed double-testing for release PRs 2025-02-21 00:55:19 +04:00
6c02c14f5c Merge pull request #96 from koloml/feature/vitest
Added Vitest for testing the codebase
2025-02-20 15:52:23 -05:00
8b2e0722f0 Building the extension first since testing depends on tsconfig provided by SvelteKit 2025-02-21 00:49:58 +04:00
840202396e Merge branch 'bugfix/storage-helper-falsy-values' into feature/vitest 2025-02-21 00:36:27 +04:00
f6504eeecf Merge pull request #97 from koloml/bugfix/storage-helper-falsy-values
StorageHelper: Fixed helper treating falsy values as unset values
2025-02-20 15:31:59 -05:00
faaef1305a Fixed storage helper treating falsy values as non-existing values 2025-02-21 00:28:39 +04:00
91c44adbd3 Added workflow for GitHub CI 2025-02-21 00:25:31 +04:00
68b68d3efd Added initial implementation of mocks for chrome StorageArea, adding first tests for storage helper 2025-02-20 23:52:26 +04:00
769a63ccff Added alias $tests for test directory 2025-02-20 23:49:42 +04:00
9d097436a5 Added chrome types to autoload 2025-02-20 23:49:26 +04:00
6a34822f6a Incorrect include for tests, only test lib directory, import vitest globals 2025-02-20 22:27:04 +04:00
ab5a6daa07 Installing vitest with jsdom, setting up configuration 2025-02-20 22:16:28 +04:00
0bc4379491 Merge pull request #95 from koloml/feature/booru-api-ts
Converted classes related to parsing/submitting tags to TypeScript
2025-02-20 12:58:35 -05:00
3f245bb621 Converting classes to TS 2025-02-20 21:47:09 +04:00
a858888252 Renamed classes to TS 2025-02-20 21:38:10 +04:00
9d7f5c0f38 Initial implementation of bulk transporter with import methods 2025-02-20 20:36:34 +04:00
f67a321a66 Extracted method to import directly from object 2025-02-20 20:32:18 +04:00
5dc41700b8 Introducing lists of importable entities, renaming some interfaces 2025-02-19 03:42:20 +04:00
c93c3c7bd5 Swapped validator arguments to meet the order on exporter function, fixing types 2025-02-19 03:19:01 +04:00
459b1fa779 Marking entity name as key of the mapping for type safety 2025-02-19 03:17:52 +04:00
62dc38b35a Fixed exporters not saving categories for groups and temporary flags for profiles 2025-02-19 03:08:54 +04:00
07373e17d5 Updated exporters to use importable types for more type safety 2025-02-19 03:07:52 +04:00
371bce133e Moved base types used in validators to separate module 2025-02-19 03:07:03 +04:00
898c82daf5 Merge pull request #94 from koloml/bugfix/fullscreen-viewer-quality-selector-bg
Fullscreen Viewer: Fixed size selector not having backgronud color making it difficult to read with certain images
2025-02-18 15:57:53 -05:00
fbecc5ccd5 Fixed background color of image size selector to not be transparent 2025-02-18 20:41:52 +04:00
48767d47f9 Merge pull request #93 from koloml/feature/migrate-svelte-to-v5-syntax
Migration to Svelte 5 syntax
2025-02-17 18:25:54 -05:00
c859c5b038 Refactored tag editor element 2025-02-18 02:09:41 +04:00
12e9054f59 Refactoring form components 2025-02-18 02:00:48 +04:00
23638b50ae Refactored profiles related routes & components 2025-02-18 01:39:11 +04:00
b31541ddf0 Fixed error being displayed incorrectly 2025-02-18 01:29:35 +04:00
62857d44c2 Making sure checkbox input is clickable without following the link 2025-02-18 01:17:43 +04:00
dff77391b0 Making sure radio input is clickable without following the link 2025-02-18 01:14:44 +04:00
5a07fa3d60 Refactored menu & menu items
Once again, breaking change for a lot of components, would be tackled separately
2025-02-17 04:51:15 +04:00
49986ec497 Refactored groups-related routes and components 2025-02-17 04:30:45 +04:00
9f08eb2171 Refactored MenuItem
This change will break a lot of menu items with `on:click`. These cases would be resolved separately.
2025-02-17 03:46:29 +04:00
927fa21d95 Fixed the typo in the back link 2025-02-17 03:44:48 +04:00
620af3e622 Refactoring storage viewer and its route 2025-02-17 03:06:39 +04:00
efdd9487ad Reformatting all svelte templates to use new rules 2025-02-17 02:07:55 +04:00
762652f795 Added specific rules for the svelte components formatting 2025-02-17 02:07:40 +04:00
67d41ecf03 Migration to Svelte 5 (using the migration script) 2025-02-16 18:02:03 +04:00
5975584905 Merge pull request #91 from koloml/feature/ts-storages
Converting storages to TypeScript, minor restructuring of the storage folder
2025-02-16 08:38:18 -05:00
566211d046 Merge pull request #90 from koloml/bugfix/storage-inspector-null
Debugging: Fixed null value being displayed incorrectly in storage inspector
2025-02-16 08:33:48 -05:00
16f60ef9b5 Moving and renaming entities storages to entities dir 2025-02-16 16:19:25 +04:00
09e912ffff Moving all preferences-related stores to preferences dir 2025-02-16 16:12:48 +04:00
8a3ef6b049 Converting tag groups store to TS 2025-02-16 16:09:16 +04:00
5392a17db5 Renaming tag groups store to TS 2025-02-16 16:08:34 +04:00
0b4ff96fc1 Adding type for suggestion position, converting store to TS 2025-02-16 16:07:52 +04:00
2104922951 Renaming search preferences store to TS 2025-02-16 16:05:02 +04:00
f27157a0c5 Converting misc preferences store to TS 2025-02-16 16:04:14 +04:00
729d0281ed Converting profiles store to TS 2025-02-16 16:03:24 +04:00
461fce5c05 Renaming profiles store to TS 2025-02-16 16:01:31 +04:00
d1a69437d1 Converting debug store to TS 2025-02-16 15:59:13 +04:00
062b04ca8a Renamed debug storage to TS 2025-02-16 15:50:37 +04:00
4d3023a641 Display nulls and undefined values in the storage inspector properly 2025-02-16 15:47:41 +04:00
f7dff0968e Merge pull request #86 from koloml/release/0.4.1
Release: 0.4.1
2025-02-15 18:58:27 -05:00
acacfe2027 Bumped version to 0.4.1 2025-02-16 03:55:22 +04:00
90e75f1e38 Bumping dependencies (#89)
* Updated `@fortawesome/fontawesome-free` to 6.7.2

* Updated `sass` to 1.85.0

* Updated `@sveltejs/kit` to 2.17.2

* Updated `svelte` to 5.20.1

* Updated `vite` to 6.1.0

* Updated `@types/chrome` to 0.0.304
2025-02-15 18:54:36 -05:00
805bb7543a Merge pull request #88 from koloml/feature/update-colors-for-content-styles
Updated maintenance popup styling to respect active site theme
2025-02-15 18:25:17 -05:00
b6a96abadf Fixed incorrect usage of media border variable 2025-02-16 03:22:37 +04:00
06b9fefe8f Updating the popup colors to respect active theme 2025-02-16 03:17:50 +04:00
ff79e0b7fc Renaming booru colors to vars, adding several new variables 2025-02-16 03:17:32 +04:00
a99e4d286a Merge pull request #85 from koloml/feature/converting-js-to-ts
Converting modules to TypeScript
2025-02-15 17:56:28 -05:00
26fae1dc4a Merge pull request #87 from koloml/bugfix/maintenance-popups-tags-appearance
Fixed tag in popups having broken appearance after themes updates for Philomena, updated how colors applied to tags
2025-02-15 17:53:31 -05:00
c834703781 Fixed content styles not respecting aliases 2025-02-16 02:48:08 +04:00
33e1948a22 Added site-managed vars as colors, removed override for category 2025-02-16 02:43:58 +04:00
1b324f2829 Added new site-defined vars for tag colors 2025-02-16 02:43:32 +04:00
22f158dda9 Fixed broken paddings 2025-02-16 02:37:20 +04:00
f5dd2f7711 Adding type annotations to the query lexer 2025-02-07 03:57:08 +04:00
d9affcf5a0 Renaming query lexer to TS 2025-02-07 03:47:45 +04:00
c6d75e2b2a Converting storage helper to TS, adding types for subscribe functions 2025-02-07 03:23:21 +04:00
7bb71807bc Renaming storage helper to TS 2025-02-07 03:08:09 +04:00
9be8db85a2 Converting config controller to TS 2025-02-07 03:03:22 +04:00
392513f375 Renaming config controller to TS 2025-02-07 02:45:40 +04:00
d504ce3b04 Converting the code to typescript, making validators more type-safe 2025-02-07 02:39:53 +04:00
08aa71c959 Renaming validators to TS 2025-02-07 01:58:36 +04:00
bda2756779 Annotating component utils with types 2025-02-06 23:57:19 +04:00
92a0efaace Annotating tag utils with types 2025-02-06 23:49:28 +04:00
01c08353f1 Renaming components & tags utils to TS 2025-02-06 23:47:45 +04:00
d6487bbc2b Renaming tag categories to TS 2025-02-06 23:41:00 +04:00
011139d191 Updating utils with type annotations from JSDoc 2025-02-06 23:40:25 +04:00
2eb824e54b Renaming utils to TS 2025-02-06 23:36:06 +04:00
d439a69aab Merge pull request #84 from koloml/feature/removing-js-ts-extensions
Removing extensions from imports for consistency, slightly changing the formatting settings for imports
2025-02-06 23:30:35 +04:00
b9a609a190 Removing extensions for JS and TS in imports, reformatting 2025-02-06 23:20:28 +04:00
2c2c2acf3e Merge pull request #83 from koloml/feature/typeified-custom-events
Adding separate methods for events dispatching/listening with better type safety
2025-02-06 22:46:19 +04:00
a8265e9baa Merge pull request #82 from koloml/feature/moving-components-around
Moving several components internally into proper directories
2025-02-06 22:16:32 +04:00
4ea0e11ec1 Implementing the unified functions for custom events with types 2025-02-06 15:43:52 +04:00
2561cd19c9 Making container getter public 2025-02-06 15:04:32 +04:00
1de6a89269 Moved ProfileView to features directory 2025-02-06 14:20:36 +04:00
2db2a20803 Moved TagsEditor to tags directory 2025-02-06 14:19:46 +04:00
c7919e0127 Merge pull request #76 from koloml/release/0.4.0
Release: 0.4
2025-01-17 03:54:44 +04:00
73ff913eb7 Merge pull request #77 from koloml/feature/updating-dependencies
Updated Svelte, Vite, SCSS and other internal tools, fixed related errors & warnings
2025-01-17 03:53:42 +04:00
dd312e170e Cover :is selectors into the :global as well 2025-01-17 03:46:58 +04:00
283629d64b Updated all @sveltejs/kit-related packages to latest versions 2025-01-17 03:39:13 +04:00
9af73f0598 Replaced @import with @use on popup stylesheets 2025-01-17 03:25:30 +04:00
bd85d165d3 Updated deprecated color adjustment methods 2025-01-17 03:22:27 +04:00
24e0937c6b Updated all @use calls to use the $styles alias 2025-01-17 02:59:29 +04:00
24cec58af5 Updated vite to 6.0.7 2025-01-17 02:55:02 +04:00
66f106364a Updated sass to 1.83.4 2025-01-17 02:54:02 +04:00
650c5714d0 Updated cheerio to 1.0.0 2025-01-17 02:49:51 +04:00
31e2993a12 Bumped version to 0.4.0 2025-01-17 02:45:23 +04:00
e8349ce9a3 Merge pull request #75 from koloml/feature/more-suggested-properties
Search Suggestions: Added category tags count & other missed properties described in the search docs
2025-01-13 22:05:27 +04:00
6b807db235 Merge pull request #74 from koloml/bugfix/incorrect-redirect
Fixed fallback redirect when visiting profile with ID "new"
2025-01-13 22:04:45 +04:00
9a8e3cc597 Added more documented searchable terms from Derpibooru docs 2025-01-13 21:50:20 +04:00
d2d02b06e4 Added search terms for category-based tag count 2025-01-13 21:45:21 +04:00
58b79531e4 Merge remote-tracking branch 'origin/release/0.4' into bugfix/incorrect-redirect
# Conflicts:
#	src/routes/features/maintenance/[id]/+page.svelte
2025-01-13 21:38:55 +04:00
927e4c157b Fixed incorrect fallback redirect for "new" profiles 2025-01-12 19:41:16 +04:00
5c534da875 Removing unused last index in category resolving (oops) 2025-01-05 01:36:05 +04:00
257c369b02 Merge pull request #73 from koloml/feature/update-active-profile-toggler
Tagging Profiles: Replaced simple link with checkbox for "Activate Profile" option
2025-01-05 01:32:01 +04:00
d559d16977 Replaced simple button with checkbox for profile activity 2025-01-05 01:18:36 +04:00
6cced5036c Bind checked values from MenuCheckboxItem to input 2025-01-05 01:17:51 +04:00
15e379c798 Merge pull request #72 from koloml/feature/auto-remove-temporary-profiles
Remove temporary profiles when last tag was removed through dropdown
2025-01-03 19:43:27 +04:00
a39099bb1e Merge remote-tracking branch 'origin/release/0.4' into feature/auto-remove-temporary-profiles
# Conflicts:
#	src/lib/components/TagDropdownWrapper.js
2025-01-03 19:35:58 +04:00
3af723e50d Merge pull request #70 from koloml/feature/fullscreen-controls
Fullscreen Viewer: Added image/video quality setting and more explicit "close" icon
2025-01-03 06:41:41 +04:00
3e028a1509 Merge pull request #69 from koloml/feature/tag-groups
Added new Tag Groups feature with ability to customize colors of any tag with specific category
2025-01-03 06:41:22 +04:00
ba10768496 Added visible close button 2025-01-03 06:28:09 +04:00
e5ffd59b9c Fixed missing gap between 2 form controls 2025-01-03 06:03:42 +04:00
9a73ad80dd Skip blocks if they're not filled up 2025-01-03 05:54:06 +04:00
cd10ad62f4 Implemented resolver for custom categories for all the tags 2025-01-03 05:14:06 +04:00
b7a829ff12 Added tag category, storing the original category in the component class 2025-01-03 05:13:29 +04:00
e06359a24a Export the tag dropdown class, make tag name getter public 2025-01-03 03:43:02 +04:00
bb2065cf07 Added escaping utility function for RegExp 2025-01-03 03:42:03 +04:00
309dd15598 Keep the current URIs and switch them when size is changed 2024-12-30 23:08:06 +04:00
757526ab52 Fixed fullscreen button hiding itself on settings update 2024-12-30 22:57:43 +04:00
112d60ac78 Fixed selector appearing behind the media and not being accessible 2024-12-30 22:52:48 +04:00
3d1e0d6f06 Updated viewer to receive all the image URLs and apply selected size 2024-12-30 22:43:49 +04:00
98d3b1c696 Prevent viewer from closing when selecting sizes 2024-12-30 22:40:17 +04:00
52a8b6e778 Rendering selector for preview size in the fullscreen viewer element 2024-12-30 21:57:35 +04:00
c9c441a8ae Added size setting to the misc settings controller 2024-12-30 21:53:35 +04:00
c241bfb25c Mark profile as no longer temporary once it's edited 2024-12-23 21:46:36 +04:00
ac85938355 Added temporary flag, auto-remove profile when tags list is empty 2024-12-23 21:45:09 +04:00
a8a27654fc Merge pull request #65 from koloml/release/0.3.4
Release: 0.3.4
2024-12-16 16:43:16 +04:00
f58c4aa818 Bumped version to 0.3.4 2024-12-16 16:41:05 +04:00
eda58cd2ca Merge pull request #67 from koloml/feature/option-to-auto-remove-invalid-tags
Detect invalid blacklisted tags from Furbooru and allow to remove them manually or automatically
2024-12-16 16:32:44 +04:00
8f3020bc7b Merge pull request #66 from koloml/feature/property-icons-in-autocomplete
Added icon for the properties to differentiate between properties & tags
2024-12-15 02:46:17 +04:00
abbfcf2e34 Merge pull request #64 from koloml/feature/typescriptify-settings-classes
Refactoring settings classes to TypeScript
2024-12-15 02:41:39 +04:00
c4f00c4905 Fixed hover colors for error-tags 2024-12-14 22:04:31 +04:00
71039ee657 Adding default theme colors for categories 2024-12-14 22:02:10 +04:00
fa8ff3b718 Auto-removing or simply revealing tags when detected 2024-12-14 22:00:35 +04:00
90562f3878 Added settings menu for tags with auto-remove option 2024-12-14 21:59:47 +04:00
d1e22eaa0c Added flag for auto-removing invalid tags 2024-12-14 21:48:35 +04:00
ca3c4f6618 Added $config alias for config directory 2024-12-14 21:25:36 +04:00
3123ce1c0f Added list of blacklisted tags from Furbooru 2024-12-14 20:18:33 +04:00
6775a2175a Moving settings classes to TypeScript 2024-12-14 19:55:45 +04:00
62bcba34da Editing tag category, applying category color on view 2024-12-08 22:59:13 +04:00
17396952c3 Added field for selection of category, added container for tags 2024-12-08 22:58:46 +04:00
e61f6af237 Added category field to tag group 2024-12-08 22:58:16 +04:00
27133077c8 Adding default theme colors for categories 2024-12-08 22:57:49 +04:00
f90e51b546 Added icon for the properties to differentiate between props & tags 2024-12-07 20:54:56 +04:00
9e9499c904 Group deletion confirmation 2024-12-04 23:25:04 +04:00
7d19693f5e Import & export views 2024-12-04 23:21:26 +04:00
4fcf83d732 Group detail view & editing form 2024-12-02 03:23:32 +04:00
93d6d3a174 Groups listing, added link on the main view 2024-12-02 03:19:58 +04:00
4bdf04f911 Added store for the groups 2024-12-02 03:17:58 +04:00
c9a9fe059c Group view element 2024-12-02 03:17:48 +04:00
e523ce4468 Added initial version of tag group entity with tags & prefixes 2024-12-02 03:12:09 +04:00
d5f6ed1a3e Merge pull request #60 from koloml/release/0.3.3
Release: 0.3.3
2024-11-30 22:10:25 +04:00
0eba277f48 Bumped version to 0.3.3 2024-11-30 22:09:11 +04:00
b562752778 Merge pull request #62 from koloml/feature/fullscreen-viewer-loading-spinner
Fullscreen Viewer: Show the loading spinner while loading the image or video
2024-11-30 06:12:57 +04:00
015e5d6ec4 Show spinner icon in the fullscreen viewer while loading 2024-11-30 06:05:02 +04:00
fffb915985 Merge pull request #61 from koloml/feature/typescriptify-storage-entities
Storage entities code cleanup, moving to TypeScript
2024-11-30 05:16:16 +04:00
8151d1519a Merge remote-tracking branch 'origin/release/0.3.3' into feature/typescriptify-storage-entities
# Conflicts:
#	src/app.d.ts
2024-11-30 05:02:11 +04:00
4f0f3142a1 Typing controller methods, moving subscribe to base class 2024-11-30 04:46:10 +04:00
be97ac5640 Marking the constructor protected 2024-11-30 04:23:09 +04:00
9f61f99548 Moving readAll static method to base class 2024-11-30 04:22:43 +04:00
59c958ab32 Switching the jsconfig & vite config to TypeScript 2024-11-30 04:20:27 +04:00
ee3fcd1b08 Renamed controller to TS 2024-11-30 03:49:53 +04:00
d14fc8ba7d Proper modifiers 2024-11-30 03:37:05 +04:00
b3a92653ad Converting the exporters to TypeScript, fixing type errors 2024-11-28 00:45:41 +04:00
c7f40e99b7 Added mapping of entities and their names 2024-11-28 00:43:55 +04:00
f6ab60d939 Making entity name public, trying to better support new types 2024-11-27 22:48:31 +04:00
666d374057 Moving Maintenance Profile to TypeScript for better types 2024-11-27 22:11:35 +04:00
1ecb15c986 Merge pull request #58 from koloml/feature/moving-delete-action-and-confirmation
Added confirmation for profile deletion, moved "delete" button to the profile view instead of the editor
2024-11-26 03:11:36 +04:00
b22749812f Placed trash icon on delete button 2024-11-26 03:03:00 +04:00
4b3414be47 Added trash icon 2024-11-26 02:51:34 +04:00
6e4aef519f Merge remote-tracking branch 'origin/release/0.3.3' into feature/moving-delete-action-and-confirmation 2024-11-26 02:46:04 +04:00
feb57eec38 Merge pull request #59 from koloml/feature/use-fontawesome-from-npm
Replace local icons with icons from FontAwesome's NPM package
2024-11-26 02:42:16 +04:00
2a451d18be Replaced icons stored in static with icons files from npm package 2024-11-26 02:28:27 +04:00
1420ad1ece Disabled assets inlining for files like SVG icons 2024-11-26 01:48:56 +04:00
c0590ae347 Installed FA6 2024-11-26 01:47:49 +04:00
39260f9c5d Moved deletion button to profile view, added confirmation view 2024-11-26 01:22:20 +04:00
97b79b0b0d Merge pull request #52 from koloml/release/0.3.2
Release: 0.3.2
2024-11-23 01:15:14 +04:00
b645a1ca7a Bumped version to 0.3.2 2024-11-23 01:09:31 +04:00
c0a00e0c05 Merge pull request #57 from koloml/bugfix/backward-sync-for-preferences
Fixed preferences not being sycnhronized back from browser storage to popup view
2024-11-23 00:58:46 +04:00
a8f0f16121 Fixed missing backward synchronization from browser storage to stores 2024-11-23 00:47:56 +04:00
e13d9054cc Merge pull request #51 from koloml/bugfix/autocomplete-duplication
Fixed duplicating of auto-complete popup, added missed mouse clicks handling
2024-11-14 05:34:52 +04:00
c0139d0638 Fixed properties not being clickable with mouse 2024-11-14 05:06:27 +04:00
80ba4671f5 Fixed autocomplete popup duplication 2024-11-14 04:40:43 +04:00
bab919f0f8 Merge pull request #45 from koloml/release/0.3.1
Release: 0.3.1
2024-11-12 16:35:19 +04:00
72f901a2b7 Bumped version to 0.3.1 2024-11-12 16:30:52 +04:00
fd8efccfb3 Merge pull request #50 from koloml/feature/moving-import-and-export-to-separate-class
Slightly reduced extension content scripts size by extracting import/export logic into separate class
2024-11-12 16:29:31 +04:00
3621bb9f0e Removed export/import logic from entities, using transporter in popup 2024-11-12 16:21:06 +04:00
c15fae7c3d Implemented separate class for importing/exporting the entities 2024-11-12 16:19:13 +04:00
01e538c5c2 Cloning JS formatting settings to the TS, allow importing TS 2024-11-12 15:47:21 +04:00
4375613768 Merge pull request #49 from koloml/bugfix/wrap-tags-inside-dynamic-tag-editor
Fixed the "Add to profile" option not showing up after submitting tag changes in tags editor
2024-11-12 14:26:23 +04:00
3e05b1964d Skip watching logic if there is no editor on the page 2024-11-12 14:24:04 +04:00
5092dc7f6d Catch and wrap new tags dropdowns inside fancy tags editor 2024-11-12 14:19:53 +04:00
64dfac310e Merge pull request #48 from koloml/bugfix/force-reload-active-profile-on-change
Fixed profile not being refreshed after initial page load
2024-11-12 13:55:14 +04:00
2da2716844 Fixed profile not being refreshed after initial page load 2024-11-12 13:50:44 +04:00
10b5bff377 Merge pull request #46 from koloml/feature/display-active-profile-in-index-view
Display currently active profile on the main view of the popup
2024-11-12 13:38:56 +04:00
198b9da407 Fixed shrinking of the inputs on radio & checkbox items 2024-11-10 20:45:42 +04:00
b5cdb0d81b Show the active profile directly in the index view 2024-11-10 20:41:45 +04:00
dc4f575576 Merge pull request #44 from koloml/bugfix/add-to-profile-in-tags-index
Fixed option to add tag to profile not showing up on the index tags page
2024-10-23 12:29:03 +04:00
0a947219d0 Fixed "Add to profile" button is not being added on Tags index page 2024-10-22 21:49:42 +04:00
e83d70fbd9 Merge pull request #42 from koloml/release/0.3
Release: 0.3
2024-10-20 04:15:24 +04:00
844853ff57 Applied npm audit fix, updated lock file with new version 2024-10-20 04:13:27 +04:00
774409aac6 Bumped vite from 5.0.3 to 5.4.9 2024-10-20 04:12:12 +04:00
917775c5cd Bumped svelte from 4.2.7 to 4.2.19 2024-10-20 04:11:46 +04:00
a68c261b52 Bumped version to 0.3.0 2024-10-20 04:02:19 +04:00
f3a9694b1b Merge pull request #43 from koloml/feature/fullscreen-component
Fullscreen Viewer: Moved viewer logic into separate component, added closing with swipe for touch devices, added scroll lock
2024-10-20 03:55:38 +04:00
2cb4c6b4b2 Refactoring fullscreen viewer, added close swipe action for mobile 2024-10-20 03:47:18 +04:00
bafdb68f1e Added return type 2024-10-20 00:38:22 +04:00
02f9f3b36e Merge pull request #41 from koloml/feature/color-tags-in-editor
Tag Editor: Automatically apply category colors to the tags in the tag editor
2024-10-12 20:49:59 +04:00
57c505bee9 Dynamically catch and refresh colors in tag editor 2024-10-12 20:15:59 +04:00
03512a6539 Copying tag colors into the tag editor using other tags on the page 2024-10-12 19:15:04 +04:00
f95eaacaaa Merge pull request #40 from koloml/feature/add-tag-to-profile
Tagging Profiles: Added option to quickly add the tag into the active profile from the dropdown context menu
2024-10-12 18:46:16 +04:00
38cb925fa4 Implemented option to add the tag into active profile from dropdown 2024-10-12 03:40:38 +04:00
7dd738d0e8 Merge pull request #39 from koloml/feature/renaming-settings-route
Renamed `/settings` path to `/features` to avoid confusion with `/preferences`
2024-09-30 17:49:40 +04:00
2f8a47b808 Moving /settings route to /features to avoid confusion 2024-09-30 17:46:29 +04:00
d0c910d5bb Merge pull request #31 from koloml/feature/allow-tags-popup-in-galleries-listing
Show the tagging profiles popup in galleries view
2024-09-30 17:40:54 +04:00
dc1e49e60c Merge pull request #30 from koloml/feature/storage-viewer
Added debug section with extension's storage viewer
2024-09-30 17:40:44 +04:00
5e7e92614d Merge pull request #35 from koloml/feature/npm-audit
Updating vulnerable dependencies via `npm audit fix`
2024-08-26 22:22:06 +04:00
727b2c81ff Updating vulnerable dependencies via npm audit fix
Details of report:

# npm audit report

braces  <3.0.3
Severity: high
Uncontrolled resource consumption in braces - https://github.com/advisories/GHSA-grv7-fg5c-xmjg
fix available via `npm audit fix`
node_modules/braces

micromatch  <4.0.8
Severity: moderate
Regular Expression Denial of Service (ReDoS) in micromatch - https://github.com/advisories/GHSA-952p-6rrq-rcjv
fix available via `npm audit fix`
node_modules/micromatch

vite  5.1.0 - 5.1.6
Severity: moderate
Vite's `server.fs.deny` did not deny requests for patterns with directories. - https://github.com/advisories/GHSA-8jhw-289h-jh2g
fix available via `npm audit fix`
node_modules/vite

3 vulnerabilities (2 moderate, 1 high)
2024-08-26 22:09:17 +04:00
8059c93baa Display types correctly for the value 2024-08-12 20:04:02 +04:00
2f8d608e6b Fixed missing returning statement when updating Writeable 2024-08-12 20:02:35 +04:00
4635ccdb2b Fixed breadcrumbs generation 2024-08-12 19:55:11 +04:00
68d1d726af Added debug section to inspect extension's local storage 2024-08-12 19:37:35 +04:00
e8c3e610eb Support tagging profiles on galleries 2024-08-11 17:53:18 +04:00
f9cb73bafc Added missing alt-text for the chrome installation link 2024-08-10 16:33:44 +04:00
6bb3e83684 Added installation buttons for the Chrome & Firefox 2024-08-10 16:32:10 +04:00
b99846ba6a Merge pull request #26 from koloml/release/0.2.1
Release: 0.2.1
2024-08-10 15:10:14 +04:00
4ca84b0c14 Bump version to 0.2.1 2024-08-10 15:09:51 +04:00
25fe769a1e Merge pull request #27 from koloml/feature/misc-preferences
Fullscreen Viewer: Added preference to turn the fullscreen button ON and OFF
2024-08-10 15:09:20 +04:00
c9d20be33d Turn on the fullscreen viewer button by default 2024-08-10 15:05:09 +04:00
e9b68137de Apply the preference 2024-08-10 15:04:00 +04:00
e0820c50ec Added settings for misc. & tools preferences with fullscreen option 2024-08-10 15:03:39 +04:00
135ed48c01 Merge pull request #25 from koloml/feature/consistent-sorting
Sort listing of profiles & tags in profile view alphabetically
2024-08-10 14:42:14 +04:00
9d9aa38a9d Merge pull request #24 from koloml/bugfix/active-tagging-profile-resetting
Tagging Profiles: Fixed active profile selection getting reset on popup being opened
2024-08-10 14:42:01 +04:00
323fa4e2b7 Sort tags alphabetically 2024-08-10 14:37:02 +04:00
920804467e Invert sorting of tagging profiles list 2024-08-10 14:32:21 +04:00
9c66f62408 Wait for initial loading before subscribing to changes 2024-08-10 13:53:19 +04:00
16d126598e Merge pull request #23 from koloml/hotfix/firefox-missing-extension-id
Fixed missing extension ID required for publishing to Firefox extensions store
2024-08-08 08:00:09 +04:00
a93430f3e3 Bumped version to 0.2.0.1 2024-08-08 07:54:20 +04:00
1927c2ec31 Fixed missing extension ID for FireFox 2024-08-08 07:53:53 +04:00
63e6ee394d Merge pull request #16 from koloml/release/0.2
Release: v0.2.0
2024-08-08 07:29:54 +04:00
342cc38292 Bumping version to 0.2.0 2024-08-08 07:29:20 +04:00
f11827d516 Merge pull request #22 from koloml/feature/firefox-support
Tested for Firefox, preparing for release to Firefox addon store
2024-08-08 07:27:31 +04:00
595e73aff3 Added basic building instructions 2024-08-08 07:23:36 +04:00
c9107ab109 Fixed appearance when popup is opened in the new tab instead of popup 2024-08-08 06:57:54 +04:00
3adfab9555 De-Chrome-ify helper class directory 2024-08-08 06:15:28 +04:00
0a740273a3 Merge pull request #21 from koloml/feature/improving-mobile-controls
Tag Editor: Added better support for mobile devices
2024-08-08 05:53:56 +04:00
e325e51b41 Stretch field to 100% of width 2024-08-08 05:36:26 +04:00
2637eac162 Fixed Enter and Backspace getting missed on mobile devices 2024-08-08 05:33:34 +04:00
64ad82b985 Merge pull request #20 from koloml/feature/configuration-section
Added the preferences section, implemented preferences to toggle properties suggestions
2024-08-07 03:59:05 +04:00
5bb6055aee Changed texts, hiding the position option when disabled 2024-08-07 03:55:04 +04:00
56f397c2d8 Update default appearance of selectors 2024-08-07 03:49:39 +04:00
e85055368a Apply preferences to the search wrapper 2024-08-07 03:39:56 +04:00
0265622337 Merge remote-tracking branch 'refs/remotes/origin/release/0.2' into feature/configuration-section 2024-08-07 03:28:13 +04:00
91648a1ee0 Merge pull request #19 from koloml/feature/autocomplete-property-queries
Autocompletion: Added completion for the properties in header search field
2024-08-07 03:27:34 +04:00
fa77e8b923 Added search settings controller, sync settings in popup with the class 2024-08-07 03:26:32 +04:00
67d3b57eb1 Implemented base class for cached settings storages 2024-08-07 03:26:06 +04:00
b0889486c7 Added search preferences section with several initial settings 2024-08-07 02:30:24 +04:00
c0b1259e45 Added select and checkboxes fields 2024-08-07 02:29:31 +04:00
8aacd83474 Support form controls without labels, added paddings 2024-08-07 02:29:19 +04:00
be1ae8f004 Added undocumented faved_by_id and uploader_id 2024-07-22 22:05:32 +04:00
3f22852714 Added autocompletion of my: namespace 2024-07-22 22:05:09 +04:00
71ab75efaf Partially restored header logic, added suggestions for properties 2024-07-15 03:54:28 +04:00
f71dc5a029 Merge pull request #17 from koloml/feature/tagging-profiles-import-export
Added functionality to export and import tagging profiles
2024-07-07 19:45:07 +04:00
1a086625b9 Implemented the importing procedure for tagging profiles 2024-07-07 19:35:27 +04:00
60914e11c4 Moved styling of the textarea inside FormControl 2024-07-07 19:09:19 +04:00
d3fc78533d Extracted the logic of previewing the tagging profiles into component 2024-07-07 19:08:31 +04:00
b240c2aefe Added error & warning colors 2024-07-07 19:07:21 +04:00
e1026fd108 Fixed error triggered when empty string is parsed as null 2024-07-07 19:07:03 +04:00
81c66134a1 Added import logic, moved logic to the profile class 2024-07-07 17:18:26 +04:00
8d8d7cc7e4 Force cursor to point, disabled user selection 2024-07-07 16:51:31 +04:00
688ed15939 Added exporting view for a tagging profile 2024-07-07 05:58:31 +04:00
d671ca13f6 Installed lz-string which will be used to compress exported data 2024-07-07 05:57:54 +04:00
aaa1441a38 Added file-export icon, moved some the types to typedef file 2024-07-07 05:10:17 +04:00
b7a53daa9b Merge pull request #15 from koloml/feature/menu-items-controls
Minor refactoring for menu items, support switching between tagging profiles using radio controls right from the list
2024-07-07 03:19:23 +04:00
965045e672 Merge pull request #14 from koloml/feature/link-latest-release-to-github
Linking the version in the popup footer to the latest release page
2024-07-07 03:19:00 +04:00
7ee1a72302 Added radio item, replaced menu item on profiles listing screen 2024-07-07 03:10:50 +04:00
dc27f33231 Renaming all instances of MenuLink to MenuItem 2024-07-07 02:52:36 +04:00
eae5016daa Support links without URLs provided 2024-07-07 02:48:41 +04:00
7b236f12cd Added link to the repository release for current version 2024-07-07 01:45:15 +04:00
7eab8d633f Bumping version to 0.1.2 2024-07-05 02:12:59 +04:00
68de994811 Merge pull request #13 from koloml/prevent-closing-tab-before-submission-completed
Prompt the browser confirmation popup when attempting to change or close the tab before all submissions are processed
2024-07-05 02:05:59 +04:00
be4aec54fe Merge pull request #12 from koloml/disable-search-autocompletion
Removing auto-completion logic
2024-07-05 02:05:43 +04:00
9ca663ffdb Added icon for failed status 2024-07-05 02:00:36 +04:00
bb0f84c9ad Showing exit popup when leaving page before submissions are processed 2024-07-05 01:57:34 +04:00
c8eb54ab98 Removing auto-completion logic 2024-07-05 01:06:11 +04:00
d2140c6eee Bumping version to 0.1.1 2024-06-27 02:04:45 +04:00
741bc71f11 Merge pull request #8 from koloml/minor-text-and-styling-changes
Minor content & styling changes and fixes
2024-06-27 02:03:07 +04:00
9732fa2005 Adding more spaces between elements on profile view 2024-06-27 02:01:46 +04:00
c45d4619a8 Renaming main link to "Tagging Profiles" 2024-06-27 02:01:10 +04:00
e42419e3c5 Merge pull request #7 from koloml/tags-editor-refactor
Re-import base input styling removed with Tags Editor styling back to main popup stylesheet
2024-06-27 01:43:15 +04:00
5c48e1cca6 Re-import base input styling to popup stylesheet
Forgot to move import out of tags editor into the main popup stylesheet.
2024-06-27 01:41:17 +04:00
c55b9cc851 Merge pull request #6 from koloml/media-boxes-positions
Fixed Maintenance popups getting out of screen on the start and on the end of the grid rows
2024-06-27 01:37:44 +04:00
723e72b65f Merge pull request #5 from koloml/tags-editor-refactor
Refactoring Tags Editor from Web Components API to Svelte component, improved accessibility for keyboards
2024-06-27 01:37:31 +04:00
8da814c8dd Moving popups of first/last media boxes to not get out of screen 2024-06-27 01:28:53 +04:00
4bd7a67a03 Calculate the first/last media boxes in the row on every resize 2024-06-27 01:12:04 +04:00
b302e8fbb7 Removed tags editor stylesheets from global styles 2024-06-27 00:44:38 +04:00
e6e537ea0c Tags Editor: Removed Web Component with a component made with Svelte
Additionally, this editor works a little bit better when used with
keyboard. It supports tabbing between "remove" buttons inside the tags
and pressing them with Space/Enter.

Web Component was an idea to keep the editor the same between frontend
and Svelte app, but as I figured out later, extensions can't use those.
Unfortunate.
2024-06-27 00:43:29 +04:00
15d318ec90 Merge pull request #1 from koloml/fullscreen-viewer-interactions
Support playing videos in Fullscreen Viewer, closing Viewer on click
2024-06-23 18:26:21 +04:00
a81a7c5d27 Support video content in Fullscreen Viewer 2024-06-23 18:25:05 +04:00
eda7342144 Closing full screen viewer on click 2024-06-23 15:07:16 +04:00
179 changed files with 11939 additions and 5942 deletions

View File

@@ -204,7 +204,7 @@ ij_javascript_spaces_within_brackets = false
ij_javascript_spaces_within_catch_parentheses = false
ij_javascript_spaces_within_for_parentheses = false
ij_javascript_spaces_within_if_parentheses = false
ij_javascript_spaces_within_imports = false
ij_javascript_spaces_within_imports = true
ij_javascript_spaces_within_interpolation_expressions = false
ij_javascript_spaces_within_method_call_parentheses = false
ij_javascript_spaces_within_method_parentheses = false
@@ -221,7 +221,7 @@ ij_javascript_ternary_operation_wrap = off
ij_javascript_union_types_wrap = on_every_item
ij_javascript_use_chained_calls_group_indents = false
ij_javascript_use_double_quotes = true
ij_javascript_use_explicit_js_extension = auto
ij_javascript_use_explicit_js_extension = never
ij_javascript_use_import_type = auto
ij_javascript_use_path_mapping = always
ij_javascript_use_public_modifier = false
@@ -231,6 +231,537 @@ ij_javascript_while_brace_force = never
ij_javascript_while_on_new_line = false
ij_javascript_wrap_comments = false
[{*.ts,*.tsx}]
indent_size = 2
tab_width = 2
ij_continuation_indent_size = 2
ij_typescript_align_imports = false
ij_typescript_align_multiline_array_initializer_expression = false
ij_typescript_align_multiline_binary_operation = false
ij_typescript_align_multiline_chained_methods = false
ij_typescript_align_multiline_extends_list = false
ij_typescript_align_multiline_for = true
ij_typescript_align_multiline_parameters = true
ij_typescript_align_multiline_parameters_in_calls = false
ij_typescript_align_multiline_ternary_operation = false
ij_typescript_align_object_properties = 0
ij_typescript_align_union_types = false
ij_typescript_align_var_statements = 0
ij_typescript_array_initializer_new_line_after_left_brace = false
ij_typescript_array_initializer_right_brace_on_new_line = false
ij_typescript_array_initializer_wrap = off
ij_typescript_assignment_wrap = off
ij_typescript_binary_operation_sign_on_next_line = false
ij_typescript_binary_operation_wrap = off
ij_typescript_blacklist_imports = rxjs/Rx, node_modules/**, **/node_modules/**, @angular/material, @angular/material/typings/**
ij_typescript_blank_lines_after_imports = 1
ij_typescript_blank_lines_around_class = 1
ij_typescript_blank_lines_around_field = 0
ij_typescript_blank_lines_around_function = 1
ij_typescript_blank_lines_around_method = 1
ij_typescript_block_brace_style = end_of_line
ij_typescript_block_comment_add_space = false
ij_typescript_block_comment_at_first_column = true
ij_typescript_call_parameters_new_line_after_left_paren = false
ij_typescript_call_parameters_right_paren_on_new_line = false
ij_typescript_call_parameters_wrap = off
ij_typescript_catch_on_new_line = false
ij_typescript_chained_call_dot_on_new_line = true
ij_typescript_class_brace_style = end_of_line
ij_typescript_comma_on_new_line = false
ij_typescript_do_while_brace_force = never
ij_typescript_else_on_new_line = false
ij_typescript_enforce_trailing_comma = keep
ij_typescript_extends_keyword_wrap = off
ij_typescript_extends_list_wrap = off
ij_typescript_field_prefix = _
ij_typescript_file_name_style = relaxed
ij_typescript_finally_on_new_line = false
ij_typescript_for_brace_force = never
ij_typescript_for_statement_new_line_after_left_paren = false
ij_typescript_for_statement_right_paren_on_new_line = false
ij_typescript_for_statement_wrap = off
ij_typescript_force_quote_style = false
ij_typescript_force_semicolon_style = false
ij_typescript_function_expression_brace_style = end_of_line
ij_typescript_if_brace_force = never
ij_typescript_import_merge_members = global
ij_typescript_import_prefer_absolute_path = global
ij_typescript_import_sort_members = true
ij_typescript_import_sort_module_name = false
ij_typescript_import_use_node_resolution = true
ij_typescript_imports_wrap = on_every_item
ij_typescript_indent_case_from_switch = true
ij_typescript_indent_chained_calls = true
ij_typescript_indent_package_children = 0
ij_typescript_jsx_attribute_value = braces
ij_typescript_keep_blank_lines_in_code = 2
ij_typescript_keep_first_column_comment = true
ij_typescript_keep_indents_on_empty_lines = false
ij_typescript_keep_line_breaks = true
ij_typescript_keep_simple_blocks_in_one_line = false
ij_typescript_keep_simple_methods_in_one_line = false
ij_typescript_line_comment_add_space = true
ij_typescript_line_comment_at_first_column = false
ij_typescript_method_brace_style = end_of_line
ij_typescript_method_call_chain_wrap = off
ij_typescript_method_parameters_new_line_after_left_paren = false
ij_typescript_method_parameters_right_paren_on_new_line = false
ij_typescript_method_parameters_wrap = off
ij_typescript_object_literal_wrap = on_every_item
ij_typescript_object_types_wrap = on_every_item
ij_typescript_parentheses_expression_new_line_after_left_paren = false
ij_typescript_parentheses_expression_right_paren_on_new_line = false
ij_typescript_place_assignment_sign_on_next_line = false
ij_typescript_prefer_as_type_cast = false
ij_typescript_prefer_explicit_types_function_expression_returns = false
ij_typescript_prefer_explicit_types_function_returns = false
ij_typescript_prefer_explicit_types_vars_fields = false
ij_typescript_prefer_parameters_wrap = false
ij_typescript_property_prefix =
ij_typescript_reformat_c_style_comments = false
ij_typescript_space_after_colon = true
ij_typescript_space_after_comma = true
ij_typescript_space_after_dots_in_rest_parameter = false
ij_typescript_space_after_generator_mult = true
ij_typescript_space_after_property_colon = true
ij_typescript_space_after_quest = true
ij_typescript_space_after_type_colon = true
ij_typescript_space_after_unary_not = false
ij_typescript_space_before_async_arrow_lparen = true
ij_typescript_space_before_catch_keyword = true
ij_typescript_space_before_catch_left_brace = true
ij_typescript_space_before_catch_parentheses = true
ij_typescript_space_before_class_lbrace = true
ij_typescript_space_before_class_left_brace = true
ij_typescript_space_before_colon = true
ij_typescript_space_before_comma = false
ij_typescript_space_before_do_left_brace = true
ij_typescript_space_before_else_keyword = true
ij_typescript_space_before_else_left_brace = true
ij_typescript_space_before_finally_keyword = true
ij_typescript_space_before_finally_left_brace = true
ij_typescript_space_before_for_left_brace = true
ij_typescript_space_before_for_parentheses = true
ij_typescript_space_before_for_semicolon = false
ij_typescript_space_before_function_left_parenth = true
ij_typescript_space_before_generator_mult = false
ij_typescript_space_before_if_left_brace = true
ij_typescript_space_before_if_parentheses = true
ij_typescript_space_before_method_call_parentheses = false
ij_typescript_space_before_method_left_brace = true
ij_typescript_space_before_method_parentheses = false
ij_typescript_space_before_property_colon = false
ij_typescript_space_before_quest = true
ij_typescript_space_before_switch_left_brace = true
ij_typescript_space_before_switch_parentheses = true
ij_typescript_space_before_try_left_brace = true
ij_typescript_space_before_type_colon = false
ij_typescript_space_before_unary_not = false
ij_typescript_space_before_while_keyword = true
ij_typescript_space_before_while_left_brace = true
ij_typescript_space_before_while_parentheses = true
ij_typescript_spaces_around_additive_operators = true
ij_typescript_spaces_around_arrow_function_operator = true
ij_typescript_spaces_around_assignment_operators = true
ij_typescript_spaces_around_bitwise_operators = true
ij_typescript_spaces_around_equality_operators = true
ij_typescript_spaces_around_logical_operators = true
ij_typescript_spaces_around_multiplicative_operators = true
ij_typescript_spaces_around_relational_operators = true
ij_typescript_spaces_around_shift_operators = true
ij_typescript_spaces_around_unary_operator = false
ij_typescript_spaces_within_array_initializer_brackets = false
ij_typescript_spaces_within_brackets = false
ij_typescript_spaces_within_catch_parentheses = false
ij_typescript_spaces_within_for_parentheses = false
ij_typescript_spaces_within_if_parentheses = false
ij_typescript_spaces_within_imports = true
ij_typescript_spaces_within_interpolation_expressions = false
ij_typescript_spaces_within_method_call_parentheses = false
ij_typescript_spaces_within_method_parentheses = false
ij_typescript_spaces_within_object_literal_braces = false
ij_typescript_spaces_within_object_type_braces = true
ij_typescript_spaces_within_parentheses = false
ij_typescript_spaces_within_switch_parentheses = false
ij_typescript_spaces_within_type_assertion = false
ij_typescript_spaces_within_union_types = true
ij_typescript_spaces_within_while_parentheses = false
ij_typescript_special_else_if_treatment = true
ij_typescript_ternary_operation_signs_on_next_line = false
ij_typescript_ternary_operation_wrap = off
ij_typescript_union_types_wrap = on_every_item
ij_typescript_use_chained_calls_group_indents = false
ij_typescript_use_double_quotes = true
ij_typescript_use_explicit_js_extension = never
ij_typescript_use_import_type = auto
ij_typescript_use_path_mapping = always
ij_typescript_use_public_modifier = false
ij_typescript_use_semicolon_after_statement = true
ij_typescript_var_declaration_wrap = normal
ij_typescript_while_brace_force = never
ij_typescript_while_on_new_line = false
ij_typescript_wrap_comments = false
[*.svelte]
indent_size = 2
tab_width = 2
ij_continuation_indent_size = 2
ij_javascript_align_imports = false
ij_javascript_align_multiline_array_initializer_expression = false
ij_javascript_align_multiline_binary_operation = false
ij_javascript_align_multiline_chained_methods = false
ij_javascript_align_multiline_extends_list = false
ij_javascript_align_multiline_for = true
ij_javascript_align_multiline_parameters = true
ij_javascript_align_multiline_parameters_in_calls = false
ij_javascript_align_multiline_ternary_operation = false
ij_javascript_align_object_properties = 0
ij_javascript_align_union_types = false
ij_javascript_align_var_statements = 0
ij_javascript_array_initializer_new_line_after_left_brace = false
ij_javascript_array_initializer_right_brace_on_new_line = false
ij_javascript_array_initializer_wrap = off
ij_javascript_assignment_wrap = off
ij_javascript_binary_operation_sign_on_next_line = false
ij_javascript_binary_operation_wrap = off
ij_javascript_blacklist_imports = rxjs/Rx, node_modules/**, **/node_modules/**, @angular/material, @angular/material/typings/**
ij_javascript_blank_lines_after_imports = 1
ij_javascript_blank_lines_around_class = 1
ij_javascript_blank_lines_around_field = 0
ij_javascript_blank_lines_around_function = 1
ij_javascript_blank_lines_around_method = 1
ij_javascript_block_brace_style = end_of_line
ij_javascript_block_comment_add_space = false
ij_javascript_block_comment_at_first_column = true
ij_javascript_call_parameters_new_line_after_left_paren = false
ij_javascript_call_parameters_right_paren_on_new_line = false
ij_javascript_call_parameters_wrap = off
ij_javascript_catch_on_new_line = false
ij_javascript_chained_call_dot_on_new_line = true
ij_javascript_class_brace_style = end_of_line
ij_javascript_comma_on_new_line = false
ij_javascript_do_while_brace_force = never
ij_javascript_else_on_new_line = false
ij_javascript_enforce_trailing_comma = keep
ij_javascript_extends_keyword_wrap = off
ij_javascript_extends_list_wrap = off
ij_javascript_field_prefix = _
ij_javascript_file_name_style = relaxed
ij_javascript_finally_on_new_line = false
ij_javascript_for_brace_force = never
ij_javascript_for_statement_new_line_after_left_paren = false
ij_javascript_for_statement_right_paren_on_new_line = false
ij_javascript_for_statement_wrap = off
ij_javascript_force_quote_style = false
ij_javascript_force_semicolon_style = false
ij_javascript_function_expression_brace_style = end_of_line
ij_javascript_if_brace_force = never
ij_javascript_import_merge_members = global
ij_javascript_import_prefer_absolute_path = global
ij_javascript_import_sort_members = true
ij_javascript_import_sort_module_name = false
ij_javascript_import_use_node_resolution = true
ij_javascript_imports_wrap = on_every_item
ij_javascript_indent_case_from_switch = true
ij_javascript_indent_chained_calls = true
ij_javascript_indent_package_children = 0
ij_javascript_jsx_attribute_value = braces
ij_javascript_keep_blank_lines_in_code = 2
ij_javascript_keep_first_column_comment = true
ij_javascript_keep_indents_on_empty_lines = false
ij_javascript_keep_line_breaks = true
ij_javascript_keep_simple_blocks_in_one_line = false
ij_javascript_keep_simple_methods_in_one_line = false
ij_javascript_line_comment_add_space = true
ij_javascript_line_comment_at_first_column = false
ij_javascript_method_brace_style = end_of_line
ij_javascript_method_call_chain_wrap = off
ij_javascript_method_parameters_new_line_after_left_paren = false
ij_javascript_method_parameters_right_paren_on_new_line = false
ij_javascript_method_parameters_wrap = off
ij_javascript_object_literal_wrap = on_every_item
ij_javascript_object_types_wrap = on_every_item
ij_javascript_parentheses_expression_new_line_after_left_paren = false
ij_javascript_parentheses_expression_right_paren_on_new_line = false
ij_javascript_place_assignment_sign_on_next_line = false
ij_javascript_prefer_as_type_cast = false
ij_javascript_prefer_explicit_types_function_expression_returns = false
ij_javascript_prefer_explicit_types_function_returns = false
ij_javascript_prefer_explicit_types_vars_fields = false
ij_javascript_prefer_parameters_wrap = false
ij_javascript_property_prefix =
ij_javascript_reformat_c_style_comments = false
ij_javascript_space_after_colon = true
ij_javascript_space_after_comma = true
ij_javascript_space_after_dots_in_rest_parameter = false
ij_javascript_space_after_generator_mult = true
ij_javascript_space_after_property_colon = true
ij_javascript_space_after_quest = true
ij_javascript_space_after_type_colon = true
ij_javascript_space_after_unary_not = false
ij_javascript_space_before_async_arrow_lparen = true
ij_javascript_space_before_catch_keyword = true
ij_javascript_space_before_catch_left_brace = true
ij_javascript_space_before_catch_parentheses = true
ij_javascript_space_before_class_lbrace = true
ij_javascript_space_before_class_left_brace = true
ij_javascript_space_before_colon = true
ij_javascript_space_before_comma = false
ij_javascript_space_before_do_left_brace = true
ij_javascript_space_before_else_keyword = true
ij_javascript_space_before_else_left_brace = true
ij_javascript_space_before_finally_keyword = true
ij_javascript_space_before_finally_left_brace = true
ij_javascript_space_before_for_left_brace = true
ij_javascript_space_before_for_parentheses = true
ij_javascript_space_before_for_semicolon = false
ij_javascript_space_before_function_left_parenth = true
ij_javascript_space_before_generator_mult = false
ij_javascript_space_before_if_left_brace = true
ij_javascript_space_before_if_parentheses = true
ij_javascript_space_before_method_call_parentheses = false
ij_javascript_space_before_method_left_brace = true
ij_javascript_space_before_method_parentheses = false
ij_javascript_space_before_property_colon = false
ij_javascript_space_before_quest = true
ij_javascript_space_before_switch_left_brace = true
ij_javascript_space_before_switch_parentheses = true
ij_javascript_space_before_try_left_brace = true
ij_javascript_space_before_type_colon = false
ij_javascript_space_before_unary_not = false
ij_javascript_space_before_while_keyword = true
ij_javascript_space_before_while_left_brace = true
ij_javascript_space_before_while_parentheses = true
ij_javascript_spaces_around_additive_operators = true
ij_javascript_spaces_around_arrow_function_operator = true
ij_javascript_spaces_around_assignment_operators = true
ij_javascript_spaces_around_bitwise_operators = true
ij_javascript_spaces_around_equality_operators = true
ij_javascript_spaces_around_logical_operators = true
ij_javascript_spaces_around_multiplicative_operators = true
ij_javascript_spaces_around_relational_operators = true
ij_javascript_spaces_around_shift_operators = true
ij_javascript_spaces_around_unary_operator = false
ij_javascript_spaces_within_array_initializer_brackets = false
ij_javascript_spaces_within_brackets = false
ij_javascript_spaces_within_catch_parentheses = false
ij_javascript_spaces_within_for_parentheses = false
ij_javascript_spaces_within_if_parentheses = false
ij_javascript_spaces_within_imports = true
ij_javascript_spaces_within_interpolation_expressions = false
ij_javascript_spaces_within_method_call_parentheses = false
ij_javascript_spaces_within_method_parentheses = false
ij_javascript_spaces_within_object_literal_braces = true
ij_javascript_spaces_within_object_type_braces = true
ij_javascript_spaces_within_parentheses = false
ij_javascript_spaces_within_switch_parentheses = false
ij_javascript_spaces_within_type_assertion = false
ij_javascript_spaces_within_union_types = true
ij_javascript_spaces_within_while_parentheses = false
ij_javascript_special_else_if_treatment = true
ij_javascript_ternary_operation_signs_on_next_line = false
ij_javascript_ternary_operation_wrap = off
ij_javascript_union_types_wrap = on_every_item
ij_javascript_use_chained_calls_group_indents = false
ij_javascript_use_double_quotes = true
ij_javascript_use_explicit_js_extension = never
ij_javascript_use_import_type = auto
ij_javascript_use_path_mapping = always
ij_javascript_use_public_modifier = false
ij_javascript_use_semicolon_after_statement = true
ij_javascript_var_declaration_wrap = normal
ij_javascript_while_brace_force = never
ij_javascript_while_on_new_line = false
ij_javascript_wrap_comments = false
ij_scss_align_closing_brace_with_properties = false
ij_scss_blank_lines_around_nested_selector = 1
ij_scss_blank_lines_between_blocks = 1
ij_scss_block_comment_add_space = false
ij_scss_brace_placement = 0
ij_scss_enforce_quotes_on_format = false
ij_scss_hex_color_long_format = false
ij_scss_hex_color_lower_case = false
ij_scss_hex_color_short_format = false
ij_scss_hex_color_upper_case = false
ij_scss_keep_blank_lines_in_code = 2
ij_scss_keep_indents_on_empty_lines = false
ij_scss_keep_single_line_blocks = false
ij_scss_line_comment_add_space = false
ij_scss_line_comment_at_first_column = false
ij_scss_properties_order = font, font-family, font-size, font-weight, font-style, font-variant, font-size-adjust, font-stretch, line-height, position, z-index, top, right, bottom, left, display, visibility, float, clear, overflow, overflow-x, overflow-y, clip, zoom, align-content, align-items, align-self, flex, flex-flow, flex-basis, flex-direction, flex-grow, flex-shrink, flex-wrap, justify-content, order, box-sizing, width, min-width, max-width, height, min-height, max-height, margin, margin-top, margin-right, margin-bottom, margin-left, padding, padding-top, padding-right, padding-bottom, padding-left, table-layout, empty-cells, caption-side, border-spacing, border-collapse, list-style, list-style-position, list-style-type, list-style-image, content, quotes, counter-reset, counter-increment, resize, cursor, user-select, nav-index, nav-up, nav-right, nav-down, nav-left, transition, transition-delay, transition-timing-function, transition-duration, transition-property, transform, transform-origin, animation, animation-name, animation-duration, animation-play-state, animation-timing-function, animation-delay, animation-iteration-count, animation-direction, text-align, text-align-last, vertical-align, white-space, text-decoration, text-emphasis, text-emphasis-color, text-emphasis-style, text-emphasis-position, text-indent, text-justify, letter-spacing, word-spacing, text-outline, text-transform, text-wrap, text-overflow, text-overflow-ellipsis, text-overflow-mode, word-wrap, word-break, tab-size, hyphens, pointer-events, opacity, color, border, border-width, border-style, border-color, border-top, border-top-width, border-top-style, border-top-color, border-right, border-right-width, border-right-style, border-right-color, border-bottom, border-bottom-width, border-bottom-style, border-bottom-color, border-left, border-left-width, border-left-style, border-left-color, border-radius, border-top-left-radius, border-top-right-radius, border-bottom-right-radius, border-bottom-left-radius, border-image, border-image-source, border-image-slice, border-image-width, border-image-outset, border-image-repeat, outline, outline-width, outline-style, outline-color, outline-offset, background, background-color, background-image, background-repeat, background-attachment, background-position, background-position-x, background-position-y, background-clip, background-origin, background-size, box-decoration-break, box-shadow, text-shadow
ij_scss_space_after_colon = true
ij_scss_space_before_opening_brace = true
ij_scss_use_double_quotes = true
ij_scss_value_alignment = 0
ij_typescript_align_imports = false
ij_typescript_align_multiline_array_initializer_expression = false
ij_typescript_align_multiline_binary_operation = false
ij_typescript_align_multiline_chained_methods = false
ij_typescript_align_multiline_extends_list = false
ij_typescript_align_multiline_for = true
ij_typescript_align_multiline_parameters = true
ij_typescript_align_multiline_parameters_in_calls = false
ij_typescript_align_multiline_ternary_operation = false
ij_typescript_align_object_properties = 0
ij_typescript_align_union_types = false
ij_typescript_align_var_statements = 0
ij_typescript_array_initializer_new_line_after_left_brace = false
ij_typescript_array_initializer_right_brace_on_new_line = false
ij_typescript_array_initializer_wrap = off
ij_typescript_assignment_wrap = off
ij_typescript_binary_operation_sign_on_next_line = false
ij_typescript_binary_operation_wrap = off
ij_typescript_blacklist_imports = rxjs/Rx, node_modules/**, **/node_modules/**, @angular/material, @angular/material/typings/**
ij_typescript_blank_lines_after_imports = 1
ij_typescript_blank_lines_around_class = 1
ij_typescript_blank_lines_around_field = 0
ij_typescript_blank_lines_around_function = 1
ij_typescript_blank_lines_around_method = 1
ij_typescript_block_brace_style = end_of_line
ij_typescript_block_comment_add_space = false
ij_typescript_block_comment_at_first_column = true
ij_typescript_call_parameters_new_line_after_left_paren = false
ij_typescript_call_parameters_right_paren_on_new_line = false
ij_typescript_call_parameters_wrap = off
ij_typescript_catch_on_new_line = false
ij_typescript_chained_call_dot_on_new_line = true
ij_typescript_class_brace_style = end_of_line
ij_typescript_comma_on_new_line = false
ij_typescript_do_while_brace_force = never
ij_typescript_else_on_new_line = false
ij_typescript_enforce_trailing_comma = keep
ij_typescript_extends_keyword_wrap = off
ij_typescript_extends_list_wrap = off
ij_typescript_field_prefix = _
ij_typescript_file_name_style = relaxed
ij_typescript_finally_on_new_line = false
ij_typescript_for_brace_force = never
ij_typescript_for_statement_new_line_after_left_paren = false
ij_typescript_for_statement_right_paren_on_new_line = false
ij_typescript_for_statement_wrap = off
ij_typescript_force_quote_style = false
ij_typescript_force_semicolon_style = false
ij_typescript_function_expression_brace_style = end_of_line
ij_typescript_if_brace_force = never
ij_typescript_import_merge_members = global
ij_typescript_import_prefer_absolute_path = global
ij_typescript_import_sort_members = true
ij_typescript_import_sort_module_name = false
ij_typescript_import_use_node_resolution = true
ij_typescript_imports_wrap = on_every_item
ij_typescript_indent_case_from_switch = true
ij_typescript_indent_chained_calls = true
ij_typescript_indent_package_children = 0
ij_typescript_jsx_attribute_value = braces
ij_typescript_keep_blank_lines_in_code = 2
ij_typescript_keep_first_column_comment = true
ij_typescript_keep_indents_on_empty_lines = false
ij_typescript_keep_line_breaks = true
ij_typescript_keep_simple_blocks_in_one_line = false
ij_typescript_keep_simple_methods_in_one_line = false
ij_typescript_line_comment_add_space = true
ij_typescript_line_comment_at_first_column = false
ij_typescript_method_brace_style = end_of_line
ij_typescript_method_call_chain_wrap = off
ij_typescript_method_parameters_new_line_after_left_paren = false
ij_typescript_method_parameters_right_paren_on_new_line = false
ij_typescript_method_parameters_wrap = off
ij_typescript_object_literal_wrap = on_every_item
ij_typescript_object_types_wrap = on_every_item
ij_typescript_parentheses_expression_new_line_after_left_paren = false
ij_typescript_parentheses_expression_right_paren_on_new_line = false
ij_typescript_place_assignment_sign_on_next_line = false
ij_typescript_prefer_as_type_cast = false
ij_typescript_prefer_explicit_types_function_expression_returns = false
ij_typescript_prefer_explicit_types_function_returns = false
ij_typescript_prefer_explicit_types_vars_fields = false
ij_typescript_prefer_parameters_wrap = false
ij_typescript_property_prefix =
ij_typescript_reformat_c_style_comments = false
ij_typescript_space_after_colon = true
ij_typescript_space_after_comma = true
ij_typescript_space_after_dots_in_rest_parameter = false
ij_typescript_space_after_generator_mult = true
ij_typescript_space_after_property_colon = true
ij_typescript_space_after_quest = true
ij_typescript_space_after_type_colon = true
ij_typescript_space_after_unary_not = false
ij_typescript_space_before_async_arrow_lparen = true
ij_typescript_space_before_catch_keyword = true
ij_typescript_space_before_catch_left_brace = true
ij_typescript_space_before_catch_parentheses = true
ij_typescript_space_before_class_lbrace = true
ij_typescript_space_before_class_left_brace = true
ij_typescript_space_before_colon = true
ij_typescript_space_before_comma = false
ij_typescript_space_before_do_left_brace = true
ij_typescript_space_before_else_keyword = true
ij_typescript_space_before_else_left_brace = true
ij_typescript_space_before_finally_keyword = true
ij_typescript_space_before_finally_left_brace = true
ij_typescript_space_before_for_left_brace = true
ij_typescript_space_before_for_parentheses = true
ij_typescript_space_before_for_semicolon = false
ij_typescript_space_before_function_left_parenth = true
ij_typescript_space_before_generator_mult = false
ij_typescript_space_before_if_left_brace = true
ij_typescript_space_before_if_parentheses = true
ij_typescript_space_before_method_call_parentheses = false
ij_typescript_space_before_method_left_brace = true
ij_typescript_space_before_method_parentheses = false
ij_typescript_space_before_property_colon = false
ij_typescript_space_before_quest = true
ij_typescript_space_before_switch_left_brace = true
ij_typescript_space_before_switch_parentheses = true
ij_typescript_space_before_try_left_brace = true
ij_typescript_space_before_type_colon = false
ij_typescript_space_before_unary_not = false
ij_typescript_space_before_while_keyword = true
ij_typescript_space_before_while_left_brace = true
ij_typescript_space_before_while_parentheses = true
ij_typescript_spaces_around_additive_operators = true
ij_typescript_spaces_around_arrow_function_operator = true
ij_typescript_spaces_around_assignment_operators = true
ij_typescript_spaces_around_bitwise_operators = true
ij_typescript_spaces_around_equality_operators = true
ij_typescript_spaces_around_logical_operators = true
ij_typescript_spaces_around_multiplicative_operators = true
ij_typescript_spaces_around_relational_operators = true
ij_typescript_spaces_around_shift_operators = true
ij_typescript_spaces_around_unary_operator = false
ij_typescript_spaces_within_array_initializer_brackets = false
ij_typescript_spaces_within_brackets = false
ij_typescript_spaces_within_catch_parentheses = false
ij_typescript_spaces_within_for_parentheses = false
ij_typescript_spaces_within_if_parentheses = false
ij_typescript_spaces_within_imports = true
ij_typescript_spaces_within_interpolation_expressions = false
ij_typescript_spaces_within_method_call_parentheses = false
ij_typescript_spaces_within_method_parentheses = false
ij_typescript_spaces_within_object_literal_braces = true
ij_typescript_spaces_within_object_type_braces = true
ij_typescript_spaces_within_parentheses = false
ij_typescript_spaces_within_switch_parentheses = false
ij_typescript_spaces_within_type_assertion = false
ij_typescript_spaces_within_union_types = true
ij_typescript_spaces_within_while_parentheses = false
ij_typescript_special_else_if_treatment = true
ij_typescript_ternary_operation_signs_on_next_line = false
ij_typescript_ternary_operation_wrap = off
ij_typescript_union_types_wrap = on_every_item
ij_typescript_use_chained_calls_group_indents = false
ij_typescript_use_double_quotes = true
ij_typescript_use_explicit_js_extension = never
ij_typescript_use_import_type = auto
ij_typescript_use_path_mapping = always
ij_typescript_use_public_modifier = false
ij_typescript_use_semicolon_after_statement = true
ij_typescript_var_declaration_wrap = normal
ij_typescript_while_brace_force = never
ij_typescript_while_on_new_line = false
ij_typescript_wrap_comments = false
[{*.htm,*.html,*.sht,*.shtm,*.shtml}]
ij_html_add_new_line_before_tags = body, div, p, form, h1, h2, h3
ij_html_align_attributes = true

BIN
.github/assets/chrome.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
.github/assets/firefox.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

BIN
.github/assets/groups-showcase.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
.github/assets/profiles-showcase.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

31
.github/workflows/build-and-tests.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Testing
on:
push:
branches:
- master
pull_request:
branches:
- master
- 'release/**'
jobs:
run-tests:
name: 'Run Unit Tests'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install npm dependencies
run: npm ci
- name: Building the extension
run: npm run build
- name: Running unit tests
run: npm run test

3
.gitignore vendored
View File

@@ -2,10 +2,11 @@
.DS_Store
node_modules
/build
/coverage
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
vite.config.ts.timestamp-*

View File

@@ -1,7 +1,9 @@
import {build} from "vite";
import {createHash} from "crypto";
import { build } from "vite";
import { createHash } from "crypto";
import path from "path";
import fs from "fs";
import { SwapDefinedVariablesPlugin } from "../plugins/swap-defined-variables.js";
import { ScssViteReadEnvVariableFunctionPlugin } from "../plugins/scss-read-env-variable-function.js";
/**
* Create the result base file name for the file.
@@ -48,6 +50,7 @@ function wrapScriptIntoIIFE() {
*/
function makeAliases(rootDir) {
return {
"$config": path.resolve(rootDir, 'src/config'),
"$lib": path.resolve(rootDir, 'src/lib'),
"$entities": path.resolve(rootDir, 'src/lib/extension/entities'),
"$styles": path.resolve(rootDir, 'src/styles'),
@@ -55,66 +58,187 @@ function makeAliases(rootDir) {
}
/**
* Build the selected script separately.
* @param {AssetBuildOptions} buildOptions Building options for the script.
* @return {Promise<string>} Result file path.
* @param {import('rollup').OutputChunk} chunk
* @param {import('rollup').OutputBundle} bundle
* @param {Set<import('rollup').OutputChunk>} processedChunks
* @return string[]
*/
export async function buildScript(buildOptions) {
const outputBaseName = createOutputBaseName(buildOptions.input);
function collectChunkDependencies(chunk, bundle, processedChunks = new Set()) {
if (processedChunks.has(chunk) || !chunk.imports) {
return [];
}
processedChunks.add(chunk);
return chunk.imports.concat(
chunk.imports
.map(importedChunkName => {
const module = bundle[importedChunkName];
if (module.type === 'chunk') {
return collectChunkDependencies(module, bundle, processedChunks);
}
return [];
})
.flat()
);
}
/**
* @param {(fileName: string, dependencies: string[]) => void} onDependencyResolvedCallback
* @returns {import('vite').Plugin}
*/
function collectDependenciesForManifestBuilding(onDependencyResolvedCallback) {
return {
name: 'extract-dependencies-for-content-scripts',
enforce: "post",
/**
* @param {any} options
* @param {import('rollup').OutputBundle} bundle
*/
writeBundle(options, bundle) {
Object.keys(bundle).forEach(fileName => {
const chunk = bundle[fileName];
if (chunk.type !== "chunk" || !chunk.facadeModuleId) {
return;
}
const dependencies = Array.from(
new Set(
collectChunkDependencies(chunk, bundle)
)
);
onDependencyResolvedCallback(fileName, dependencies);
});
}
}
}
/**
* Second revision of the building logic for the content scripts. This method tries to address duplication of
* dependencies generated with the previous method, where every single content script was built separately.
* @param {BatchBuildOptions} buildOptions
* @returns {Promise<Map<string, string[]>>}
*/
export async function buildScriptsAndStyles(buildOptions) {
/** @type {Map<string, string[]>} */
const pathsReplacement = new Map();
/** @type {Map<string, string[]>} */
const pathsReplacementByOutputPath = new Map();
const amdScriptsInput = {};
const libsAndStylesInput = {};
for (const inputPath of buildOptions.inputs) {
let outputExtension = path.extname(inputPath);
if (outputExtension === '.scss') {
outputExtension = '.css';
}
if (outputExtension === '.ts') {
outputExtension = '.js';
}
const outputPath = createOutputBaseName(inputPath);
const replacementsArray = [`${outputPath}${outputExtension}`];
pathsReplacement.set(inputPath, replacementsArray);
if (outputExtension === '.css' || inputPath.includes('/deps/')) {
libsAndStylesInput[outputPath] = inputPath;
continue;
}
pathsReplacementByOutputPath.set(outputPath + '.js', replacementsArray);
amdScriptsInput[outputPath] = inputPath;
}
const aliasesSettings = makeAliases(buildOptions.rootDir);
const defineConstants = {
__CURRENT_SITE__: JSON.stringify('furbooru'),
__CURRENT_SITE_NAME__: JSON.stringify('Furbooru'),
};
const derpibooruSwapPlugin = SwapDefinedVariablesPlugin({
envVariable: 'SITE',
expectedValue: 'derpibooru',
define: {
__CURRENT_SITE__: JSON.stringify('derpibooru'),
__CURRENT_SITE_NAME__: JSON.stringify('Derpibooru'),
}
});
// Building all scripts together with AMD loader in mind
await build({
configFile: false,
publicDir: false,
build: {
rollupOptions: {
input: {
[outputBaseName]: buildOptions.input
},
input: amdScriptsInput,
output: {
dir: buildOptions.outputDir,
entryFileNames: '[name].js'
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name]-[hash].js',
// ManifestV3 doesn't allow to use modern ES modules syntax, so we build all content scripts as AMD modules.
format: "amd",
inlineDynamicImports: false,
amd: {
// amd-lite requires names even for the entry-point scripts, so we should make sure to add those.
autoId: true,
},
// All these modules are not intended to be used outside of extension anyway
minifyInternalExports: true,
}
},
emptyOutDir: false,
},
resolve: {
alias: makeAliases(buildOptions.rootDir)
alias: aliasesSettings,
},
plugins: [
wrapScriptIntoIIFE()
]
wrapScriptIntoIIFE(),
collectDependenciesForManifestBuilding((fileName, dependencies) => {
pathsReplacementByOutputPath
.get(fileName)
?.push(...dependencies);
}),
derpibooruSwapPlugin,
],
define: defineConstants,
});
return path.resolve(buildOptions.outputDir, `${outputBaseName}.js`);
}
/**
* Build the selected stylesheet.
* @param {AssetBuildOptions} buildOptions Build options for the stylesheet.
* @return {Promise<string>} Result file path.
*/
export async function buildStyle(buildOptions) {
const outputBaseName = createOutputBaseName(buildOptions.input);
// Build styles separately because AMD converts styles to JS files.
await build({
configFile: false,
publicDir: false,
build: {
rollupOptions: {
input: {
[outputBaseName]: buildOptions.input
},
input: libsAndStylesInput,
output: {
dir: buildOptions.outputDir,
entryFileNames: '[name].js',
assetFileNames: '[name].[ext]',
}
},
emptyOutDir: false,
}
emptyOutDir: false
},
resolve: {
alias: aliasesSettings,
},
plugins: [
wrapScriptIntoIIFE(),
ScssViteReadEnvVariableFunctionPlugin(),
derpibooruSwapPlugin,
],
define: defineConstants,
});
return path.resolve(buildOptions.outputDir, `${outputBaseName}.css`);
return pathsReplacement;
}
/**
@@ -123,3 +247,11 @@ export async function buildStyle(buildOptions) {
* @property {string} outputDir Destination folder for the script.
* @property {string} rootDir Root directory of the repository.
*/
/**
* @typedef {Object} BatchBuildOptions
* @property {Set<string>} inputs Set of all scripts and styles to build.
* @property {string} outputDir Destination folder for the assets.
* @property {string} rootDir Root directory of the repository.
* @property {(fileName: string, dependencies: string[]) => void} onDependenciesResolved Callback for dependencies.
*/

View File

@@ -17,6 +17,38 @@ class ManifestProcessor {
this.#manifestObject = parsedManifest;
}
/**
* Collect all the content scripts & stylesheets for single build action.
*
* @returns {Set<string>}
*/
collectContentScripts() {
const contentScripts = this.#manifestObject.content_scripts;
if (!contentScripts) {
console.info('No content scripts to collect.');
return new Set();
}
const entryPoints = new Set();
for (let entry of contentScripts) {
if (entry.js) {
for (let jsPath of entry.js) {
entryPoints.add(jsPath);
}
}
if (entry.css) {
for (let cssPath of entry.css) {
entryPoints.add(cssPath);
}
}
}
return entryPoints;
}
/**
* Map over every content script defined in the manifest. If no content scripts defined, no calls will be made to the
* callback.
@@ -54,6 +86,53 @@ class ManifestProcessor {
}
}
/**
* Find all patterns in content scripts and host permissions and replace the hostname to the different one.
*
* @param {string|string[]} singleOrMultipleHostnames One or multiple hostnames to replace the original hostname with.
*/
replaceHostTo(singleOrMultipleHostnames) {
if (typeof singleOrMultipleHostnames === 'string') {
singleOrMultipleHostnames = [singleOrMultipleHostnames];
}
this.#manifestObject.host_permissions = singleOrMultipleHostnames.map(hostname => `*://*.${hostname}/`);
this.#manifestObject.content_scripts?.forEach(entry => {
entry.matches = entry.matches.reduce((resultMatches, originalMatchPattern) => {
for (const updatedHostname of singleOrMultipleHostnames) {
resultMatches.push(
originalMatchPattern.replace(
/\*:\/\/\*\.[a-z]+\.[a-z]+\//,
`*://*.${updatedHostname}/`
),
);
}
return resultMatches;
}, []);
})
}
/**
* Set different identifier for Gecko-based browsers (Firefox).
*
* @param {string} id ID of the extension to use.
*/
setGeckoIdentifier(id) {
this.#manifestObject.browser_specific_settings.gecko.id = id;
}
/**
* Set the different extension name.
*
* @param {string} booruName
*/
replaceBooruNameWith(booruName) {
this.#manifestObject.name = this.#manifestObject.name.replaceAll('Furbooru', booruName);
this.#manifestObject.description = this.#manifestObject.description.replaceAll('Furbooru', booruName);
}
/**
* Save the current state of the manifest into the selected file.
*
@@ -86,13 +165,27 @@ export function loadManifest(filePath) {
/**
* @typedef {Object} Manifest
* @property {string} name
* @property {string} description
* @property {string} version
* @property {BrowserSpecificSettings} browser_specific_settings
* @property {string[]} host_permissions
* @property {ContentScriptsEntry[]|undefined} content_scripts
*/
/**
* @typedef {Object} BrowserSpecificSettings
* @property {GeckoSettings} gecko
*/
/**
* @typedef {Object} GeckoSettings
* @property {string} id
*/
/**
* @typedef {Object} ContentScriptsEntry
* @property {string[]} mathces
* @property {string[]} matches
* @property {string[]|undefined} js
* @property {string[]|undefined} css
*/

View File

@@ -1,8 +1,8 @@
import {loadManifest} from "./lib/manifest.js";
import { loadManifest } from "./lib/manifest.js";
import path from "path";
import {buildScript, buildStyle} from "./lib/content-scripts.js";
import {normalizePath} from "vite";
import {extractInlineScriptsFromIndex} from "./lib/index-file.js";
import { buildScriptsAndStyles } from "./lib/content-scripts.js";
import { extractInlineScriptsFromIndex } from "./lib/index-file.js";
import { normalizePath } from "vite";
/**
* Build addition assets required for the extension and pack it into the directory.
@@ -11,45 +11,70 @@ import {extractInlineScriptsFromIndex} from "./lib/index-file.js";
export async function packExtension(settings) {
const manifest = loadManifest(path.resolve(settings.rootDir, 'manifest.json'));
// Since we CAN'T really build all scripts and stylesheets in a single build entry, we will run build for every single
// one of them in a row. This way, no chunks will be generated. Thanks, ManifestV3!
await manifest.mapContentScripts(async (entry) => {
if (entry.js) {
for (let scriptIndex = 0; scriptIndex < entry.js.length; scriptIndex++) {
const builtScriptFilePath = await buildScript({
input: path.resolve(settings.rootDir, entry.js[scriptIndex]),
outputDir: settings.contentScriptsDir,
rootDir: settings.rootDir,
});
const replacementMapping = await buildScriptsAndStyles({
inputs: manifest.collectContentScripts(),
outputDir: settings.contentScriptsDir,
rootDir: settings.rootDir,
});
entry.js[scriptIndex] = normalizePath(
path.relative(
settings.exportDir,
builtScriptFilePath
await manifest.mapContentScripts(async entry => {
if (entry.js) {
entry.js = entry.js
.map(jsSourcePath => {
if (!replacementMapping.has(jsSourcePath)) {
return [];
}
return replacementMapping.get(jsSourcePath);
})
.flat(1)
.map(pathName => {
return normalizePath(
path.relative(
settings.exportDir,
path.join(
settings.contentScriptsDir,
pathName
)
)
)
);
}
});
}
if (entry.css) {
for (let styleIndex = 0; styleIndex < entry.css.length; styleIndex++) {
const builtStylesheetFilePath = await buildStyle({
input: path.resolve(settings.rootDir, entry.css[styleIndex]),
outputDir: settings.contentScriptsDir,
rootDir: settings.rootDir
});
entry.css = entry.css
.map(jsSourcePath => {
if (!replacementMapping.has(jsSourcePath)) {
return [];
}
entry.css[styleIndex] = normalizePath(
path.relative(
settings.exportDir,
builtStylesheetFilePath
return replacementMapping.get(jsSourcePath);
})
.flat(1)
.map(pathName => {
return normalizePath(
path.relative(
settings.exportDir,
path.join(
settings.contentScriptsDir,
pathName
)
)
)
);
}
})
}
return entry;
});
})
if (process.env.SITE === 'derpibooru') {
manifest.replaceHostTo([
'derpibooru.org',
'trixiebooru.org'
]);
manifest.replaceBooruNameWith('Derpibooru');
manifest.setGeckoIdentifier('derpibooru-tagging-assistant@thecore.city');
}
manifest.passVersionFromPackage(path.resolve(settings.rootDir, 'package.json'));
manifest.saveTo(path.resolve(settings.exportDir, 'manifest.json'));

View File

@@ -0,0 +1,46 @@
import { SassString, Value } from "sass";
/**
* @return {import('vite').Plugin}
*/
export function ScssViteReadEnvVariableFunctionPlugin() {
return {
name: 'koloml:scss-read-env-variable-function',
apply: 'build',
enforce: 'post',
configResolved: config => {
config.css.preprocessorOptions ??= {};
config.css.preprocessorOptions.scss ??= {};
config.css.preprocessorOptions.scss.functions ??= {};
/**
* @param {Value[]} args
* @return {SassString}
*/
config.css.preprocessorOptions.scss.functions['vite-read-env-variable($constant-name)'] = (args) => {
const constName = args[0].assertString('constant-name').text;
if (config.define && config.define.hasOwnProperty(constName)) {
let returnedValue = config.define[constName];
try {
returnedValue = JSON.parse(returnedValue);
} catch {
returnedValue = null;
}
if (typeof returnedValue !== 'string') {
console.warn(`Attempting to read the constant with non-string type: ${constName}`);
return new SassString('');
}
return new SassString(returnedValue);
}
console.warn(`Constant does not exist: ${constName}`);
return new SassString('');
}
}
}
}

View File

@@ -0,0 +1,28 @@
/**
* @param {SwapDefinedVariablesSettings} settings
* @return {import('vite').Plugin}
*/
export function SwapDefinedVariablesPlugin(settings) {
return {
name: 'koloml:swap-defined-variables',
enforce: 'post',
configResolved: (config) => {
if (
config.define
&& process.env.hasOwnProperty(settings.envVariable)
&& process.env[settings.envVariable] === settings.expectedValue
) {
for (const [key, value] of Object.entries(settings.define)) {
config.define[key] = value;
}
}
}
}
}
/**
* @typedef {Object} SwapDefinedVariablesSettings
* @property {string} envVariable
* @property {string} expectedValue
* @property {Record<string, string>} define
*/

View File

@@ -1,4 +1,74 @@
# Furbooru Tagging Assistant
# Philomena Tagging Assistant
This is a browser extension written for the [Furbooru](https://furbooru.org) image-board. It gives you the ability to
tag the images more easily and quickly.
This is a browser extension written for the [Furbooru](https://furbooru.org) and [Derpibooru](https://derpibooru.org)
image-boards. It gives you the ability to manually go over the list of images and apply tags to them without opening
each individual image.
## Installation
This extension is available for both Chromium- and Firefox-based browsers. You can find the links to the extension pages
below.
### Furbooru Tagging Assistant
[![Get the Add-on on Firefox](.github/assets/firefox.png)](https://addons.mozilla.org/en-US/firefox/addon/furbooru-tagging-assistant/)
[![Get the extension on Chrome](.github/assets/chrome.png)](https://chromewebstore.google.com/detail/kpgaphaooaaodgodmnkamhmoedjcnfkj)
### Derpibooru Tagging Assistant
[![Get the Add-on on Firefox](.github/assets/firefox.png)](https://addons.mozilla.org/en-US/firefox/addon/derpibooru-tagging-assistant/)
[![Get the extension on Chrome](.github/assets/chrome.png)](https://chromewebstore.google.com/detail/pnmbomcdbfcghgmegklfofncfigdielb)
## Features
### Tagging Profiles
Select a set of tags and add/remove them from images without opening them. Just hover over image, click on tags and
you're done!
![Tagging Profiles Showcase](.github/assets/profiles-showcase.png)
### Custom Tag Groups
Customize the list of tags with your own custom tag groups. Apply custom colors to different groups or even separate
them from each other with group titles.
![Tag Groups Showcase](.github/assets/groups-showcase.png)
### Fullscreen Viewer
Open up the specific image or video in fullscreen mode by clicking 🔍 icon in the bottom left corner of the image. This
feature is opt-in and should be enabled in the settings first.
![Fullscreen Viewer Icon](.github/assets/fullscreen-viewer-icon.png)
![Fullscreen Viewer Showcase](.github/assets/fullscreen-viewer-showcase.png)
## Building
Recommendations on environment:
- Recommended version of Node.js: LTS (20)
First you need to clone the repository and install all packages:
```shell
npm install --save-dev
```
Second, you need to run the `build` command. It will first build the popup using SvelteKit and then build all the
content scripts/stylesheets and copy the manifest afterward.
Extension can currently be built for 2 different imageboards using one of the following commands:
```shell
# To build the extension for Furbooru, use:
npm run build
# To build the extension for Derpbooru, use:
npm run build:derpibooru
```
When build is complete, extension files can be found in the `/build` directory. These files can be either used
directly in Chrome (via loading the extension as unpacked extension) or manually compressed into `*.zip` file and loaded
into Firefox.

View File

@@ -1,18 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias and https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

View File

@@ -1,7 +1,12 @@
{
"name": "Furbooru Tagging Assistant",
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
"version": "0.1.0",
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
"version": "0.5.1",
"browser_specific_settings": {
"gecko": {
"id": "furbooru-tagging-assistant@thecore.city"
}
},
"icons": {
"16": "icon16.png",
"48": "icon48.png",
@@ -13,15 +18,24 @@
"*://*.furbooru.org/"
],
"content_scripts": [
{
"matches": [
"*://*.furbooru.org/*"
],
"js": [
"src/content/deps/amd.ts"
]
},
{
"matches": [
"*://*.furbooru.org/",
"*://*.furbooru.org/images?*",
"*://*.furbooru.org/search?*",
"*://*.furbooru.org/tags/*"
"*://*.furbooru.org/tags/*",
"*://*.furbooru.org/galleries/*"
],
"js": [
"src/content/listing.js"
"src/content/listing.ts"
],
"css": [
"src/styles/content/listing.scss"
@@ -29,13 +43,31 @@
},
{
"matches": [
"*://*.furbooru.org/*"
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/header.js"
"src/content/tags-editor.ts"
],
"css": [
"src/styles/content/header.scss"
"src/styles/content/tags-editor.scss"
]
},
{
"matches": [
"*://*.furbooru.org/images?*",
"*://*.furbooru.org/images/*",
"*://*.furbooru.org/images/*/tag_changes",
"*://*.furbooru.org/images/*/tag_changes?*",
"*://*.furbooru.org/search?*",
"*://*.furbooru.org/tags",
"*://*.furbooru.org/tags?*",
"*://*.furbooru.org/tags/*",
"*://*.furbooru.org/profiles/*/tag_changes",
"*://*.furbooru.org/profiles/*/tag_changes?*",
"*://*.furbooru.org/filters/*"
],
"js": [
"src/content/tags.ts"
]
}
],

7230
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,38 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.1.0",
"version": "0.5.1",
"private": true,
"scripts": {
"build": "npm run build:popup && npm run build:extension",
"build:derpibooru": "cross-env SITE=derpibooru npm run build",
"build:popup": "vite build",
"build:extension": "node build-extension.js",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run --coverage",
"test:watch": "vitest watch --coverage"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/chrome": "^0.0.262",
"cheerio": "^1.0.0-rc.12",
"sass": "^1.71.0",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"typescript": "^5.0.0",
"vite": "^5.0.3"
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.21.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.326",
"@types/node": "^22.15.29",
"@vitest/coverage-v8": "^3.2.0",
"cheerio": "^1.0.0",
"cross-env": "^10.0.0",
"jsdom": "^26.1.0",
"sass": "^1.89.1",
"svelte": "^5.33.14",
"svelte-check": "^4.2.1",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.2.0"
},
"type": "module"
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"amd-lite": "^1.0.1",
"lz-string": "^1.5.0"
}
}

52
src/app.d.ts vendored
View File

@@ -1,13 +1,53 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
import MaintenanceProfile from "$entities/MaintenanceProfile";
import type TagGroup from "$entities/TagGroup";
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
/**
* Identifier of the current site this extension is built for.
*/
const __CURRENT_SITE__: string;
/**
* Name of the site.
*/
const __CURRENT_SITE_NAME__: string;
// Helper type to not deal with differences between the setTimeout of @types/node and usual web browser's type.
type Timeout = ReturnType<typeof setTimeout>;
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
type LinkTarget = "_blank" | "_self" | "_parent" | "_top";
type IconName = (
"tag"
| "paint-brush"
| "arrow-left"
| "info-circle"
| "wrench"
| "globe"
| "plus"
| "file-export"
| "trash"
);
interface EntityNamesMap {
profiles: MaintenanceProfile;
groups: TagGroup;
}
interface ImageURIs {
full: string;
large: string;
medium: string;
small: string;
}
}
}
export {};

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { storagesCollection } from "$stores/debug";
import { goto } from "$app/navigation";
import { findDeepObject } from "$lib/utils";
interface StorageViewerProps {
storage: string;
path: string[];
}
type BreadcrumbsArray = [string, string][];
let { storage, path }: StorageViewerProps = $props();
let breadcrumbs = $derived.by<BreadcrumbsArray>(() => {
return path.reduce<BreadcrumbsArray>((resultCrumbs, entry) => {
let entryPath = entry;
if (resultCrumbs.length) {
entryPath = resultCrumbs[resultCrumbs.length - 1][1] + "/" + entryPath;
}
resultCrumbs.push([entry, entryPath]);
return resultCrumbs;
}, [])
});
let targetStorage = $derived.by<object|null>(() => {
return $storagesCollection[storage];
});
let targetObject = $derived.by<Record<string, any> | null>(() => {
return targetStorage
? findDeepObject(targetStorage, path)
: null;
});
let targetPathString = $derived.by<string>(() => {
let pathString = path.join("/");
if (pathString.length) {
pathString += "/";
}
return pathString;
});
$effect(() => {
if (!targetStorage) {
goto("/preferences/debug/storage");
}
});
/**
* Helper function to resolve type, including the null.
* @param value Value to resolve type from.
* @return Type of the value, including "null" for null.
*/
function resolveType(value: unknown): string {
let typeName: string = typeof value;
if (typeName === 'object' && value === null) {
typeName = 'null';
}
return typeName;
}
/**
* Helper function to resolve value, including values like null or undefined.
* @param value Value to resolve.
* @return String representation of the value.
*/
function resolveValue(value: unknown): string {
if (value === null) {
return "null";
}
if (value === undefined) {
return "undefined";
}
return value?.toString() ?? '';
}
</script>
<Menu>
<MenuItem href="/preferences/debug/storage" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<p class="path">
<span>/ <a href="/preferences/debug/storage/{storage}">{storage}</a></span>
{#each breadcrumbs as [name, entryPath]}
<span>/ <a href="/preferences/debug/storage/{storage}/{entryPath}/">{name}</a></span>
{/each}
</p>
{#if targetObject}
<Menu>
<hr>
{#each Object.entries(targetObject) as [key, _]}
{#if targetObject[key] && typeof targetObject[key] === 'object'}
<MenuItem href="/preferences/debug/storage/{storage}/{targetPathString}{key}">
{key}: Object
</MenuItem>
{:else}
<MenuItem>
{key}: {resolveType(targetObject[key])} = {resolveValue(targetObject[key])}
</MenuItem>
{/if}
{/each}
</Menu>
{/if}
<style lang="scss">
.path {
display: flex;
flex-wrap: wrap;
column-gap: .5em;
}
</style>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
import type TagGroup from "$entities/TagGroup";
interface GroupViewProps {
group: TagGroup;
}
let { group }: GroupViewProps = $props();
let sortedTagsList = $derived<string[]>(group.settings.tags.sort((a, b) => a.localeCompare(b))),
sortedPrefixes = $derived<string[]>(group.settings.prefixes.sort((a, b) => a.localeCompare(b))),
sortedSuffixes = $derived<string[]>(group.settings.suffixes.sort((a, b) => a.localeCompare(b)));
</script>
<div class="block">
<strong>Group Name:</strong>
<div>{group.settings.name}</div>
</div>
{#if sortedTagsList.length}
<div class="block">
<strong>Tags:</strong>
<TagsColorContainer targetCategory={group.settings.category}>
<div class="tags-list">
{#each sortedTagsList as tagName}
<span class="tag">{tagName}</span>
{/each}
</div>
</TagsColorContainer>
</div>
{/if}
{#if sortedPrefixes.length}
<div class="block">
<strong>Prefixes:</strong>
<TagsColorContainer targetCategory={group.settings.category}>
<div class="tags-list">
{#each sortedPrefixes as prefixName}
<span class="tag">{prefixName}*</span>
{/each}
</div>
</TagsColorContainer>
</div>
{/if}
{#if sortedSuffixes.length}
<div class="block">
<strong>Suffixes:</strong>
<TagsColorContainer targetCategory={group.settings.category}>
<div class="tags-list">
{#each sortedSuffixes as suffixName}
<span class="tag">*{suffixName}</span>
{/each}
</div>
</TagsColorContainer>
</div>
{/if}
<style lang="scss">
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.block + .block {
margin-top: .5em;
strong {
display: block;
margin-bottom: .25em;
}
}
</style>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import type MaintenanceProfile from "$entities/MaintenanceProfile";
interface ProfileViewProps {
profile: MaintenanceProfile;
}
let { profile }: ProfileViewProps = $props();
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));
</script>
<div class="block">
<strong>Profile:</strong>
<div>{profile.settings.name}</div>
</div>
<div class="block">
<strong>Tags:</strong>
<div class="tags-list">
{#each sortedTagsList as tagName}
<span class="tag">{tagName}</span>
{/each}
</div>
</div>
<style lang="scss">
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.block + .block {
margin-top: .5em;
strong {
display: block;
margin-bottom: .25em;
}
}
</style>

View File

@@ -1,19 +1,32 @@
<script>
import {version} from "$app/environment";
import { version } from "$app/environment";
</script>
<footer>
v{version}, made with ♥ by KoloMl.
<a href="https://github.com/koloml/furbooru-tagging-assistant/releases/tag/{version}" target="_blank">
v{version}
</a>
<span>, made with ♥ by KoloMl.</span>
</footer>
<style lang="scss">
@use 'src/styles/colors';
@use '$styles/colors';
footer {
background: colors.$footer;
color: colors.$footer-text;
padding: 0 24px;
font-size: 12px;
line-height: 36px;
footer {
display: flex;
width: 100%;
background: colors.$footer;
color: colors.$footer-text;
padding: 0 24px;
font-size: 12px;
line-height: 36px;
a {
color: inherit;
&:hover {
text-decoration: underline;
}
}
}
</style>

View File

@@ -1,28 +1,29 @@
<header>
<a href="/">Furbooru Tagging Assistant</a>
<a href="/">{__CURRENT_SITE_NAME__} Tagging Assistant</a>
</header>
<style lang="scss">
@use "src/styles/colors";
@use "$styles/colors";
header {
background: colors.$header;
padding: 0 24px;
display: flex;
position: sticky;
top: 0;
left: 0;
right: 0;
header {
background: colors.$header;
padding: 0 24px;
display: flex;
position: sticky;
top: 0;
left: 0;
right: 0;
z-index: 10;
a {
color: colors.$text;
line-height: 36px;
padding: 0 12px;
margin-left: -12px;
a {
color: colors.$text;
line-height: 36px;
padding: 0 12px;
margin-left: -12px;
&:hover {
background: colors.$header-hover-background;
}
}
&:hover {
background: colors.$header-hover-background;
}
}
}
</style>

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface TagColorContainerProps {
targetCategory?: string;
children?: Snippet;
}
let { targetCategory = '', children }: TagColorContainerProps = $props();
</script>
<div class="tag-color-container tag-color-container--{targetCategory || 'default'}">
{@render children?.()}
</div>
<style lang="scss">
@use '$styles/colors';
.tag-color-container:is(:global(.tag-color-container--rating)) :global(.tag) {
background-color: colors.$tag-rating-background;
color: colors.$tag-rating-text;
border-color: colors.$tag-rating-border;
}
.tag-color-container:is(:global(.tag-color-container--spoiler)) :global(.tag) {
background-color: colors.$tag-spoiler-background;
color: colors.$tag-spoiler-text;
border-color: colors.$tag-spoiler-border;
}
.tag-color-container:is(:global(.tag-color-container--origin)) :global(.tag) {
background-color: colors.$tag-origin-background;
color: colors.$tag-origin-text;
border-color: colors.$tag-origin-border;
}
.tag-color-container:is(:global(.tag-color-container--oc)) :global(.tag) {
background-color: colors.$tag-oc-background;
color: colors.$tag-oc-text;
border-color: colors.$tag-oc-border;
}
.tag-color-container:is(:global(.tag-color-container--error)) :global(.tag) {
background-color: colors.$tag-error-background;
color: colors.$tag-error-text;
border-color: colors.$tag-error-border;
}
.tag-color-container:is(:global(.tag-color-container--character)) :global(.tag) {
background-color: colors.$tag-character-background;
color: colors.$tag-character-text;
border-color: colors.$tag-character-border;
}
.tag-color-container:is(:global(.tag-color-container--content-official)) :global(.tag) {
background-color: colors.$tag-content-official-background;
color: colors.$tag-content-official-text;
border-color: colors.$tag-content-official-border;
}
.tag-color-container:is(:global(.tag-color-container--content-fanmade)) :global(.tag) {
background-color: colors.$tag-content-fanmade-background;
color: colors.$tag-content-fanmade-text;
border-color: colors.$tag-content-fanmade-border;
}
.tag-color-container:is(:global(.tag-color-container--species)) :global(.tag) {
background-color: colors.$tag-species-background;
color: colors.$tag-species-text;
border-color: colors.$tag-species-border;
}
.tag-color-container:is(:global(.tag-color-container--body-type)) :global(.tag) {
background-color: colors.$tag-body-type-background;
color: colors.$tag-body-type-text;
border-color: colors.$tag-body-type-border;
}
</style>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import type { EventHandler } from "svelte/elements";
interface TagEditorProps {
// List of tags to edit. Any duplicated tags present in the array will be removed on the first edit.
tags?: string[];
mapTagNames?: (tagName: string) => string;
}
let {
tags = $bindable([]),
mapTagNames,
}: TagEditorProps = $props();
let uniqueTags = $state<Set<string>>(new Set());
$effect.pre(() => {
uniqueTags = new Set(tags);
});
let addedTagName = $state<string>('');
/**
* Create a callback function to pass into both mouse & keyboard events for tag removal.
* @param tagName Name to remove when clicked.
* @return Callback to pass as event listener.
*/
function createTagRemoveHandler(tagName: string): EventHandler<Event, HTMLElement> {
return event => {
if (event.type === 'click') {
removeTag(tagName);
}
if (event instanceof KeyboardEvent && (event.code === 'Enter' || event.code === 'Space')) {
// To be more comfortable, automatically focus next available tag's remove button in the list.
if (event.currentTarget instanceof HTMLElement) {
const currenTagElement = event.currentTarget.closest('.tag');
const nextTagElement = currenTagElement?.previousElementSibling ?? currenTagElement?.parentElement?.firstElementChild;
const nextRemoveButton = nextTagElement?.querySelector('.remove');
if (nextRemoveButton instanceof HTMLElement) {
nextRemoveButton.focus();
}
}
removeTag(tagName);
}
}
}
/**
* Remove the tag from the set.
* @param tagName Name of the tag to remove.
*/
function removeTag(tagName: string) {
uniqueTags.delete(tagName);
tags = Array.from(uniqueTags);
}
/**
* Add the tag to the set.
* @param tagName Name of the tag to add.
*/
function addTag(tagName: string) {
uniqueTags.add(tagName);
tags = Array.from(uniqueTags);
}
/**
* Handle adding new tags to the list or removing them when backspace is pressed.
*
* Additional note: For some reason, mobile Chrome breaks the usual behaviour inside extension. `code` is becoming
* empty, while usually it should contain proper button code.
*
* @param event
*/
function handleKeyPresses(event: KeyboardEvent) {
if ((event.code === 'Enter' || event.key === 'Enter') && addedTagName.length) {
addTag(addedTagName)
addedTagName = '';
}
if ((event.code === 'Backspace' || event.key === 'Backspace') && !addedTagName.length && tags?.length) {
removeTag(tags[tags.length - 1]);
}
}
</script>
<div class="tags-editor">
{#each uniqueTags.values() as tagName}
<div class="tag">
{mapTagNames?.(tagName) ?? tagName}
<span class="remove" onclick={createTagRemoveHandler(tagName)}
onkeydown={createTagRemoveHandler(tagName)}
role="button" tabindex="0">x</span>
</div>
{/each}
<input autocapitalize="none"
autocomplete="off"
bind:value={addedTagName}
onkeydown={handleKeyPresses}
type="text"/>
</div>
<style lang="scss">
.tags-editor {
display: flex;
flex-wrap: wrap;
gap: 6px;
input {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface CheckboxFieldProps {
name?: string;
checked: boolean;
children?: Snippet;
}
let {
name = undefined,
checked = $bindable(),
children
}: CheckboxFieldProps = $props();
</script>
<input bind:checked={checked} {name} type="checkbox">
<span>
{@render children?.()}
</span>

View File

@@ -1,11 +1,21 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface FormContainerProps {
children?: Snippet;
}
let { children }: FormContainerProps = $props();
</script>
<form>
<slot></slot>
{@render children?.()}
</form>
<style lang="scss">
form {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
form {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
</style>

View File

@@ -1,16 +1,35 @@
<script>
<script lang="ts">
import type { Snippet } from "svelte";
/** @type {string|undefined} */
export let label;
interface FormControlProps {
label?: string;
children?: Snippet;
}
let {
label = undefined,
children
}: FormControlProps = $props();
</script>
<label class="control">
{#if label}
<div class="label">{label}</div>
{/if}
<slot></slot>
{#if label}
<div class="label">{label}</div>
{/if}
{@render children?.()}
</label>
<style lang="scss">
.label {
margin-bottom: .5em;
}
.control {
padding: 5px 0;
:global(textarea) {
width: 100%;
resize: vertical;
}
}
</style>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
type SelectFieldOptionsObject = Record<string, string>;
interface SelectFieldProps {
options?: string[] | SelectFieldOptionsObject;
name?: string;
id?: string;
value?: string;
}
let {
options = [],
name = undefined,
id = undefined,
value = $bindable(undefined)
}: SelectFieldProps = $props();
const optionPairs = $derived.by<SelectFieldOptionsObject>(() => {
const resultPairs: SelectFieldOptionsObject = {};
if (Array.isArray(options)) {
for (let optionName of options) {
resultPairs[optionName] = optionName;
}
} else if (options && typeof options === 'object') {
Object.keys(options).forEach(optionKey => {
resultPairs[optionKey] = options[optionKey];
})
}
return resultPairs;
});
</script>
<select bind:value={value} {id} {name}>
{#each Object.entries(optionPairs) as [value, label]}
<option {value}>{label}</option>
{/each}
</select>
<style lang="scss">
select {
width: 100%;
}
</style>

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import SelectField from "$components/ui/forms/SelectField.svelte";
import { categories } from "$lib/booru/tag-categories";
interface TagCategorySelectFieldProps {
value?: string;
}
let {
value = $bindable('')
}: TagCategorySelectFieldProps = $props();
let tagCategoriesOptions = $derived.by<Record<string, string>>(() => {
return categories.reduce<Record<string, string>>((options, category) => {
options[category] = category
.replace('-', ' ')
.replace(/(?<=\s|^)\w/g, (matchedCharacter) => matchedCharacter.toUpperCase());
return options;
}, {
'': 'Default'
})
});
</script>
<SelectField bind:value={value} name="tag_color" options={tagCategoriesOptions}/>
<style lang="scss">
@use '$styles/colors';
:global(select[name=tag_color]) {
:global(option) {
&:is(:global([value=rating])) {
background-color: colors.$tag-rating-background;
color: colors.$tag-rating-text;
}
&:is(:global([value=spoiler])) {
background-color: colors.$tag-spoiler-background;
color: colors.$tag-spoiler-text;
}
&:is(:global([value=origin])) {
background-color: colors.$tag-origin-background;
color: colors.$tag-origin-text;
}
&:is(:global([value=oc])) {
background-color: colors.$tag-oc-background;
color: colors.$tag-oc-text;
}
&:is(:global([value=error])) {
background-color: colors.$tag-error-background;
color: colors.$tag-error-text;
}
&:is(:global([value=character])) {
background-color: colors.$tag-character-background;
color: colors.$tag-character-text;
}
&:is(:global([value=content-official])) {
background-color: colors.$tag-content-official-background;
color: colors.$tag-content-official-text;
}
&:is(:global([value=content-fanmade])) {
background-color: colors.$tag-content-fanmade-background;
color: colors.$tag-content-fanmade-text;
}
&:is(:global([value=species])) {
background-color: colors.$tag-species-background;
color: colors.$tag-species-text;
}
&:is(:global([value=body-type])) {
background-color: colors.$tag-body-type-background;
color: colors.$tag-body-type-text;
}
}
}
</style>

View File

@@ -1,12 +1,21 @@
<script>
/** @type {string|undefined} */
export let name = undefined;
<script lang="ts">
interface TextFieldProps {
name?: string;
placeholder?: string;
value?: string;
}
/** @type {string|undefined} */
export let placeholder = undefined;
/** @type {string} */
export let value = '';
let {
name = undefined,
placeholder = undefined,
value = $bindable('')
}: TextFieldProps = $props();
</script>
<input type="text" {name} {placeholder} bind:value={value}>
<input bind:value={value} {name} {placeholder} type="text">
<style lang="scss">
:global(.control) input {
width: 100%;
}
</style>

View File

@@ -1,38 +1,46 @@
<script lang="ts">
interface MenuProps {
children?: import('svelte').Snippet;
}
let { children }: MenuProps = $props();
</script>
<nav>
<slot></slot>
{@render children?.()}
</nav>
<style lang="scss">
@use 'src/styles/colors';
@use '$styles/colors';
nav {
display: flex;
flex-direction: column;
nav {
display: flex;
flex-direction: column;
& > :global(a) {
padding: 5px 24px;
}
:global(a) {
color: colors.$text;
&:hover {
background: colors.$header-mobile-link-hover;
}
}
:global(hr) {
background: colors.$block-border;
margin: .5em 24px;
border: 0;
height: 1px;
}
:global(main) > & {
margin: {
left: -24px;
right: -24px;
}
}
& > :global(.menu-item) {
padding: 5px 24px;
}
:global(.menu-item) {
color: colors.$text;
&:hover {
background: colors.$header-mobile-link-hover;
}
}
:global(hr) {
background: colors.$block-border;
margin: .5em 24px;
border: 0;
height: 1px;
}
:global(main) > & {
margin: {
left: -24px;
right: -24px;
}
}
}
</style>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import MenuLink from "$components/ui/menu/MenuItem.svelte";
import type { Snippet } from "svelte";
import type { FormEventHandler, MouseEventHandler } from "svelte/elements";
interface MenuCheckboxItemProps {
checked: boolean;
name?: string;
value?: string;
href?: string;
children?: Snippet;
/**
* Click event received by the checkbox input element.
*/
onclick?: MouseEventHandler<HTMLInputElement>;
oninput?: FormEventHandler<HTMLInputElement>;
/**
* Click event received by the menu item instead of the checkbox element.
*/
onitemclick?: MouseEventHandler<HTMLElement>;
}
let checkboxElement: HTMLInputElement;
let {
checked = $bindable(),
name = undefined,
value = undefined,
href = undefined,
children,
onclick,
oninput,
onitemclick,
}: MenuCheckboxItemProps = $props();
/**
* Prevent clicks from getting sent to the menu link if user clicked directly on the checkbox.
* @param originalEvent
*/
function stopPropagationAndPassCallback(originalEvent: MouseEvent) {
originalEvent.stopPropagation();
onclick?.(originalEvent as MouseEvent & { currentTarget: HTMLInputElement });
}
/**
* Check and try to toggle checkbox if href was not provided for the menu item.
*/
function maybeToggleCheckboxOnOuterLinkClicked(event: MouseEvent) {
// Call the event handler if present.
if (onitemclick) {
onitemclick(event as MouseEvent & {currentTarget: HTMLElement});
// If it was prevented, then don't attempt to run checkbox toggling workaround.
if (event.defaultPrevented) {
return;
}
}
// When menu link does not contain any link, we should just treat clicks on it as toggle action on checkbox.
if (!href) {
checked = !checked;
// Since we've toggled it using the `checked` property and input does not trigger `onclick` when we do something
// programmatically, we should create valid event and send it back to the parent component so it will handle
// whatever it wants.
if (oninput) {
// Uhh, not sure if this is how it should be done, but we need `currentTarget` to point on the checkbox. Without
// dispatching the event, we can't fill it normally. Also, input element does not return us untrusted input
// events automatically. Probably should make the util function later in case I'd need something similar.
checkboxElement.addEventListener('input', (inputEvent: Event) => {
oninput(inputEvent as Event & { currentTarget: HTMLInputElement });
}, { once: true })
checkboxElement.dispatchEvent(new InputEvent('input'));
}
}
}
</script>
<MenuLink {href} onclick={maybeToggleCheckboxOnOuterLinkClicked}>
<input bind:this={checkboxElement}
bind:checked={checked}
{name}
onclick={stopPropagationAndPassCallback}
{oninput}
type="checkbox"
{value}>
{@render children?.()}
</MenuLink>
<style lang="scss">
:global(.menu-item) input {
width: 16px;
height: 16px;
margin-right: 6px;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { MouseEventHandler } from "svelte/elements";
interface MenuItemProps {
href?: string | null;
icon?: App.IconName | null;
target?: App.LinkTarget | undefined;
children?: Snippet;
onclick?: MouseEventHandler<HTMLAnchorElement | HTMLSpanElement>;
}
let {
href = null,
icon = null,
target = undefined,
children,
onclick
}: MenuItemProps = $props();
</script>
<svelte:element class="menu-item" {href} {onclick} role="link" tabindex="0" {target} this="{href ? 'a': 'span'}">
{#if icon}
<i class="icon icon-{icon}"></i>
{/if}
{@render children?.()}
</svelte:element>
<style lang="scss">
@use '$styles/colors';
.menu-item {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
i {
width: 16px;
height: 16px;
background: colors.$text;
margin-right: 6px;
}
}
</style>

View File

@@ -1,41 +0,0 @@
<script>
/**
* @type {string}
*/
export let href;
/**
* @type {"tag"|"paint-brush"|"arrow-left"|"info-circle"|"wrench"|"globe"|"plus"|null}
*/
export let icon = null;
/**
* @type {"_blank"|"_self"|"_parent"|"_top"|undefined}
*/
export let target = undefined;
</script>
{#if href}
<a {href} {target} on:click>
{#if icon}
<i class="icon icon-{icon}"></i>
{/if}
<slot></slot>
</a>
{/if}
<style lang="scss">
@use '../../../styles/colors';
a {
display: flex;
align-items: center;
i {
width: 16px;
height: 16px;
background: colors.$text;
margin-right: 6px;
}
}
</style>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import MenuLink from "$components/ui/menu/MenuItem.svelte";
import type { Snippet } from "svelte";
import type { FormEventHandler, MouseEventHandler } from "svelte/elements";
interface MenuRadioItemProps {
checked: boolean;
name: string;
value: string;
href?: string | null;
children?: Snippet;
onclick?: MouseEventHandler<HTMLInputElement>;
oninput?: FormEventHandler<HTMLInputElement>;
}
let {
checked,
name,
value,
href = null,
children,
onclick,
oninput,
}: MenuRadioItemProps = $props();
function stopPropagationAndPassCallback(originalEvent: MouseEvent) {
originalEvent.stopPropagation();
onclick?.(originalEvent as MouseEvent & { currentTarget: HTMLInputElement });
}
</script>
<MenuLink {href}>
<input {checked} {name} onclick={stopPropagationAndPassCallback} {oninput} type="radio" {value}>
{@render children?.()}
</MenuLink>
<style lang="scss">
:global(.menu-item) input {
width: 16px;
height: 16px;
margin-right: 6px;
flex-shrink: 0;
}
</style>

View File

@@ -1,21 +0,0 @@
<script>
import "$lib/web-components/TagEditorComponent.js";
/**
* @type {string[]}
*/
export let tags = [];
let tagsAttribute = tags.join(',');
/**
* @param {CustomEvent<string[]>} event
*/
function onTagsChanged(event) {
tags = event.detail;
}
$: tagsAttribute = tags.join(',');
</script>
<tags-editor tags="{tagsAttribute}" on:change={onTagsChanged}></tags-editor>

83
src/config/tags.ts Normal file
View File

@@ -0,0 +1,83 @@
export const tagsBlacklist: string[] = (__CURRENT_SITE__ === 'furbooru' ? [
"anthro art",
"anthro artist",
"anthro cute",
"anthro furry",
"anthro nsfw",
"anthro oc",
"anthroart",
"anthroartist",
"anthrofurry",
"anthronsfw",
"anthrooc",
"art",
"artist",
"artwork",
"cringe",
"cringeworthy",
"cute art",
"cute artwork",
"cute furry",
"downvotes galore",
"drama in comments",
"drama in the comments",
"fandom",
"furries",
"furry anthro",
"furry art",
"furry artist",
"furry artwork",
"furry character",
"furry community",
"furry cute",
"furry fandom",
"furry nsfw",
"furry oc",
"furryanthro",
"furryart",
"furryartist",
"furryartwork",
"furrynsfw",
"furryoc",
"image",
"no tag",
"not tagged",
"notag",
"notags",
"nsfw anthro",
"nsfw art",
"nsfw artist",
"nsfw artwork",
"nsfw",
"nsfwanthro",
"nsfwart",
"nsfwartist",
"nsfwartwork",
"paywall",
"rcf community",
"sfw",
"solo oc",
"tag me",
"tag needed",
"tag your shit",
"tagme",
"upvotes galore",
"wall of faves"
] : [
"tagme",
"tag me",
"not tagged",
"no tag",
"notag",
"notags",
"upvotes galore",
"downvotes galore",
"wall of faves",
"drama in the comments",
"drama in comments",
"tag needed",
"paywall",
"cringeworthy",
"solo oc",
"tag your shit"
]);

53
src/content/deps/amd.ts Normal file
View File

@@ -0,0 +1,53 @@
import { amdLite } from "amd-lite";
const originalDefine = amdLite.define;
/**
* Set of already defined modules. Used for deduplication.
*/
const definedModules = new Set<string>();
/**
* Throttle timer to make sure only one attempt at loading modules will run for a batch of loaded scripts.
*/
let throttledAutoRunTimer: NodeJS.Timeout | number | undefined;
/**
* Schedule the automatic resolving of all waiting modules on the next available frame.
*/
function scheduleModulesAutoRun() {
clearTimeout(throttledAutoRunTimer);
throttledAutoRunTimer = setTimeout(() => {
amdLite.resolveDependencies(Object.keys(amdLite.waitingModules));
});
}
amdLite.define = (name, dependencies, originalCallback) => {
// Chrome doesn't run the same content script multiple times, while Firefox does. Since each content script and their
// chunks are intended to be run only once, we should just ignore any attempts of running the same module more than
// once. Names of the modules are assumed to be unique.
if (definedModules.has(name)) {
return;
}
definedModules.add(name);
originalDefine(name, dependencies, function () {
const callbackResult = originalCallback(...arguments);
// Workaround for the entry script not returning anything causing AMD-Lite to send warning about modules not
// being loaded/not existing.
return typeof callbackResult !== 'undefined' ? callbackResult : {};
});
// Schedule the auto run on the next available frame. Firefox and Chromium have a lot of differences in how they
// decide to execute content scripts. For example, Firefox might decide to skip a frame before attempting to load
// different groups of them. Chromium on the other hand doesn't have that issue, but it doesn't allow us to, for
// example, schedule a microtask to run the modules.
scheduleModulesAutoRun();
}
amdLite.init({
publicScope: window
});

View File

@@ -1,7 +0,0 @@
import {initializeSiteHeader} from "$lib/components/SiteHeaderWrapper.js";
const siteHeader = document.querySelector('.header');
if (siteHeader) {
initializeSiteHeader(siteHeader);
}

View File

@@ -1,20 +0,0 @@
import {createMaintenancePopup} from "$lib/components/MaintenancePopup.js";
import {createMediaBoxTools} from "$lib/components/MediaBoxTools.js";
import {initializeMediaBox} from "$lib/components/MediaBoxWrapper.js";
import {createMaintenanceStatusIcon} from "$lib/components/MaintenanceStatusIcon.js";
import {createImageShowFullscreenButton} from "$lib/components/ImageShowFullscreenButton.js";
document.querySelectorAll('.media-box').forEach(mediaBoxElement => {
initializeMediaBox(mediaBoxElement, [
createMediaBoxTools(
createMaintenancePopup(),
createMaintenanceStatusIcon(),
createImageShowFullscreenButton(),
)
]);
// Attempt to fix misplacement of media boxes
requestAnimationFrame(() => {
window.dispatchEvent(new CustomEvent('resize'));
})
});

30
src/content/listing.ts Normal file
View File

@@ -0,0 +1,30 @@
import { createMaintenancePopup } from "$lib/components/MaintenancePopup";
import { createMediaBoxTools } from "$lib/components/MediaBoxTools";
import { calculateMediaBoxesPositions, initializeMediaBox } from "$lib/components/MediaBoxWrapper";
import { createMaintenanceStatusIcon } from "$lib/components/MaintenanceStatusIcon";
import { createImageShowFullscreenButton } from "$lib/components/ImageShowFullscreenButton";
import { initializeImageListContainer } from "$lib/components/listing/ImageListContainer";
const mediaBoxes = document.querySelectorAll<HTMLElement>('.media-box');
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
mediaBoxes.forEach(mediaBoxElement => {
initializeMediaBox(mediaBoxElement, [
createMediaBoxTools(
createMaintenancePopup(),
createMaintenanceStatusIcon(),
createImageShowFullscreenButton(),
)
]);
// Attempt to fix misplacement of media boxes
requestAnimationFrame(() => {
window.dispatchEvent(new CustomEvent('resize'));
})
});
calculateMediaBoxesPositions(mediaBoxes);
if (imageListContainer) {
initializeImageListContainer(imageListContainer);
}

View File

@@ -0,0 +1,6 @@
import { TagsForm } from "$lib/components/TagsForm";
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$lib/components/TagsListBlock";
initializeAllTagsLists();
watchForUpdatedTagLists();
TagsForm.watchForEditors();

7
src/content/tags.ts Normal file
View File

@@ -0,0 +1,7 @@
import { watchTagDropdownsInTagsEditor, wrapTagDropdown } from "$lib/components/TagDropdownWrapper";
for (let tagDropdownElement of document.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
watchTagDropdownsInTagsEditor();

View File

@@ -1,8 +1,13 @@
/** @type {import('@sveltejs/kit').Reroute} */
export function reroute({url}) {
import type { Reroute } from "@sveltejs/kit";
export const reroute: Reroute = ({url}) => {
// Reroute index.html as just / for the root.
// Browser extension starts from with the index.html file in the pathname which is not correct for the router.
if (url.pathname === '/index.html') {
if (url.searchParams.has('path')) {
return url.searchParams.get('path')!;
}
return "/";
}
}
};

View File

@@ -1,34 +0,0 @@
/**
* Build the map containing both real tags and their aliases.
*
* @param {string[]} realAndAliasedTags List combining aliases and tag names.
* @param {string[]} realTags List of actual tag names, excluding aliases.
*
* @return {Map<string, string>} Map where key is a tag or alias and value is an actual tag name.
*/
export function buildTagsAndAliasesMap(realAndAliasedTags, realTags) {
/** @type {Map<string, string>} */
const tagsAndAliasesMap = new Map();
for (let tagName of realTags) {
tagsAndAliasesMap.set(tagName, tagName);
}
let realTagName = null;
for (let tagNameOrAlias of realAndAliasedTags) {
if (tagsAndAliasesMap.has(tagNameOrAlias)) {
realTagName = tagNameOrAlias;
continue;
}
if (!realTagName) {
console.warn('No real tag found for the alias:', tagNameOrAlias);
continue;
}
tagsAndAliasesMap.set(tagNameOrAlias, realTagName);
}
return tagsAndAliasesMap;
}

View File

@@ -1,19 +1,25 @@
import PostParser from "$lib/booru/scraped/parsing/PostParser.js";
import PostParser from "$lib/booru/scraped/parsing/PostParser";
type UpdaterFunction = (tags: Set<string>) => Set<string>;
export default class ScrapedAPI {
/**
* Update the tags of the image using callback.
* @param {number} imageId ID of the image.
* @param {function(Set<string>): Set<string>} callback Callback to call to change the content.
* @return {Promise<Map<string,string>|null>} Updated tags and aliases list for updating internal cached state.
* @param imageId ID of the image.
* @param callback Callback to call to change the content.
* @return Updated tags and aliases list for updating internal cached state.
*/
async updateImageTags(imageId, callback) {
async updateImageTags(imageId: number, callback: UpdaterFunction): Promise<Map<string, string> | null> {
const postParser = new PostParser(imageId);
const formData = await postParser.resolveTagEditorFormData();
const tagsFieldValue = formData.get(PostParser.tagsInputName);
if (typeof tagsFieldValue !== 'string') {
throw new Error('Missing tags field!');
}
const tagsList = new Set(
formData
.get(PostParser.tagsInputName)
tagsFieldValue
.split(',')
.map(tagName => tagName.trim())
);

View File

@@ -1,17 +1,12 @@
export default class PageParser {
/** @type {string} */
#url;
/** @type {DocumentFragment|null} */
#fragment = null;
readonly #url: string;
#fragment: DocumentFragment | null = null;
constructor(url) {
constructor(url: string) {
this.#url = url;
}
/**
* @return {Promise<DocumentFragment>}
*/
async resolveFragment() {
async resolveFragment(): Promise<DocumentFragment> {
if (this.#fragment) {
return this.#fragment;
}
@@ -34,12 +29,12 @@ export default class PageParser {
/**
* Create a document fragment from the following response.
*
* @param {Response} response Response to create a fragment from. Note, that this response will be used. If you need
* to use the same response somewhere else, then you need to pass a cloned version of the response.
* @param response Response to create a fragment from. Note, that this response will be used. If you need to use the
* same response somewhere else, then you need to pass a cloned version of the response.
*
* @return {Promise<DocumentFragment>} Resulting document fragment ready for processing.
* @return Resulting document fragment ready for processing.
*/
static async resolveFragmentFromResponse(response) {
static async resolveFragmentFromResponse(response: Response): Promise<DocumentFragment> {
const documentFragment = document.createDocumentFragment();
const template = document.createElement('template');
template.innerHTML = await response.text();

View File

@@ -1,81 +0,0 @@
import PageParser from "$lib/booru/scraped/parsing/PageParser.js";
import {buildTagsAndAliasesMap} from "$lib/booru/TagsUtils.js";
export default class PostParser extends PageParser {
/** @type {HTMLFormElement} */
#tagEditorForm;
constructor(imageId) {
super(`/images/${imageId}`);
}
/**
* @return {Promise<HTMLFormElement>}
*/
async resolveTagEditorForm() {
if (this.#tagEditorForm) {
return this.#tagEditorForm;
}
const documentFragment = await this.resolveFragment();
const tagsFormElement = documentFragment.querySelector("#tags-form");
if (!tagsFormElement) {
throw new Error("Failed to find the tag editor form");
}
this.#tagEditorForm = tagsFormElement;
return tagsFormElement;
}
async resolveTagEditorFormData() {
return new FormData(
await this.resolveTagEditorForm()
);
}
/**
* Resolve the tags and aliases mapping from the post page.
*
* @return {Promise<Map<string, string>|null>}
*/
async resolveTagsAndAliases() {
return PostParser.resolveTagsAndAliasesFromPost(
await this.resolveFragment()
);
}
/**
* Resolve the list of tags and aliases from the post content.
*
* @param {DocumentFragment} documentFragment Real content to parse the data from.
*
* @return {Map<string, string>|null} Tags and aliases or null if failed to parse.
*/
static resolveTagsAndAliasesFromPost(documentFragment) {
const imageShowContainer = documentFragment.querySelector('.image-show-container');
const tagsForm = documentFragment.querySelector('#tags-form');
if (!imageShowContainer || !tagsForm) {
return null;
}
const tagsFormData = new FormData(tagsForm);
const tagsAndAliasesList = imageShowContainer.dataset.imageTagAliases
.split(',')
.map(tagName => tagName.trim());
const actualTagsList = tagsFormData.get(this.tagsInputName)
.split(',')
.map(tagName => tagName.trim());
return buildTagsAndAliasesMap(
tagsAndAliasesList,
actualTagsList,
);
}
static tagsInputName = 'image[tag_input]';
}

View File

@@ -0,0 +1,82 @@
import PageParser from "$lib/booru/scraped/parsing/PageParser";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
export default class PostParser extends PageParser {
#tagEditorForm: HTMLFormElement | null = null;
constructor(imageId: number) {
super(`/images/${imageId}`);
}
async resolveTagEditorForm(): Promise<HTMLFormElement> {
if (this.#tagEditorForm) {
return this.#tagEditorForm;
}
const documentFragment = await this.resolveFragment();
const tagsFormElement = documentFragment.querySelector<HTMLFormElement>("#tags-form");
if (!tagsFormElement) {
throw new Error("Failed to find the tag editor form");
}
this.#tagEditorForm = tagsFormElement;
return tagsFormElement;
}
async resolveTagEditorFormData() {
return new FormData(
await this.resolveTagEditorForm()
);
}
/**
* Resolve the tags and aliases mapping from the post page.
*/
async resolveTagsAndAliases(): Promise<Map<string, string> | null> {
return PostParser.resolveTagsAndAliasesFromPost(
await this.resolveFragment()
);
}
/**
* Resolve the list of tags and aliases from the post content.
*
* @param documentFragment Real content to parse the data from.
*
* @return Tags and aliases or null if failed to parse.
*/
static resolveTagsAndAliasesFromPost(documentFragment: DocumentFragment): Map<string, string> | null {
const imageShowContainer = documentFragment.querySelector<HTMLElement>('.image-show-container');
const tagsForm = documentFragment.querySelector<HTMLFormElement>('#tags-form');
if (!imageShowContainer || !tagsForm) {
return null;
}
const tagsFormData = new FormData(tagsForm);
const tagsAndAliasesValue = imageShowContainer.dataset.imageTagAliases;
const tagsValue = tagsFormData.get(this.tagsInputName);
if (!tagsAndAliasesValue || !tagsValue || typeof tagsValue !== 'string') {
console.warn('Failed to locate tags & aliases!');
return null;
}
const tagsAndAliasesList = tagsAndAliasesValue
.split(',')
.map(tagName => tagName.trim());
const actualTagsList = tagsValue
.split(',')
.map(tagName => tagName.trim());
return buildTagsAndAliasesMap(
tagsAndAliasesList,
actualTagsList,
);
}
static tagsInputName = 'image[tag_input]';
}

View File

@@ -1,8 +1,8 @@
export class Token {
index;
value;
readonly index: number;
readonly value: string;
constructor(index, value) {
constructor(index: number, value: string) {
this.index = index;
this.value = value;
}
@@ -28,12 +28,9 @@ export class BoostToken extends Token {
}
export class QuotedTermToken extends Token {
/**
* @type {string}
*/
#quotedValue;
readonly #quotedValue: string;
constructor(index, value, quotedValue) {
constructor(index: number, value: string, quotedValue: string) {
super(index, value);
this.#quotedValue = quotedValue;
@@ -43,19 +40,11 @@ export class QuotedTermToken extends Token {
return QuotedTermToken.decode(this.#quotedValue);
}
/**
* @param {string} value
* @return {string}
*/
static decode(value) {
static decode(value: string): string {
return value.replace(/\\([\\"])/g, "$1");
}
/**
* @param {string} value
* @return {string}
*/
static encode(value) {
static encode(value: string): string {
return value.replace(/[\\"]/g, "\\$&");
}
}
@@ -63,6 +52,10 @@ export class QuotedTermToken extends Token {
export class TermToken extends Token {
}
type MatchResultCarry = {
match?: RegExpMatchArray | null
}
/**
* Search query tokenizer. Should mostly work for the cases of parsing and finding the selected term for
* auto-completion. Follows the rules described in the Philomena booru engine.
@@ -70,38 +63,28 @@ export class TermToken extends Token {
export class QueryLexer {
/**
* The original value to be parsed.
* @type {string}
*/
#value;
readonly #value: string;
/**
* Current position of the parser in the value.
* @type {number}
*/
#index = 0;
#index: number = 0;
/**
* @param {string} value
*/
constructor(value) {
constructor(value: string) {
this.#value = value;
}
/**
* Parse the query and get the list of tokens.
*
* @return {Token[]} List of tokens.
* @return List of tokens.
*/
parse() {
/** @type {Token[]} */
const tokens = [];
parse(): Token[] {
const tokens: Token[] = [];
const result: MatchResultCarry = {};
/**
* @type {{match: RegExpMatchArray|null}}
*/
const result = {};
let dirtyText;
let dirtyText: string;
while (this.#index < this.#value.length) {
if (this.#value[this.#index] === QueryLexer.#commaCharacter) {
@@ -111,26 +94,26 @@ export class QueryLexer {
}
if (this.#match(QueryLexer.#negotiationOperator, result)) {
tokens.push(new NotToken(this.#index, result.match[0]));
this.#index += result.match[0].length;
tokens.push(new NotToken(this.#index, result.match![0]));
this.#index += result.match![0].length;
continue;
}
if (this.#match(QueryLexer.#andOperator, result)) {
tokens.push(new AndToken(this.#index, result.match[0]));
this.#index += result.match[0].length;
tokens.push(new AndToken(this.#index, result.match![0]));
this.#index += result.match![0].length;
continue;
}
if (this.#match(QueryLexer.#orOperator, result)) {
tokens.push(new OrToken(this.#index, result.match[0]));
this.#index += result.match[0].length;
tokens.push(new OrToken(this.#index, result.match![0]));
this.#index += result.match![0].length;
continue;
}
if (this.#match(QueryLexer.#notOperator, result)) {
tokens.push(new NotToken(this.#index, result.match[0]));
this.#index += result.match[0].length;
tokens.push(new NotToken(this.#index, result.match![0]));
this.#index += result.match![0].length;
continue;
}
@@ -147,19 +130,19 @@ export class QueryLexer {
}
if (this.#match(QueryLexer.#boostOperator, result)) {
tokens.push(new BoostToken(this.#index, result.match[0]));
this.#index += result.match[0].length;
tokens.push(new BoostToken(this.#index, result.match![0]));
this.#index += result.match![0].length;
continue;
}
if (this.#match(QueryLexer.#whitespaces, result)) {
this.#index += result.match[0].length;
this.#index += result.match![0].length;
continue;
}
if (this.#match(QueryLexer.#quotedText, result)) {
tokens.push(new QuotedTermToken(this.#index, result.match[0], result.match[1]));
this.#index += result.match[0].length;
tokens.push(new QuotedTermToken(this.#index, result.match![0], result.match![1]));
this.#index += result.match![0].length;
continue;
}
@@ -180,25 +163,25 @@ export class QueryLexer {
/**
* Match the provided regular expression on the string with the current parser position.
*
* @param {RegExp} targetRegExp Target RegExp to parse with.
* @param {{match: any}} [resultCarrier] Object for passing the results into.
* @param targetRegExp Target RegExp to parse with.
* @param [resultCarrier] Object for passing the results into.
*
* @return {boolean} Is there a match?
* @return Is there a match?
*/
#match(targetRegExp, resultCarrier = {}) {
#match(targetRegExp: RegExp, resultCarrier: MatchResultCarry = {}): boolean {
return this.#matchAt(targetRegExp, this.#index, resultCarrier);
}
/**
* Match the provided regular expression in the string with the specific index.
*
* @param {RegExp} targetRegExp Target RegExp to parse with.
* @param {number} index Index to match the expression from.
* @param {{match: any}} [resultCarrier] Object for passing the results into.
* @param targetRegExp Target RegExp to parse with.
* @param index Index to match the expression from.
* @param [resultCarrier] Object for passing the results into.
*
* @return {boolean} Is there a match?
* @return Is there a match?
*/
#matchAt(targetRegExp, index, resultCarrier = {}) {
#matchAt(targetRegExp: RegExp, index: number, resultCarrier: MatchResultCarry = {}): boolean {
targetRegExp.lastIndex = index;
resultCarrier.match = this.#value.match(targetRegExp);
@@ -212,11 +195,10 @@ export class QueryLexer {
*
* @return {string} Matched text.
*/
#parseDirtyText(index) {
let resultValue = '';
#parseDirtyText(index: number): string {
let resultValue: string = '';
/** @type {{match: RegExpMatchArray|null}} */
const result = {match: null};
const result: MatchResultCarry = {match: null};
// Loop over
while (index < this.#value.length) {
@@ -226,8 +208,8 @@ export class QueryLexer {
}
if (this.#matchAt(QueryLexer.#dirtyTextContent, index, result)) {
resultValue += result.match[0];
index += result.match[0].length;
resultValue += result.match![0];
index += result.match![0].length;
continue;
}

View File

@@ -0,0 +1,12 @@
export const categories = [
'rating',
'spoiler',
'origin',
'oc',
'error',
'character',
'content-official',
'content-fanmade',
'species',
'body-type',
];

View File

@@ -0,0 +1,33 @@
/**
* Build the map containing both real tags and their aliases.
*
* @param realAndAliasedTags List combining aliases and tag names.
* @param realTags List of actual tag names, excluding aliases.
*
* @return Map where key is a tag or alias and value is an actual tag name.
*/
export function buildTagsAndAliasesMap(realAndAliasedTags: string[], realTags: string[]): Map<string, string> {
const tagsAndAliasesMap: Map<string, string> = new Map();
for (const tagName of realTags) {
tagsAndAliasesMap.set(tagName, tagName);
}
let realTagName: string | null = null;
for (const tagNameOrAlias of realAndAliasedTags) {
if (tagsAndAliasesMap.has(tagNameOrAlias)) {
realTagName = tagNameOrAlias;
continue;
}
if (!realTagName) {
console.warn('No real tag found for the alias:', tagNameOrAlias);
continue;
}
tagsAndAliasesMap.set(tagNameOrAlias, realTagName);
}
return tagsAndAliasesMap;
}

View File

@@ -0,0 +1,53 @@
/**
* Changes subscribe function. It receives changes with old and new value for keys of the storage.
*/
export type StorageChangeSubscriber = (changes: Record<string, chrome.storage.StorageChange>) => void;
/**
* Helper class to read and write JSON objects to the local storage.
*/
export default class StorageHelper {
readonly #storageArea: chrome.storage.StorageArea;
constructor(storageArea: chrome.storage.StorageArea) {
this.#storageArea = storageArea;
}
/**
* Read the following entry from the local storage as a JSON object.
*
* @param key Key of the entry to read.
* @param defaultValue Default value to return if the entry does not exist.
*
* @return The JSON object or the default value if the entry does not exist.
*/
async read<Type = any, DefaultType = any>(key: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
return (await this.#storageArea.get(key))?.[key] ?? defaultValue;
}
/**
* Write the following JSON object to the local storage.
*
* @param key Key of the entry to write.
* @param value Value to write.
*/
write(key: string, value: any): void {
void this.#storageArea.set({[key]: value});
}
/**
* Subscribe to changes in the local storage.
* @param callback Listener function to receive changes.
*/
subscribe(callback: StorageChangeSubscriber): void {
this.#storageArea.onChanged.addListener(callback);
}
/**
* Unsubscribe from changes in the local storage.
* @param callback Reference to the callback for unsubscribing.
*/
unsubscribe(callback: StorageChangeSubscriber): void {
this.#storageArea.onChanged.removeListener(callback);
}
}

View File

@@ -1,57 +0,0 @@
/**
* Helper class to read and write JSON objects to the local storage.
* @class
*/
class StorageHelper {
/**
* @type {import('@types/chrome').storage.StorageArea}
*/
#storageArea;
/**
* @param {import('@types/chrome').storage.StorageArea} storageArea
*/
constructor(storageArea) {
this.#storageArea = storageArea;
}
/**
* Read the following entry from the local storage as a JSON object.
*
* @param {string} key Key of the entry to read.
* @param {any} defaultValue Default value to return if the entry does not exist.
*
* @return {Promise<any>} The JSON object or the default value if the entry does not exist.
*/
async read(key, defaultValue = null) {
return (await this.#storageArea.get(key))?.[key] || defaultValue;
}
/**
* Write the following JSON object to the local storage.
*
* @param {string} key Key of the entry to write.
* @param {any} value JSON object to write.
*/
write(key, value) {
void this.#storageArea.set({[key]: value});
}
/**
* Subscribe to changes in the local storage.
* @param {function(Record<string, StorageChange>): void} callback
*/
subscribe(callback) {
this.#storageArea.onChanged.addListener(callback);
}
/**
* Unsubscribe from changes in the local storage.
* @param {function(Record<string, StorageChange>): void} callback
*/
unsubscribe(callback) {
this.#storageArea.onChanged.removeListener(callback);
}
}
export default StorageHelper;

View File

@@ -0,0 +1,308 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
import { emit, on } from "$lib/components/events/comms";
import { EVENT_SIZE_LOADED } from "$lib/components/events/fullscreen-viewer-events";
export class FullscreenViewer extends BaseComponent {
#videoElement: HTMLVideoElement = document.createElement('video');
#imageElement: HTMLImageElement = document.createElement('img');
#spinnerElement: HTMLElement = document.createElement('i');
#sizeSelectorElement: HTMLSelectElement = document.createElement('select');
#closeButtonElement: HTMLElement = document.createElement('i');
#touchId: number | null = null;
#startX: number | null = null;
#startY: number | null = null;
#isClosingSwipeStarted: boolean | null = null;
#isSizeFetched: boolean = false;
#currentURIs: App.ImageURIs | null = null;
protected build() {
this.container.classList.add('fullscreen-viewer');
this.container.append(
this.#spinnerElement,
this.#sizeSelectorElement,
this.#closeButtonElement,
);
this.#spinnerElement.classList.add('spinner', 'fa', 'fa-circle-notch', 'fa-spin');
this.#closeButtonElement.classList.add('close', 'fa', 'fa-xmark');
this.#sizeSelectorElement.classList.add('size-selector', 'input');
for (const [sizeKey, sizeName] of Object.entries(FullscreenViewer.#previewSizes)) {
const sizeOptionElement = document.createElement('option');
sizeOptionElement.value = sizeKey;
sizeOptionElement.innerText = sizeName;
this.#sizeSelectorElement.append(sizeOptionElement);
}
}
/**
* @protected
*/
init() {
document.addEventListener('keydown', this.#onDocumentKeyPressed.bind(this));
this.on('click', this.#close.bind(this));
this.on('touchstart', this.#onTouchStart.bind(this));
this.on('touchmove', this.#onTouchMove.bind(this));
this.on('touchend', this.#onTouchEnd.bind(this));
this.#videoElement.addEventListener('loadeddata', this.#onLoaded.bind(this));
this.#imageElement.addEventListener('load', this.#onLoaded.bind(this));
this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation());
FullscreenViewer.#miscSettings
.resolveFullscreenViewerPreviewSize()
.then(this.#onSizeResolved.bind(this))
.then(this.#watchForSizeSelectionChanges.bind(this));
}
#onLoaded() {
this.container.classList.remove('loading');
}
#onTouchStart(event: TouchEvent) {
if (this.#touchId !== null) {
return;
}
const firstTouch = event.touches.item(0);
if (!firstTouch) {
return;
}
this.#touchId = firstTouch.identifier;
this.#startX = firstTouch.clientX;
this.#startY = firstTouch.clientY;
this.container.classList.add(FullscreenViewer.#swipeState);
}
#onTouchEnd(event: TouchEvent) {
if (this.#touchId === null || this.#startY === null) {
return;
}
const endedTouch = Array.from(event.changedTouches)
.find(touch => touch.identifier === this.#touchId);
if (!endedTouch) {
return;
}
const verticalDistance = Math.abs(endedTouch.clientY - this.#startY);
const requiredClosingDistance = window.innerHeight / 3;
if (this.#isClosingSwipeStarted && verticalDistance > requiredClosingDistance) {
this.#close();
}
this.#touchId = null;
this.#startX = null;
this.#startY = null;
this.#isClosingSwipeStarted = null;
this.container.classList.remove(FullscreenViewer.#swipeState);
requestAnimationFrame(() => {
this.container.style.removeProperty(FullscreenViewer.#offsetProperty);
this.container.style.removeProperty(FullscreenViewer.#opacityProperty);
});
}
#onTouchMove(event: TouchEvent) {
if (this.#touchId === null || this.#startY === null || this.#startX === null) {
return;
}
if (this.#isClosingSwipeStarted === false) {
return;
}
for (const changedTouch of event.changedTouches) {
if (changedTouch.identifier !== this.#touchId) {
continue;
}
const verticalDistance = changedTouch.clientY - this.#startY;
if (this.#isClosingSwipeStarted === null) {
const horizontalDistance = changedTouch.clientX - this.#startX;
if (Math.abs(verticalDistance) >= FullscreenViewer.#minRequiredDistance) {
this.#isClosingSwipeStarted = true;
} else if (Math.abs(horizontalDistance) >= FullscreenViewer.#minRequiredDistance) {
this.#isClosingSwipeStarted = false;
break;
} else {
break;
}
}
this.container.style.setProperty(
FullscreenViewer.#offsetProperty,
verticalDistance.toString().concat('px')
);
const maxDistance = window.innerHeight * 2;
let opacity = 1;
if (verticalDistance !== 0) {
opacity -= Math.min(1, Math.abs(verticalDistance) / maxDistance);
}
this.container.style.setProperty(
FullscreenViewer.#opacityProperty,
opacity.toString()
);
break;
}
}
#onDocumentKeyPressed(event: KeyboardEvent) {
if (event.code === 'Escape' || event.code === 'Esc') {
this.#close();
}
}
#onSizeResolved(size: FullscreenViewerSize) {
this.#sizeSelectorElement.value = size;
this.#isSizeFetched = true;
emit(this.container, EVENT_SIZE_LOADED, size);
}
#watchForSizeSelectionChanges() {
let lastActiveSize = this.#sizeSelectorElement.value;
FullscreenViewer.#miscSettings.subscribe(settings => {
const targetSize = settings.fullscreenViewerSize;
if (!targetSize || lastActiveSize === targetSize) {
return;
}
lastActiveSize = targetSize;
this.#sizeSelectorElement.value = targetSize;
});
this.#sizeSelectorElement.addEventListener('input', () => {
const targetSize = this.#sizeSelectorElement.value;
if (this.#currentURIs) {
void this.show(this.#currentURIs);
}
if (!targetSize || targetSize === lastActiveSize || !(targetSize in FullscreenViewer.#previewSizes)) {
return;
}
lastActiveSize = targetSize;
void FullscreenViewer.#miscSettings.setFullscreenViewerPreviewSize(targetSize);
});
}
#close() {
this.#currentURIs = null;
this.container.classList.remove(FullscreenViewer.#shownState);
document.body.style.removeProperty('overflow');
requestAnimationFrame(() => {
this.#videoElement.volume = 0;
this.#videoElement.pause();
this.#videoElement.remove();
});
}
async #resolveCurrentSelectedSizeUrl(imageUris: App.ImageURIs): Promise<string | null> {
if (!this.#isSizeFetched) {
await new Promise(
resolve => on(
this.container,
EVENT_SIZE_LOADED,
resolve
),
);
}
let targetSize: FullscreenViewerSize | string = this.#sizeSelectorElement.value;
if (!imageUris.hasOwnProperty(targetSize)) {
targetSize = FullscreenViewer.#fallbackSize;
}
if (!imageUris.hasOwnProperty(targetSize)) {
targetSize = Object.keys(imageUris)[0];
}
if (!targetSize) {
return null;
}
return imageUris[targetSize as FullscreenViewerSize];
}
async show(imageUris: App.ImageURIs): Promise<void> {
this.#currentURIs = imageUris;
const url = await this.#resolveCurrentSelectedSizeUrl(imageUris);
if (!url) {
console.warn('Failed to resolve media for the viewer!');
return;
}
this.container.classList.add('loading');
requestAnimationFrame(() => {
this.container.classList.add(FullscreenViewer.#shownState);
document.body.style.overflow = 'hidden';
});
if (FullscreenViewer.#isVideoUrl(url)) {
this.#imageElement.remove();
this.#videoElement.src = url;
this.#videoElement.volume = 0;
this.#videoElement.autoplay = true;
this.#videoElement.loop = true;
this.#videoElement.controls = true;
this.container.append(this.#videoElement);
return;
}
this.#videoElement.remove();
this.#imageElement.src = url;
this.container.append(this.#imageElement);
}
static #isVideoUrl(url: string): boolean {
return url.endsWith('.mp4') || url.endsWith('.webm');
}
static #miscSettings = new MiscSettings();
static #offsetProperty = '--offset';
static #opacityProperty = '--opacity';
static #shownState = 'shown';
static #swipeState = 'swiped';
static #minRequiredDistance = 50;
static #previewSizes: Record<FullscreenViewerSize, string> = {
full: 'Full',
large: 'Large',
medium: 'Medium',
small: 'Small'
}
static #fallbackSize = 'large';
}

View File

@@ -1,75 +0,0 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
export class ImageShowFullscreenButton extends BaseComponent {
/**
* @type {MediaBoxTools}
*/
#mediaBoxTools;
build() {
this.container.innerText = '🔍';
ImageShowFullscreenButton.#resolveFullscreenViewer();
}
init() {
this.#mediaBoxTools = getComponent(this.container.parentElement);
if (!this.#mediaBoxTools) {
throw new Error('Fullscreen button is placed outside of the tools container!');
}
this.on('click', this.#onButtonClicked.bind(this));
}
#onButtonClicked() {
const imageViewer = ImageShowFullscreenButton.#resolveFullscreenViewer();
let imageElement = imageViewer.querySelector('img') ?? document.createElement('img');
imageElement.src = this.#mediaBoxTools.mediaBox.imageLinks.large;
imageViewer.appendChild(imageElement);
imageViewer.classList.add('shown');
}
/**
* @type {HTMLElement|null}
*/
static #fullscreenViewerElement = null;
/**
* @return {HTMLElement}
*/
static #resolveFullscreenViewer() {
this.#fullscreenViewerElement ??= this.#buildFullscreenViewer();
return this.#fullscreenViewerElement;
}
/**
* @return {HTMLElement}
*/
static #buildFullscreenViewer() {
const element = document.createElement('div');
element.classList.add('fullscreen-viewer');
document.body.append(element);
document.addEventListener('keydown', event => {
// When ESC pressed
if (event.code === 'Escape' || event.code === 'Esc') {
element.classList.remove('shown');
}
});
return element;
}
}
export function createImageShowFullscreenButton() {
const element = document.createElement('div');
element.classList.add('media-box-show-fullscreen');
new ImageShowFullscreenButton(element);
return element;
}

View File

@@ -0,0 +1,89 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import MiscSettings from "$lib/extension/settings/MiscSettings";
import { FullscreenViewer } from "$lib/components/FullscreenViewer";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
export class ImageShowFullscreenButton extends BaseComponent {
#mediaBoxTools: MediaBoxTools | null = null;
#isFullscreenButtonEnabled: boolean = false;
protected build() {
this.container.innerText = '🔍';
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
}
protected init() {
if (!this.container.parentElement) {
throw new Error('Missing parent element!');
}
this.#mediaBoxTools = getComponent(this.container.parentElement);
if (!this.#mediaBoxTools) {
throw new Error('Fullscreen button is placed outside of the tools container!');
}
this.on('click', this.#onButtonClicked.bind(this));
if (ImageShowFullscreenButton.#miscSettings) {
ImageShowFullscreenButton.#miscSettings.resolveFullscreenViewerEnabled()
.then(isEnabled => {
this.#isFullscreenButtonEnabled = isEnabled;
this.#updateFullscreenButtonVisibility();
})
.then(() => {
ImageShowFullscreenButton.#miscSettings?.subscribe(settings => {
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
this.#updateFullscreenButtonVisibility();
})
})
}
}
#updateFullscreenButtonVisibility() {
this.container.classList.toggle('is-visible', this.#isFullscreenButtonEnabled);
}
#onButtonClicked() {
const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks;
if (!imageLinks) {
throw new Error('Failed to resolve image links from media box tools!');
}
ImageShowFullscreenButton
.#resolveViewer()
?.show(imageLinks);
}
static #viewer: FullscreenViewer | null = null;
static #resolveViewer(): FullscreenViewer {
this.#viewer ??= this.#buildViewer();
return this.#viewer;
}
static #buildViewer(): FullscreenViewer {
const element = document.createElement('div');
const viewer = new FullscreenViewer(element);
viewer.initialize();
document.body.append(element);
return viewer;
}
static #miscSettings: MiscSettings | null = null;
}
export function createImageShowFullscreenButton() {
const element = document.createElement('div');
element.classList.add('media-box-show-fullscreen');
new ImageShowFullscreenButton(element);
return element;
}

View File

@@ -1,290 +0,0 @@
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js";
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI.js";
export class MaintenancePopup extends BaseComponent {
/** @type {HTMLElement} */
#tagsListElement = null;
/** @type {HTMLElement[]} */
#tagsList = [];
/** @type {MaintenanceProfile|null} */
#activeProfile = null;
/** @type {import('$lib/components/MediaBoxTools.js').MediaBoxTools} */
#mediaBoxTools = null;
/** @type {Set<string>} */
#tagsToRemove = new Set();
/** @type {Set<string>} */
#tagsToAdd = new Set();
/** @type {boolean} */
#isPlanningToSubmit = false;
/** @type {boolean} */
#isSubmitting = false;
/** @type {number|null} */
#tagsSubmissionTimer = null;
/**
* @protected
*/
build() {
this.container.innerHTML = '';
this.container.classList.add('maintenance-popup');
this.#tagsListElement = document.createElement('div');
this.#tagsListElement.classList.add('tags-list');
this.container.append(
this.#tagsListElement,
);
}
/**
* @protected
*/
init() {
const mediaBoxToolsElement = this.container.closest('.media-box-tools');
if (!mediaBoxToolsElement) {
throw new Error('Maintenance popup initialized outside of the media box tools!');
}
/** @type {MediaBoxTools|null} */
const mediaBoxTools = getComponent(mediaBoxToolsElement);
if (!mediaBoxTools) {
throw new Error('Media box tools component not found!');
}
this.#mediaBoxTools = mediaBoxTools;
MaintenancePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
this.#tagsListElement.addEventListener('click', this.#handleTagClick.bind(this));
const mediaBox = this.#mediaBoxTools.mediaBox;
mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this));
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
}
/**
* @param {MaintenanceProfile|null} activeProfile
*/
#onActiveProfileChanged(activeProfile) {
this.#activeProfile = activeProfile;
this.container.classList.toggle('is-active', activeProfile !== null);
this.#refreshTagsList();
this.emit('active-profile-changed', activeProfile);
}
#refreshTagsList() {
/** @type {string[]} */
const activeProfileTagsList = this.#activeProfile?.settings.tags || [];
for (let tagElement of this.#tagsList) {
tagElement.remove();
}
this.#tagsList = new Array(activeProfileTagsList.length);
const currentPostTags = this.#mediaBoxTools.mediaBox.tagsAndAliases;
activeProfileTagsList
.sort((a, b) => a.localeCompare(b))
.forEach((tagName, index) => {
const tagElement = MaintenancePopup.#buildTagElement(tagName);
this.#tagsList[index] = tagElement;
this.#tagsListElement.appendChild(tagElement);
const isPresent = currentPostTags.has(tagName);
tagElement.classList.toggle('is-present', isPresent);
tagElement.classList.toggle('is-missing', !isPresent);
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags.get(tagName) !== tagName);
});
}
/**
* Detect and process clicks made directly to the tags.
* @param {MouseEvent} event
*/
#handleTagClick(event) {
/** @type {HTMLElement} */
let tagElement = event.target;
if (!tagElement.classList.contains('tag')) {
tagElement = tagElement.closest('.tag');
}
if (!tagElement) {
return;
}
const tagName = tagElement.dataset.name;
if (tagElement.classList.contains('is-present')) {
const isToBeRemoved = tagElement.classList.toggle('is-removed');
if (isToBeRemoved) {
this.#tagsToRemove.add(tagName);
} else {
this.#tagsToRemove.delete(tagName);
}
}
if (tagElement.classList.contains('is-missing')) {
const isToBeAdded = tagElement.classList.toggle('is-added');
if (isToBeAdded) {
this.#tagsToAdd.add(tagName);
} else {
this.#tagsToAdd.delete(tagName);
}
}
if (this.#tagsToAdd.size || this.#tagsToRemove.size) {
this.#isPlanningToSubmit = true;
this.emit('maintenance-state-change', 'waiting');
}
}
#onMouseEnteredArea() {
if (this.#tagsSubmissionTimer) {
clearTimeout(this.#tagsSubmissionTimer);
}
}
#onMouseLeftArea() {
if (this.#isPlanningToSubmit && !this.#isSubmitting) {
this.#tagsSubmissionTimer = setTimeout(
this.#onSubmissionTimerPassed.bind(this),
MaintenancePopup.#delayBeforeSubmissionMs
);
}
}
async #onSubmissionTimerPassed() {
if (!this.#isPlanningToSubmit || this.#isSubmitting) {
return;
}
this.#isPlanningToSubmit = false;
this.#isSubmitting = true;
this.emit('maintenance-state-change', 'processing');
const maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
this.#mediaBoxTools.mediaBox.imageId,
tagsList => {
for (let tagName of this.#tagsToRemove) {
tagsList.delete(tagName);
}
for (let tagName of this.#tagsToAdd) {
tagsList.add(tagName);
}
return tagsList;
}
);
if (maybeTagsAndAliasesAfterUpdate) {
this.emit('tags-updated', maybeTagsAndAliasesAfterUpdate);
}
this.emit('maintenance-state-change', 'complete');
this.#tagsToAdd.clear();
this.#tagsToRemove.clear();
this.#refreshTagsList();
this.#isSubmitting = false;
}
/**
* @return {boolean}
*/
get isActive() {
return this.container.classList.contains('is-active');
}
/**
* @param {string} tagName
* @return {HTMLElement}
*/
static #buildTagElement(tagName) {
const tagElement = document.createElement('span');
tagElement.classList.add('tag');
tagElement.innerText = tagName;
tagElement.dataset.name = tagName;
return tagElement;
}
/**
* Controller with maintenance settings.
* @type {MaintenanceSettings}
*/
static #maintenanceSettings = new MaintenanceSettings();
/**
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
* at the very start to retrieve the currently active profile.
* @param {function(MaintenanceProfile|null):void} callback Callback to execute whenever selection of active profile
* or profile itself has been changed.
* @return {function(): void} Unsubscribe function. Call it to stop watching for changes.
*/
static #watchActiveProfile(callback) {
let lastActiveProfileId;
const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => {
if (lastActiveProfileId) {
callback(
profiles.find(profile => profile.id === lastActiveProfileId) || null
);
}
});
const unsubscribeFromMaintenanceSettings = MaintenanceSettings.subscribe(settings => {
if (settings.activeProfileId === lastActiveProfileId) {
return;
}
lastActiveProfileId = settings.activeProfileId;
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(callback);
});
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(callback);
return () => {
unsubscribeFromProfilesChanges();
unsubscribeFromMaintenanceSettings();
}
}
static #scrapedAPI = new ScrapedAPI();
static #delayBeforeSubmissionMs = 500;
}
export function createMaintenancePopup() {
const container = document.createElement('div');
new MaintenancePopup(container);
return container;
}

View File

@@ -0,0 +1,419 @@
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI";
import { tagsBlacklist } from "$config/tags";
import { emitterAt } from "$lib/components/events/comms";
import {
EVENT_ACTIVE_PROFILE_CHANGED,
EVENT_MAINTENANCE_STATE_CHANGED,
EVENT_TAGS_UPDATED
} from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
class BlackListedTagsEncounteredError extends Error {
constructor(tagName: string) {
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
cause: tagName
});
}
}
export class MaintenancePopup extends BaseComponent {
#tagsListElement: HTMLElement = document.createElement('div');
#tagsList: HTMLElement[] = [];
#suggestedInvalidTags: Map<string, HTMLElement> = new Map();
#activeProfile: MaintenanceProfile | null = null;
#mediaBoxTools: MediaBoxTools | null = null;
#tagsToRemove: Set<string> = new Set();
#tagsToAdd: Set<string> = new Set();
#isPlanningToSubmit: boolean = false;
#isSubmitting: boolean = false;
#tagsSubmissionTimer: Timeout | null = null;
#emitter = emitterAt(this);
/**
* @protected
*/
build() {
this.container.innerHTML = '';
this.container.classList.add('maintenance-popup');
this.#tagsListElement.classList.add('tags-list');
this.container.append(
this.#tagsListElement,
);
}
/**
* @protected
*/
init() {
const mediaBoxToolsElement = this.container.closest<HTMLElement>('.media-box-tools');
if (!mediaBoxToolsElement) {
throw new Error('Maintenance popup initialized outside of the media box tools!');
}
const mediaBoxTools = getComponent<MediaBoxTools>(mediaBoxToolsElement);
if (!mediaBoxTools) {
throw new Error('Media box tools component not found!');
}
this.#mediaBoxTools = mediaBoxTools;
MaintenancePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
this.#tagsListElement.addEventListener('click', this.#handleTagClick.bind(this));
const mediaBox = this.#mediaBoxTools.mediaBox;
if (!mediaBox) {
throw new Error('Media box component not found!');
}
mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this));
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
}
#onActiveProfileChanged(activeProfile: MaintenanceProfile | null) {
this.#activeProfile = activeProfile;
this.container.classList.toggle('is-active', activeProfile !== null);
this.#refreshTagsList();
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
}
#refreshTagsList() {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || [];
for (const tagElement of this.#tagsList) {
tagElement.remove();
}
for (const tagElement of this.#suggestedInvalidTags.values()) {
tagElement.remove();
}
this.#tagsList = new Array(activeProfileTagsList.length);
this.#suggestedInvalidTags.clear();
const currentPostTags = this.#mediaBoxTools.mediaBox.tagsAndAliases;
activeProfileTagsList
.sort((a, b) => a.localeCompare(b))
.forEach((tagName, index) => {
const tagElement = MaintenancePopup.#buildTagElement(tagName);
this.#tagsList[index] = tagElement;
this.#tagsListElement.appendChild(tagElement);
const isPresent = currentPostTags?.has(tagName);
tagElement.classList.toggle('is-present', isPresent);
tagElement.classList.toggle('is-missing', !isPresent);
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags?.get(tagName) !== tagName);
// Just to prevent duplication, we need to include this tag to the map of suggested invalid tags
if (tagsBlacklist.includes(tagName)) {
MaintenancePopup.#markTagAsInvalid(tagElement);
this.#suggestedInvalidTags.set(tagName, tagElement);
}
});
}
/**
* Detect and process clicks made directly to the tags.
*/
#handleTagClick(event: MouseEvent) {
const targetObject = event.target;
if (!targetObject || !(targetObject instanceof HTMLElement)) {
return;
}
let tagElement: HTMLElement | null = targetObject;
if (!tagElement.classList.contains('tag')) {
tagElement = tagElement.closest<HTMLElement>('.tag');
}
if (!tagElement?.dataset.name) {
return;
}
const tagName = tagElement.dataset.name;
if (tagElement.classList.contains('is-present')) {
const isToBeRemoved = tagElement.classList.toggle('is-removed');
if (isToBeRemoved) {
this.#tagsToRemove.add(tagName);
} else {
this.#tagsToRemove.delete(tagName);
}
}
if (tagElement.classList.contains('is-missing')) {
const isToBeAdded = tagElement.classList.toggle('is-added');
if (isToBeAdded) {
this.#tagsToAdd.add(tagName);
} else {
this.#tagsToAdd.delete(tagName);
}
}
if (this.#tagsToAdd.size || this.#tagsToRemove.size) {
// Notify only once, when first planning to submit
if (!this.#isPlanningToSubmit) {
MaintenancePopup.#notifyAboutPendingSubmission(true);
}
this.#isPlanningToSubmit = true;
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting');
}
}
#onMouseEnteredArea() {
if (this.#tagsSubmissionTimer) {
clearTimeout(this.#tagsSubmissionTimer);
}
}
#onMouseLeftArea() {
if (this.#isPlanningToSubmit && !this.#isSubmitting) {
this.#tagsSubmissionTimer = setTimeout(
this.#onSubmissionTimerPassed.bind(this),
MaintenancePopup.#delayBeforeSubmissionMs
);
}
}
async #onSubmissionTimerPassed() {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) {
return;
}
this.#isPlanningToSubmit = false;
this.#isSubmitting = true;
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing');
let maybeTagsAndAliasesAfterUpdate;
const shouldAutoRemove = await MaintenancePopup.#maintenanceSettings.resolveStripBlacklistedTags();
try {
maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
this.#mediaBoxTools.mediaBox.imageId,
tagsList => {
for (let tagName of this.#tagsToRemove) {
tagsList.delete(tagName);
}
for (let tagName of this.#tagsToAdd) {
tagsList.add(tagName);
}
if (shouldAutoRemove) {
for (let tagName of tagsBlacklist) {
tagsList.delete(tagName);
}
} else {
for (let tagName of tagsList) {
if (tagsBlacklist.includes(tagName)) {
throw new BlackListedTagsEncounteredError(tagName);
}
}
}
return tagsList;
}
);
} catch (e) {
if (e instanceof BlackListedTagsEncounteredError) {
this.#revealInvalidTags();
} else {
console.warn('Tags submission failed:', e);
}
MaintenancePopup.#notifyAboutPendingSubmission(false);
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
this.#isSubmitting = false;
return;
}
if (maybeTagsAndAliasesAfterUpdate) {
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
}
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete');
this.#tagsToAdd.clear();
this.#tagsToRemove.clear();
this.#refreshTagsList();
MaintenancePopup.#notifyAboutPendingSubmission(false);
this.#isSubmitting = false;
}
#revealInvalidTags() {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases;
if (!tagsAndAliases) {
return;
}
const firstTagInList = this.#tagsList[0];
for (let tagName of tagsBlacklist) {
if (tagsAndAliases.has(tagName)) {
if (this.#suggestedInvalidTags.has(tagName)) {
continue;
}
const tagElement = MaintenancePopup.#buildTagElement(tagName);
MaintenancePopup.#markTagAsInvalid(tagElement);
tagElement.classList.add('is-present');
this.#suggestedInvalidTags.set(tagName, tagElement);
if (firstTagInList && firstTagInList.isConnected) {
this.#tagsListElement.insertBefore(tagElement, firstTagInList);
} else {
this.#tagsListElement.appendChild(tagElement);
}
}
}
}
get isActive() {
return this.container.classList.contains('is-active');
}
static #buildTagElement(tagName: string): HTMLElement {
const tagElement = document.createElement('span');
tagElement.classList.add('tag');
tagElement.innerText = tagName;
tagElement.dataset.name = tagName;
return tagElement;
}
/**
* Marks the tag with red color.
* @param tagElement Element to mark.
*/
static #markTagAsInvalid(tagElement: HTMLElement) {
tagElement.dataset.tagCategory = 'error';
tagElement.setAttribute('data-tag-category', 'error');
}
/**
* Controller with maintenance settings.
*/
static #maintenanceSettings = new MaintenanceSettings();
/**
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
* at the very start to retrieve the currently active profile.
* @param callback Callback to execute whenever selection of active profile or profile itself has been changed.
* @return Unsubscribe function. Call it to stop watching for changes.
*/
static #watchActiveProfile(callback: (profile: MaintenanceProfile | null) => void): () => void {
let lastActiveProfileId: string | null | undefined = null;
const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => {
if (lastActiveProfileId) {
callback(
profiles.find(profile => profile.id === lastActiveProfileId) || null
);
}
});
const unsubscribeFromMaintenanceSettings = this.#maintenanceSettings.subscribe(settings => {
if (settings.activeProfile === lastActiveProfileId) {
return;
}
lastActiveProfileId = settings.activeProfile;
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(callback);
});
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(profileOrNull => {
if (profileOrNull) {
lastActiveProfileId = profileOrNull.id;
}
callback(profileOrNull);
});
return () => {
unsubscribeFromProfilesChanges();
unsubscribeFromMaintenanceSettings();
}
}
/**
* Notify the frontend about new pending submission started.
* @param isStarted True if started, false if ended.
*/
static #notifyAboutPendingSubmission(isStarted: boolean) {
if (this.#pendingSubmissionCount === null) {
this.#pendingSubmissionCount = 0;
this.#initializeExitPromptHandler();
}
this.#pendingSubmissionCount += isStarted ? 1 : -1;
}
/**
* Subscribe to the global window closing event, show the prompt when there are pending submission.
*/
static #initializeExitPromptHandler() {
window.addEventListener('beforeunload', event => {
if (!this.#pendingSubmissionCount) {
return;
}
event.preventDefault();
event.returnValue = true;
});
}
static #scrapedAPI = new ScrapedAPI();
static #delayBeforeSubmissionMs = 500;
/**
* Amount of pending submissions or NULL if logic was not yet initialized.
*/
static #pendingSubmissionCount: number|null = null;
}
export function createMaintenancePopup() {
const container = document.createElement('div');
new MaintenancePopup(container);
return container;
}

View File

@@ -1,28 +1,31 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { on } from "$lib/components/events/comms";
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
export class MaintenanceStatusIcon extends BaseComponent {
/** @type {import('MediaBoxTools.js').MediaBoxTools} */
#mediaBoxTools;
#mediaBoxTools: MediaBoxTools | null = null;
build() {
this.container.innerText = '🔧';
}
init() {
if (!this.container.parentElement) {
throw new Error('Missing parent element for the maintenance status icon!');
}
this.#mediaBoxTools = getComponent(this.container.parentElement);
if (!this.#mediaBoxTools) {
throw new Error('Status icon element initialized outside of the media box!');
}
this.#mediaBoxTools.on('maintenance-state-change', this.#onMaintenanceStateChanged.bind(this));
on(this.#mediaBoxTools, EVENT_MAINTENANCE_STATE_CHANGED, this.#onMaintenanceStateChanged.bind(this));
}
/**
* @param {CustomEvent<string>} stateChangeEvent
*/
#onMaintenanceStateChanged(stateChangeEvent) {
#onMaintenanceStateChanged(stateChangeEvent: CustomEvent<string>) {
// TODO Replace those with FontAwesome icons later. Those icons can probably be sourced from the website itself.
switch (stateChangeEvent.detail) {
case "ready":
@@ -38,7 +41,11 @@ export class MaintenanceStatusIcon extends BaseComponent {
break;
case "complete":
this.container.innerText = '✅'
this.container.innerText = '✅';
break;
case "failed":
this.container.innerText = '⚠️';
break;
default:

View File

@@ -1,78 +0,0 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import {MaintenancePopup} from "$lib/components/MaintenancePopup.js";
export class MediaBoxTools extends BaseComponent {
/** @type {import('MediaBoxWrapper.js').MediaBoxWrapper|null} */
#mediaBox;
/** @type {MaintenancePopup|null} */
#maintenancePopup = null;
init() {
const mediaBoxElement = this.container.closest('.media-box');
if (!mediaBoxElement) {
throw new Error('Toolbox element initialized outside of the media box!');
}
this.#mediaBox = getComponent(mediaBoxElement);
for (let childElement of this.container.children) {
const component = getComponent(childElement);
if (!component) {
continue;
}
if (!component.isInitialized) {
component.initialize();
}
if (!this.#maintenancePopup && component instanceof MaintenancePopup) {
this.#maintenancePopup = component;
}
}
this.on('active-profile-changed', this.#onActiveProfileChanged.bind(this));
}
/**
* @param {CustomEvent<MaintenanceProfile|null>} profileChangedEvent
*/
#onActiveProfileChanged(profileChangedEvent) {
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
}
/**
* @return {MaintenancePopup|null}
*/
get maintenancePopup() {
return this.#maintenancePopup;
}
/**
* @return {import('MediaBoxWrapper.js').MediaBoxWrapper|null}
*/
get mediaBox() {
return this.#mediaBox;
}
}
/**
* Create a maintenance popup element.
* @param {HTMLElement} childrenElements List of children elements to append to the component.
* @return {HTMLElement} The maintenance popup element.
*/
export function createMediaBoxTools(...childrenElements) {
const mediaBoxToolsContainer = document.createElement('div');
mediaBoxToolsContainer.classList.add('media-box-tools');
if (childrenElements.length) {
mediaBoxToolsContainer.append(...childrenElements);
}
new MediaBoxTools(mediaBoxToolsContainer);
return mediaBoxToolsContainer;
}

View File

@@ -0,0 +1,74 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { MaintenancePopup } from "$lib/components/MaintenancePopup";
import { on } from "$lib/components/events/comms";
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxWrapper } from "$lib/components/MediaBoxWrapper";
import type MaintenanceProfile from "$entities/MaintenanceProfile";
export class MediaBoxTools extends BaseComponent {
#mediaBox: MediaBoxWrapper | null = null;
#maintenancePopup: MaintenancePopup | null = null;
init() {
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
if (!mediaBoxElement) {
throw new Error('Toolbox element initialized outside of the media box!');
}
this.#mediaBox = getComponent(mediaBoxElement);
for (let childElement of this.container.children) {
if (!(childElement instanceof HTMLElement)) {
continue;
}
const component = getComponent(childElement);
if (!component) {
continue;
}
if (!component.isInitialized) {
component.initialize();
}
if (!this.#maintenancePopup && component instanceof MaintenancePopup) {
this.#maintenancePopup = component;
}
}
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
}
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
}
get maintenancePopup(): MaintenancePopup | null {
return this.#maintenancePopup;
}
get mediaBox(): MediaBoxWrapper | null {
return this.#mediaBox;
}
}
/**
* Create a maintenance popup element.
* @param childrenElements List of children elements to append to the component.
* @return The maintenance popup element.
*/
export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement {
const mediaBoxToolsContainer = document.createElement('div');
mediaBoxToolsContainer.classList.add('media-box-tools');
if (childrenElements.length) {
mediaBoxToolsContainer.append(...childrenElements);
}
new MediaBoxTools(mediaBoxToolsContainer);
return mediaBoxToolsContainer;
}

View File

@@ -1,86 +0,0 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import {buildTagsAndAliasesMap} from "$lib/booru/TagsUtils.js";
export class MediaBoxWrapper extends BaseComponent {
#thumbnailContainer = null;
#imageLinkElement = null;
/** @type {Map<string,string>|null} */
#tagsAndAliases = null;
init() {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer.querySelector('a');
this.on('tags-updated', this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
/**
* @param {CustomEvent<Map<string,string>>} tagsUpdatedEvent
*/
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent) {
const updatedMap = tagsUpdatedEvent.detail;
if (!(updatedMap instanceof Map)) {
throw new TypeError("Tags and aliases should be stored as Map!");
}
this.#tagsAndAliases = updatedMap;
}
#calculateMediaBoxTags() {
/** @type {string[]|string[]} */
const
tagAliases = this.#thumbnailContainer.dataset.imageTagAliases?.split(', ') || [],
actualTags = this.#imageLinkElement.title.split(' | Tagged: ')[1]?.split(', ') || [];
return buildTagsAndAliasesMap(tagAliases, actualTags);
}
/**
* @return {Map<string, string>|null}
*/
get tagsAndAliases() {
if (!this.#tagsAndAliases) {
this.#tagsAndAliases = this.#calculateMediaBoxTags();
}
return this.#tagsAndAliases;
}
get imageId() {
return parseInt(
this.container.dataset.imageId
);
}
/**
* @return {ImageURIs}
*/
get imageLinks() {
return JSON.parse(this.#thumbnailContainer.dataset.uris);
}
}
/**
* Wrap the media box element into the special wrapper.
* @param {HTMLElement} mediaBoxContainer
* @param {HTMLElement[]} childComponentElements
*/
export function initializeMediaBox(mediaBoxContainer, childComponentElements) {
new MediaBoxWrapper(mediaBoxContainer)
.initialize();
for (let childComponentElement of childComponentElements) {
mediaBoxContainer.appendChild(childComponentElement);
getComponent(childComponentElement)?.initialize();
}
}
/**
* @typedef {Object} ImageURIs
* @property {string} full
* @property {string} large
* @property {string} small
*/

View File

@@ -0,0 +1,99 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
import { on } from "$lib/components/events/comms";
import { EVENT_TAGS_UPDATED } from "$lib/components/events/maintenance-popup-events";
export class MediaBoxWrapper extends BaseComponent {
#thumbnailContainer: HTMLElement | null = null;
#imageLinkElement: HTMLAnchorElement | null = null;
#tagsAndAliases: Map<string, string> | null = null;
init() {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
const updatedMap = tagsUpdatedEvent.detail;
if (!(updatedMap instanceof Map)) {
throw new TypeError("Tags and aliases should be stored as Map!");
}
this.#tagsAndAliases = updatedMap;
}
#calculateMediaBoxTags() {
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
return buildTagsAndAliasesMap(tagAliases, actualTags);
}
get tagsAndAliases(): Map<string, string> | null {
if (!this.#tagsAndAliases) {
this.#tagsAndAliases = this.#calculateMediaBoxTags();
}
return this.#tagsAndAliases;
}
get imageId(): number {
const imageId = this.container.dataset.imageId;
if (!imageId) {
throw new Error('Missing image ID');
}
return parseInt(imageId);
}
get imageLinks(): App.ImageURIs {
const jsonUris = this.#thumbnailContainer?.dataset.uris;
if (!jsonUris) {
throw new Error('Missing URIs!');
}
return JSON.parse(jsonUris);
}
}
/**
* Wrap the media box element into the special wrapper.
*/
export function initializeMediaBox(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
new MediaBoxWrapper(mediaBoxContainer)
.initialize();
for (let childComponentElement of childComponentElements) {
mediaBoxContainer.appendChild(childComponentElement);
getComponent(childComponentElement)?.initialize();
}
}
export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf<HTMLElement>) {
window.addEventListener('resize', () => {
let lastMediaBox: HTMLElement | null = null;
let lastMediaBoxPosition: number | null = null;
for (const mediaBoxElement of mediaBoxesList) {
const yPosition = mediaBoxElement.getBoundingClientRect().y;
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
lastMediaBox = mediaBoxElement;
lastMediaBoxPosition = yPosition;
}
// Last-ever media box is checked separately
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
lastMediaBox.classList.add('media-box--last');
}
})
}

View File

@@ -1,186 +0,0 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {QueryLexer, QuotedTermToken, TermToken, Token} from "$lib/booru/search/QueryLexer.js";
export class SearchWrapper extends BaseComponent {
/** @type {HTMLInputElement|null} */
#searchField = null;
/** @type {HTMLInputElement|null} */
#autoCompleteField = null;
/** @type {string|null} */
#lastParsedSearchValue = null;
/** @type {Token[]} */
#cachedParsedQuery = [];
build() {
this.container.classList.add('header__search--completable');
this.#searchField = this.container.querySelector('input[name=q]');
this.#searchField.autocomplete = 'off'; // Browser's auto-complete will get in the way!
const autoCompleteField = document.createElement('input');
autoCompleteField.dataset.ac = 'true';
autoCompleteField.dataset.acMinLength = '3';
autoCompleteField.dataset.acSource = '/autocomplete/tags?term=';
autoCompleteField.classList.add('search-autocomplete-dummy');
this.#autoCompleteField = autoCompleteField;
this.container.appendChild(autoCompleteField);
}
init() {
this.#searchField.addEventListener('input', this.#updateAutoCompletedFragment.bind(this));
this.#searchField.addEventListener('keydown', this.#onSearchFieldKeyPressed.bind(this));
this.#searchField.addEventListener('selectionchange', this.#updateAutoCompletedFragment.bind(this));
}
#updateAutoCompletedFragment() {
const searchableFragment = this.#findCurrentTagFragment();
this.#emitAutoComplete(searchableFragment || '');
}
#getInputUserSelection() {
return Math.min(
this.#searchField.selectionStart,
this.#searchField.selectionEnd
);
}
#resolveQueryTokens() {
const searchValue = this.#searchField.value;
if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) {
return this.#cachedParsedQuery;
}
this.#lastParsedSearchValue = searchValue;
this.#cachedParsedQuery = new QueryLexer(searchValue).parse();
return this.#cachedParsedQuery;
}
/**
* @param {KeyboardEvent} event
*/
#onSearchFieldKeyPressed(event) {
// On enter, attempt to replace the current active tag in the query with autocomplete selection
if (event.code === 'Enter') {
this.#onEnterPressed(event);
}
this.#autoCompleteField.dispatchEvent(
new KeyboardEvent('keydown', {
keyCode: event.keyCode
})
);
// Similarly to the site's autocomplete logic, we need to prevent the arrows up/down from causing any issues
if (event.keyCode === 38 || event.keyCode === 40) {
event.preventDefault();
}
}
/**
* @param {KeyboardEvent} event
*/
#onEnterPressed(event) {
const autocompleteSelection = document.querySelector('.autocomplete__item--selected');
if (!autocompleteSelection) {
return;
}
const activeToken = SearchWrapper.#findActiveSearchTermPosition(
this.#resolveQueryTokens(),
this.#getInputUserSelection(),
);
if (activeToken instanceof TermToken || activeToken instanceof QuotedTermToken) {
const selectionStart = activeToken.index;
const selectionEnd = activeToken.index + activeToken.value.length;
let autocompletedValue = autocompleteSelection.dataset.value;
if (activeToken instanceof QuotedTermToken) {
autocompletedValue = `"${QuotedTermToken.encode(autocompletedValue)}"`;
}
this.#searchField.value = this.#searchField.value.slice(0, selectionStart)
+ autocompletedValue
+ this.#searchField.value.slice(selectionEnd);
const newSelectionEnd = selectionStart + autocompletedValue.length;
// Place the caret at the end of the currently active tag.
// Actually, this does not work for some reason. After the tag is sent to the field and selection was changed to
// the end of the inserted tag, browser just does not scroll the input to the caret position.
this.#searchField.focus();
this.#searchField.setSelectionRange(newSelectionEnd, newSelectionEnd);
event.preventDefault();
}
}
/**
* @return {string|null}
*/
#findCurrentTagFragment() {
if (!this.#searchField) {
return null;
}
let searchValue = this.#searchField.value;
if (!searchValue) {
return null;
}
const token = SearchWrapper.#findActiveSearchTermPosition(
this.#resolveQueryTokens(),
this.#getInputUserSelection(),
);
if (token instanceof TermToken) {
return token.value;
}
if (token instanceof QuotedTermToken) {
return token.decodedValue;
}
return searchValue;
}
#emitAutoComplete(userInputFragment) {
this.#autoCompleteField.value = userInputFragment;
// Should be at least one frame away, since input event always removes autocomplete window
requestAnimationFrame(() => {
this.#autoCompleteField.dispatchEvent(
new InputEvent('input', {bubbles: true})
);
const autocompleteContainer = document.querySelector('.autocomplete');
if (autocompleteContainer) {
autocompleteContainer.style.left = `${this.container.offsetLeft}px`;
}
});
}
/**
* Loosely estimate where current selected search term is located and return it if found.
* @param {Token[]} tokens Search value to find the actively selected term from.
* @param {number} userSelectionIndex The index of the user selection.
* @return {Token|null} Search term object or NULL if nothing found.
*/
static #findActiveSearchTermPosition(tokens, userSelectionIndex) {
return tokens.find(
token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex
);
}
}
export function initializeSearWrapper(formElement) {
new SearchWrapper(formElement).initialize();
}

View File

@@ -1,23 +0,0 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {SearchWrapper} from "$lib/components/SearchWrapper.js";
class SiteHeaderWrapper extends BaseComponent {
/** @type {SearchWrapper|null} */
#searchWrapper = null;
build() {
const searchForm = this.container.querySelector('.header__search');
this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null;
}
init() {
if (this.#searchWrapper) {
this.#searchWrapper.initialize();
}
}
}
export function initializeSiteHeader(siteHeaderElement) {
new SiteHeaderWrapper(siteHeaderElement)
.initialize();
}

View File

@@ -0,0 +1,326 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
import { getComponent } from "$lib/components/base/component-utils";
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
import { on } from "$lib/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
import type TagGroup from "$entities/TagGroup";
const categoriesResolver = new CustomCategoriesResolver();
export class TagDropdownWrapper extends BaseComponent {
/**
* Container with dropdown elements to insert options into.
*/
#dropdownContainer: HTMLElement | null = null;
/**
* Button to add or remove the current tag into/from the active profile.
*/
#toggleOnExistingButton: HTMLAnchorElement | null = null;
/**
* Button to create a new profile, make it active and add the current tag into the active profile.
*/
#addToNewButton: HTMLAnchorElement | null = null;
/**
* Local clone of the currently active profile used for updating the list of tags.
*/
#activeProfile: MaintenanceProfile | null = null;
/**
* Is cursor currently entered the dropdown.
*/
#isEntered: boolean = false;
#originalCategory: string | undefined | null = null;
build() {
this.#dropdownContainer = this.container.querySelector('.dropdown__content');
}
init() {
this.on('mouseenter', this.#onDropdownEntered.bind(this));
this.on('mouseleave', this.#onDropdownLeft.bind(this));
TagDropdownWrapper.#watchActiveProfile(activeProfileOrNull => {
this.#activeProfile = activeProfileOrNull;
if (this.#isEntered) {
this.#updateButtons();
}
});
on(this, EVENT_TAG_GROUP_RESOLVED, this.#onTagGroupResolved.bind(this));
}
#onTagGroupResolved(resolvedGroupEvent: CustomEvent<TagGroup | null>) {
if (this.originalCategory) {
return;
}
const maybeTagGroup = resolvedGroupEvent.detail;
if (!maybeTagGroup) {
this.tagCategory = this.originalCategory;
return;
}
this.tagCategory = maybeTagGroup.settings.category;
}
get tagName() {
return this.container.dataset.tagName;
}
/**
* @return {string|undefined}
*/
get tagCategory() {
return this.container.dataset.tagCategory;
}
/**
* @param {string|undefined} targetCategory
*/
set tagCategory(targetCategory) {
// Make sure original category is properly stored.
this.originalCategory;
this.container.dataset.tagCategory = targetCategory;
if (targetCategory) {
this.container.setAttribute('data-tag-category', targetCategory);
return;
}
this.container.removeAttribute('data-tag-category');
}
/**
* @return {string|undefined}
*/
get originalCategory() {
if (this.#originalCategory === null) {
this.#originalCategory = this.tagCategory;
}
return this.#originalCategory;
}
#onDropdownEntered() {
this.#isEntered = true;
this.#updateButtons();
}
#onDropdownLeft() {
this.#isEntered = false;
}
#updateButtons() {
if (!this.#activeProfile) {
this.#addToNewButton ??= TagDropdownWrapper.#createDropdownLink(
'Add to new tagging profile',
this.#onAddToNewClicked.bind(this)
);
if (!this.#addToNewButton.isConnected) {
this.#dropdownContainer?.append(this.#addToNewButton);
}
} else {
this.#addToNewButton?.remove();
}
if (this.#activeProfile) {
this.#toggleOnExistingButton ??= TagDropdownWrapper.#createDropdownLink(
'Add to existing tagging profile',
this.#onToggleInExistingClicked.bind(this)
);
const profileName = this.#activeProfile.settings.name;
let profileSpecificButtonText = `Add to profile "${profileName}"`;
const tagName = this.tagName;
if (tagName && this.#activeProfile.settings.tags.includes(tagName)) {
profileSpecificButtonText = `Remove from profile "${profileName}"`;
}
// Derpibooru has icons in dropdown. Make sure to only edit text and keep the icon untouched. Also, add the space
// before the text to make space between text and icon.
if (__CURRENT_SITE__ === 'derpibooru' && this.#toggleOnExistingButton.lastChild instanceof Text) {
this.#toggleOnExistingButton.lastChild.textContent = ` ${profileSpecificButtonText}`;
} else {
this.#toggleOnExistingButton.textContent = profileSpecificButtonText;
}
if (!this.#toggleOnExistingButton.isConnected) {
this.#dropdownContainer?.append(this.#toggleOnExistingButton);
}
return;
}
this.#toggleOnExistingButton?.remove();
}
async #onAddToNewClicked() {
const tagName = this.tagName;
if (!tagName) {
throw new Error('Missing tag name to create the profile!');
}
const profile = new MaintenanceProfile(crypto.randomUUID(), {
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
tags: [this.tagName],
temporary: true,
});
await profile.save();
await TagDropdownWrapper.#maintenanceSettings.setActiveProfileId(profile.id);
}
async #onToggleInExistingClicked() {
if (!this.#activeProfile) {
return;
}
const tagsList = new Set(this.#activeProfile.settings.tags);
const targetTagName = this.tagName;
if (!targetTagName) {
throw new Error('Missing tag name!');
}
if (tagsList.has(targetTagName)) {
tagsList.delete(targetTagName);
} else {
tagsList.add(targetTagName);
}
this.#activeProfile.settings.tags = Array.from(tagsList.values());
await this.#activeProfile.save();
}
static #maintenanceSettings = new MaintenanceSettings();
/**
* Watch for changes to active profile.
* @param onActiveProfileChange Callback to call when profile was
* changed.
*/
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile | null) => void) {
let lastActiveProfile: string | null = null;
this.#maintenanceSettings.subscribe((settings) => {
lastActiveProfile = settings.activeProfile ?? null;
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(onActiveProfileChange);
});
MaintenanceProfile.subscribe(profiles => {
const activeProfile = profiles
.find(profile => profile.id === lastActiveProfile);
onActiveProfileChange(activeProfile ?? null
);
});
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(activeProfile => {
lastActiveProfile = activeProfile?.id ?? null;
onActiveProfileChange(activeProfile);
});
}
/**
* Create element for dropdown.
* @param text Base text for the option.
* @param onClickHandler Click handler. Event will be prevented by default.
* @return
*/
static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement {
const dropdownLink = document.createElement('a');
dropdownLink.href = '#';
dropdownLink.className = 'tag__dropdown__link';
// Derpibooru has an icon in dropdown item. Create the icon and place the text with additional space in front of it.
if (__CURRENT_SITE__ === 'derpibooru') {
const dropdownLinkIcon = document.createElement('i');
dropdownLinkIcon.classList.add('fa', 'fa-tags');
dropdownLink.textContent = ` ${text}`;
dropdownLink.insertAdjacentElement('afterbegin', dropdownLinkIcon);
} else {
dropdownLink.textContent = text;
}
dropdownLink.addEventListener('click', event => {
event.preventDefault();
onClickHandler(event);
});
return dropdownLink;
}
}
export function wrapTagDropdown(element: HTMLElement) {
// Skip initialization when tag component is already wrapped
if (getComponent(element)) {
return;
}
const tagDropdown = new TagDropdownWrapper(element);
tagDropdown.initialize();
categoriesResolver.addElement(tagDropdown);
}
const processedElementsSet = new WeakSet<HTMLElement>();
export function watchTagDropdownsInTagsEditor() {
// We only need to watch for new editor elements if there is a tag editor present on the page
if (!document.querySelector('#image_tags_and_source')) {
return;
}
document.body.addEventListener('mouseover', event => {
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) {
return;
}
if (processedElementsSet.has(targetElement)) {
return;
}
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
if (!closestTagEditor || processedElementsSet.has(closestTagEditor)) {
processedElementsSet.add(targetElement);
return;
}
processedElementsSet.add(targetElement);
processedElementsSet.add(closestTagEditor);
for (const tagDropdownElement of closestTagEditor.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
});
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
for (const tagDropdownElement of event.detail.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
});
}

View File

@@ -0,0 +1,150 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { emit, on, type UnsubscribeFunction } from "$lib/components/events/comms";
import { EVENT_FETCH_COMPLETE } from "$lib/components/events/booru-events";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
export class TagsForm extends BaseComponent {
protected init() {
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
const unsubscribe = on(
this.container,
EVENT_FETCH_COMPLETE,
() => this.#waitAndDetectUpdatedForm(unsubscribe),
);
}
#waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void {
const elementContainingTagEditor = this.container
.closest('#image_tags_and_source')
?.parentElement;
if (!elementContainingTagEditor) {
return;
}
const observer = new MutationObserver(() => {
const tagsFormElement = elementContainingTagEditor.querySelector<HTMLElement>('#tags-form');
if (!tagsFormElement || getComponent(tagsFormElement)) {
return;
}
const tagFormComponent = new TagsForm(tagsFormElement);
tagFormComponent.initialize();
const fullTagEditor = tagFormComponent.parentTagEditorElement;
if (fullTagEditor) {
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
} else {
console.info('Tag form is not in the tag editor. Event is not sent.');
}
observer.disconnect();
unsubscribe();
});
observer.observe(elementContainingTagEditor, {
subtree: true,
childList: true,
});
// Make sure to forcibly disconnect everything after a while.
setTimeout(() => {
observer.disconnect();
unsubscribe();
}, 5000);
}
get parentTagEditorElement(): HTMLElement | null {
return this.container.closest<HTMLElement>('.js-tagsauce')
}
/**
* Collect all the tag categories available on the page and color the tags in the editor according to them.
*/
refreshTagColors() {
const tagCategories = this.#gatherTagCategories();
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
for (const tagElement of editableTags) {
// Tag name is stored in the "remove" link and not in the tag itself.
const removeLink = tagElement.querySelector('a');
if (!removeLink) {
continue;
}
const tagName = removeLink.dataset.tagName;
if (!tagName || !tagCategories.has(tagName)) {
continue;
}
const categoryName = tagCategories.get(tagName)!;
tagElement.dataset.tagCategory = categoryName;
tagElement.setAttribute('data-tag-category', categoryName);
}
}
/**
* Collect list of categories from the tags on the page.
* @return
*/
#gatherTagCategories(): Map<string, string> {
const tagCategories: Map<string, string> = new Map();
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
const tagName = tagElement.dataset.tagName;
const tagCategory = tagElement.dataset.tagCategory;
if (!tagName || !tagCategory) {
console.warn('Missing tag name or category!');
continue;
}
tagCategories.set(tagName, tagCategory);
}
return tagCategories;
}
static watchForEditors() {
document.body.addEventListener('click', event => {
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) {
return;
}
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
if (!tagEditorWrapper) {
return;
}
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
if (!refreshTrigger) {
return;
}
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
if (!tagFormElement) {
return;
}
let tagEditor = getComponent(tagFormElement);
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
tagEditor = new TagsForm(tagFormElement);
tagEditor.initialize();
}
(tagEditor as TagsForm).refreshTagColors();
});
}
}

View File

@@ -0,0 +1,244 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import type TagGroup from "$entities/TagGroup";
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
import { on } from "$lib/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
import { getComponent } from "$lib/components/base/component-utils";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
import TagSettings from "$lib/extension/settings/TagSettings";
export class TagsListBlock extends BaseComponent {
#tagsListButtonsContainer: HTMLElement | null = null;
#tagsListContainer: HTMLElement | null = null;
#toggleGroupingButton = document.createElement('a');
#toggleGroupingButtonIcon = document.createElement('i');
#tagSettings = new TagSettings();
#shouldDisplaySeparation = false;
#separatedGroups = new Map<string, TagGroup>();
#separatedHeaders = new Map<string, HTMLElement>();
#groupsCount = new Map<string, number>();
#lastTagGroup = new WeakMap<TagDropdownWrapper, TagGroup | null>;
#isReorderingPlanned = false;
protected build() {
this.#tagsListButtonsContainer = this.container.querySelector('.block.tagsauce .block__header__buttons');
this.#tagsListContainer = this.container.querySelector('.tag-list');
this.#toggleGroupingButton.innerText = ' Grouping';
this.#toggleGroupingButton.href = 'javascript:void(0)';
this.#toggleGroupingButton.classList.add('button', 'button--link', 'button--inline');
this.#toggleGroupingButton.title = 'Toggle the global groups separation option. This will only toggle global ' +
'setting without changing the separation of specific groups.';
this.#toggleGroupingButtonIcon.classList.add('fas', TagsListBlock.#iconGroupingDisabled);
this.#toggleGroupingButton.prepend(this.#toggleGroupingButtonIcon);
if (this.#tagsListButtonsContainer) {
this.#tagsListButtonsContainer.append(this.#toggleGroupingButton);
}
}
init() {
this.#tagSettings.resolveGroupSeparation().then(this.#onTagSeparationChange.bind(this));
this.#tagSettings.subscribe(settings => {
this.#onTagSeparationChange(Boolean(settings.groupSeparation))
});
on(
this,
EVENT_TAG_GROUP_RESOLVED,
this.#onTagDropdownCustomGroupResolved.bind(this)
);
this.#toggleGroupingButton.addEventListener('click', this.#onToggleGroupingClicked.bind(this));
}
#onTagSeparationChange(isSeparationEnabled: boolean) {
if (this.#shouldDisplaySeparation === isSeparationEnabled) {
return;
}
this.#shouldDisplaySeparation = isSeparationEnabled;
this.#reorderSeparatedGroups();
this.#updateToggleSeparationButton();
}
#updateToggleSeparationButton() {
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingEnabled, this.#shouldDisplaySeparation);
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingDisabled, !this.#shouldDisplaySeparation);
}
#onTagDropdownCustomGroupResolved(resolvedCustomGroupEvent: CustomEvent<TagGroup | null>) {
const maybeDropdownElement = resolvedCustomGroupEvent.target;
if (!(maybeDropdownElement instanceof HTMLElement)) {
return;
}
const tagDropdown = getComponent<TagDropdownWrapper>(maybeDropdownElement);
if (!tagDropdown) {
return;
}
const tagGroup = resolvedCustomGroupEvent.detail;
if (tagGroup) {
this.#handleTagGroupChanges(tagGroup);
}
this.#handleResolvedTagGroup(tagGroup, tagDropdown);
if (!this.#isReorderingPlanned) {
this.#isReorderingPlanned = true;
requestAnimationFrame(this.#reorderSeparatedGroups.bind(this));
}
}
#onToggleGroupingClicked(event: Event) {
event.preventDefault();
void this.#tagSettings.setGroupSeparation(!this.#shouldDisplaySeparation);
}
#handleTagGroupChanges(tagGroup: TagGroup) {
const groupId = tagGroup.id;
const processedGroup = this.#separatedGroups.get(groupId);
if (!tagGroup.settings.separate && processedGroup) {
this.#separatedGroups.delete(groupId);
this.#separatedHeaders.get(groupId)?.remove();
this.#separatedHeaders.delete(groupId);
return;
}
// Every time group is updated, a new object is being initialized
if (tagGroup !== processedGroup) {
this.#createOrUpdateHeaderForGroup(tagGroup);
this.#separatedGroups.set(groupId, tagGroup);
}
}
#createOrUpdateHeaderForGroup(group: TagGroup) {
let heading = this.#separatedHeaders.get(group.id);
if (!heading) {
heading = document.createElement('h2');
// Heading is hidden by default and shown on next frame if there are tags to show in the section.
heading.style.display = 'none';
heading.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(group.id)}, 0)`;
heading.style.flexBasis = '100%';
heading.classList.add('tag-category-headline');
// We're inserting heading to the top just to make sure that heading is always in front of the tags related to
// this category.
this.#tagsListContainer?.insertAdjacentElement('afterbegin', heading);
this.#separatedHeaders.set(group.id, heading);
}
heading.innerText = group.settings.name;
}
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdownWrapper) {
const previousGroupId = this.#lastTagGroup.get(tagComponent)?.id;
const currentGroupId = resolvedGroup?.id;
const isDifferentId = currentGroupId !== previousGroupId;
const isSeparationEnabled = resolvedGroup?.settings.separate;
if (isDifferentId) {
// Make sure to subtract the element from counters if there was a count before.
if (previousGroupId && this.#groupsCount.has(previousGroupId)) {
this.#groupsCount.set(previousGroupId, this.#groupsCount.get(previousGroupId)! - 1);
}
// We only need to count groups which have separation enabled.
if (currentGroupId && isSeparationEnabled) {
const count = this.#groupsCount.get(resolvedGroup.id) ?? 0;
this.#groupsCount.set(currentGroupId, count + 1);
}
}
// We're adding the CSS order for the tag as the CSS variable. This variable is updated later.
if (currentGroupId && isSeparationEnabled) {
tagComponent.container.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(currentGroupId)}, 0)`;
} else {
tagComponent.container.style.removeProperty('order');
}
// If separation is disabled in the new group, then we should remove the tag from map, so it can be recaptured
// when tag group is getting enabled later.
if (currentGroupId && !isSeparationEnabled) {
this.#lastTagGroup.delete(tagComponent);
return;
}
// Mark this tag component as related to the following group.
this.#lastTagGroup.set(tagComponent, resolvedGroup);
}
#reorderSeparatedGroups() {
this.#isReorderingPlanned = false;
const tagGroups = Array.from(this.#separatedGroups.values())
.toSorted((a, b) => a.settings.name.localeCompare(b.settings.name));
for (let index = 0; index < tagGroups.length; index++) {
const tagGroup = tagGroups[index];
const groupId = tagGroup.id;
const usedCount = this.#groupsCount.get(groupId);
const relatedHeading = this.#separatedHeaders.get(groupId);
if (this.#shouldDisplaySeparation) {
this.container.style.setProperty(TagsListBlock.#orderCssVariableForGroup(groupId), (index + 1).toString());
} else {
this.container.style.removeProperty(TagsListBlock.#orderCssVariableForGroup(groupId));
}
if (relatedHeading) {
if (!this.#shouldDisplaySeparation || !usedCount || usedCount <= 0) {
relatedHeading.style.display = 'none';
} else {
relatedHeading.style.removeProperty('display');
}
}
}
}
static #orderCssVariableForGroup(groupId: string): string {
return `--ta-order-${groupId}`;
}
static #iconGroupingDisabled = 'fa-folder';
static #iconGroupingEnabled = 'fa-folder-tree';
}
export function initializeAllTagsLists() {
for (let element of document.querySelectorAll<HTMLElement>('#image_tags_and_source')) {
if (getComponent(element)) {
return;
}
new TagsListBlock(element)
.initialize();
}
}
export function watchForUpdatedTagLists() {
on(document, EVENT_FORM_EDITOR_UPDATED, event => {
const tagsListElement = event.detail.closest<HTMLElement>('#image_tags_and_source');
if (!tagsListElement || getComponent(tagsListElement)) {
return;
}
new TagsListBlock(tagsListElement)
.initialize();
});
}

View File

@@ -1,106 +0,0 @@
import {bindComponent} from "$lib/components/base/ComponentUtils.js";
/**
* @abstract
*/
export class BaseComponent {
/** @type {HTMLElement} */
#container;
#isInitialized = false;
/**
* @param {HTMLElement} container
*/
constructor(container) {
this.#container = container;
bindComponent(container, this);
}
initialize() {
if (this.#isInitialized) {
throw new Error('The component is already initialized.');
}
this.#isInitialized = true;
this.build();
this.init();
}
/**
* @protected
*/
build() {
// This method can be implemented by the component classes to modify or create the inner elements.
}
/**
* @protected
*/
init() {
// This method can be implemented by the component classes to initialize the component.
}
/**
* @return {HTMLElement}
* @protected
*/
get container() {
return this.#container;
}
/**
* Check if the component is initialized already. If not checked, subsequent calls to the `initialize` method will
* throw an error.
* @return {boolean}
*/
get isInitialized() {
return this.#isInitialized;
}
/**
* Emit the custom event on the container element.
* @param {keyof HTMLElementEventMap|string} event The event name.
* @param {any} [detail] The event detail. Can be omitted.
*/
emit(event, detail = undefined) {
this.#container.dispatchEvent(
new CustomEvent(
event,
{
detail,
bubbles: true
}
)
);
}
/**
* Subscribe to the DOM event on the container element.
* @param {keyof HTMLElementEventMap|string} event The event name.
* @param {function(Event): void} listener The event listener.
* @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted.
* @return {function(): void} The unsubscribe function.
*/
on(event, listener, options = undefined) {
this.#container.addEventListener(event, listener, options);
return () => void this.#container.removeEventListener(event, listener, options);
}
/**
* Subscribe to the DOM event on the container element. The event listener will be called only once.
* @param {keyof HTMLElementEventMap|string} event The event name.
* @param {function(Event): void} listener The event listener.
* @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted.
* @return {function(): void} The unsubscribe function.
*/
once(event, listener, options = undefined) {
options = options || {};
options.once = true;
return this.on(event, listener, options);
}
}

View File

@@ -0,0 +1,100 @@
import { bindComponent } from "$lib/components/base/component-utils";
type ComponentEventListener<EventName extends keyof HTMLElementEventMap> =
(this: HTMLElement, event: HTMLElementEventMap[EventName]) => void;
export class BaseComponent<ContainerType extends HTMLElement = HTMLElement> {
readonly #container: ContainerType;
#isInitialized = false;
constructor(container: ContainerType) {
this.#container = container;
bindComponent(container, this);
}
initialize() {
if (this.#isInitialized) {
throw new Error('The component is already initialized.');
}
this.#isInitialized = true;
this.build();
this.init();
}
protected build(): void {
// This method can be implemented by the component classes to modify or create the inner elements.
}
protected init(): void {
// This method can be implemented by the component classes to initialize the component.
};
get container(): ContainerType {
return this.#container;
}
/**
* Check if the component is initialized already. If not checked, subsequent calls to the `initialize` method will
* throw an error.
* @return
*/
get isInitialized(): boolean {
return this.#isInitialized;
}
/**
* Emit the custom event on the container element.
* @param event The event name.
* @param [detail] The event detail. Can be omitted.
*/
emit(event: keyof HTMLElementEventMap | string, detail: any = undefined): void {
this.#container.dispatchEvent(
new CustomEvent(
event,
{
detail,
bubbles: true
}
)
);
}
/**
* Subscribe to the DOM event on the container element.
* @param event The event name.
* @param listener The event listener.
* @param [options] The event listener options. Can be omitted.
* @return The unsubscribe function.
*/
on<EventName extends keyof HTMLElementEventMap>(
event: EventName,
listener: ComponentEventListener<EventName>,
options?: AddEventListenerOptions,
): () => void {
this.#container.addEventListener(event, listener, options);
return () => void this.#container.removeEventListener(event, listener, options);
}
/**
* Subscribe to the DOM event on the container element. The event listener will be called only once.
* @param event The event name.
* @param listener The event listener.
* @param [options] The event listener options. Can be omitted.
* @return The unsubscribe function.
*/
once<EventName extends keyof HTMLElementEventMap>(
event: EventName,
listener: ComponentEventListener<EventName>,
options?: AddEventListenerOptions,
): () => void {
options = options || {};
options.once = true;
return this.on(event, listener, options);
}
}

View File

@@ -1,22 +0,0 @@
const instanceSymbol = Symbol('instance');
/**
* @param {HTMLElement} element
* @return {import('./BaseComponent.js').BaseComponent|null}
*/
export function getComponent(element) {
return element[instanceSymbol] || null;
}
/**
* Bind the component to the selected element.
* @param {HTMLElement} element The element to bind the component to.
* @param {import('./BaseComponent.js').BaseComponent} instance The component instance.
*/
export function bindComponent(element, instance) {
if (element[instanceSymbol]) {
throw new Error('The element is already bound to a component.');
}
element[instanceSymbol] = instance;
}

View File

@@ -0,0 +1,29 @@
import type { BaseComponent } from "$lib/components/base/BaseComponent";
const instanceSymbol = Symbol.for('instance');
interface ElementWithComponent<T> extends HTMLElement {
[instanceSymbol]?: T;
}
/**
* Get the component from the element, if there is one.
* @param {HTMLElement} element
* @return
*/
export function getComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>): T | null {
return element[instanceSymbol] || null;
}
/**
* Bind the component to the selected element.
* @param element The element to bind the component to.
* @param instance The component instance.
*/
export function bindComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>, instance: T): void {
if (element[instanceSymbol]) {
throw new Error('The element is already bound to a component.');
}
element[instanceSymbol] = instance;
}

View File

@@ -0,0 +1,5 @@
export const EVENT_FETCH_COMPLETE = 'fetchcomplete';
export interface BooruEventsMap {
[EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation.
}

View File

@@ -0,0 +1,100 @@
import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenance-popup-events";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events";
import type { BooruEventsMap } from "$lib/components/events/booru-events";
import type { TagsFormEventsMap } from "$lib/components/events/tags-form-events";
import type { TagDropdownEvents } from "$lib/components/events/tag-dropdown-events";
type EventsMapping =
MaintenancePopupEventsMap
& FullscreenViewerEventsMap
& BooruEventsMap
& TagsFormEventsMap
& TagDropdownEvents;
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
export type UnsubscribeFunction = () => void;
type ResolvableTarget = EventTarget | BaseComponent;
function resolveTarget(componentOrElement: ResolvableTarget): EventTarget {
if (componentOrElement instanceof BaseComponent) {
return componentOrElement.container;
}
return componentOrElement;
}
export function emit<Event extends keyof EventsMapping>(
targetOrComponent: ResolvableTarget,
event: Event,
details: EventsMapping[Event]
) {
const target = resolveTarget(targetOrComponent);
target.dispatchEvent(
new CustomEvent(event, {
detail: details,
bubbles: true
})
);
}
export function on<Event extends keyof EventsMapping>(
targetOrComponent: ResolvableTarget,
eventName: Event,
callback: EventCallback<EventsMapping[Event]>,
options: AddEventListenerOptions | null = null
): UnsubscribeFunction {
const target = resolveTarget(targetOrComponent);
const controller = new AbortController();
target.addEventListener(
eventName,
callback as EventListener,
{
signal: controller.signal,
once: options?.once
}
);
return () => controller.abort();
}
const onceOptions = {once: true};
export function once<Event extends keyof EventsMapping>(
targetOrComponent: ResolvableTarget,
eventName: Event,
callback: EventCallback<EventsMapping[Event]>
): UnsubscribeFunction {
return on(
targetOrComponent,
eventName,
callback,
onceOptions
);
}
class TargetedEmitter {
readonly #element: ResolvableTarget;
constructor(targetOrComponent: ResolvableTarget) {
this.#element = targetOrComponent;
}
emit<Event extends keyof EventsMapping>(eventName: Event, details: EventsMapping[Event]): void {
emit(this.#element, eventName, details);
}
on<Event extends keyof EventsMapping>(eventName: Event, callback: EventCallback<EventsMapping[Event]>, options: AddEventListenerOptions | null = null): UnsubscribeFunction {
return on(this.#element, eventName, callback, options);
}
once<Event extends keyof EventsMapping>(eventName: Event, callback: EventCallback<EventsMapping[Event]>): UnsubscribeFunction {
return once(this.#element, eventName, callback);
}
}
export function emitterAt(targetOrComponent: ResolvableTarget): TargetedEmitter {
return new TargetedEmitter(targetOrComponent);
}

View File

@@ -0,0 +1,7 @@
import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
export const EVENT_SIZE_LOADED = 'size-loaded';
export interface FullscreenViewerEventsMap {
[EVENT_SIZE_LOADED]: FullscreenViewerSize;
}

View File

@@ -0,0 +1,13 @@
import type MaintenanceProfile from "$entities/MaintenanceProfile";
export const EVENT_ACTIVE_PROFILE_CHANGED = 'active-profile-changed';
export const EVENT_MAINTENANCE_STATE_CHANGED = 'maintenance-state-change';
export const EVENT_TAGS_UPDATED = 'tags-updated';
type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting';
export interface MaintenancePopupEventsMap {
[EVENT_ACTIVE_PROFILE_CHANGED]: MaintenanceProfile | null;
[EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState;
[EVENT_TAGS_UPDATED]: Map<string, string> | null;
}

View File

@@ -0,0 +1,7 @@
import type TagGroup from "$entities/TagGroup";
export const EVENT_TAG_GROUP_RESOLVED = 'tag-group-resolved';
export interface TagDropdownEvents {
[EVENT_TAG_GROUP_RESOLVED]: TagGroup | null;
}

View File

@@ -0,0 +1,5 @@
export const EVENT_FORM_EDITOR_UPDATED = 'tags-form-updated';
export interface TagsFormEventsMap {
[EVENT_FORM_EDITOR_UPDATED]: HTMLElement;
}

View File

@@ -0,0 +1,19 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { ImageListInfo } from "$lib/components/listing/ImageListInfo";
export class ImageListContainer extends BaseComponent {
#info: ImageListInfo | null = null;
protected build() {
const imageListInfoContainer = this.container.querySelector<HTMLElement>('.js-imagelist-info');
if (imageListInfoContainer) {
this.#info = new ImageListInfo(imageListInfoContainer);
this.#info.initialize();
}
}
}
export function initializeImageListContainer(element: HTMLElement) {
new ImageListContainer(element).initialize();
}

View File

@@ -0,0 +1,75 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
export class ImageListInfo extends BaseComponent {
#tagElement: HTMLElement | null = null;
#impliedTags: string[] = [];
#showUntaggedImplicationsButton: HTMLAnchorElement = document.createElement('a');
protected build() {
const sectionAfterImage = this.container.querySelector('.tag-info__image + .flex__grow');
this.#tagElement = sectionAfterImage?.querySelector<HTMLElement>('.tag.dropdown') ?? null;
const labels = this.container
.querySelectorAll<HTMLElement>('.tag-info__image + .flex__grow strong');
let targetElementToInsertBefore: HTMLElement | null = null;
for (const potentialListStarter of labels) {
if (potentialListStarter.innerText === ImageListInfo.#implicationsStarterText) {
targetElementToInsertBefore = potentialListStarter;
this.#collectImplicationsFromListStarter(potentialListStarter);
break;
}
}
if (this.#impliedTags.length && targetElementToInsertBefore) {
this.#showUntaggedImplicationsButton.href = '#';
this.#showUntaggedImplicationsButton.innerText = '(Q)';
this.#showUntaggedImplicationsButton.title =
'Query untagged implications\n\n' +
'This will open the search results with all untagged implications for the current tag.';
this.#showUntaggedImplicationsButton.classList.add('detail-link');
targetElementToInsertBefore.insertAdjacentElement('beforebegin', this.#showUntaggedImplicationsButton);
}
}
protected init() {
this.#showUntaggedImplicationsButton.addEventListener('click', this.#onShowUntaggedImplicationsClicked.bind(this));
}
#collectImplicationsFromListStarter(listStarter: HTMLElement) {
let targetElement: Element | null = listStarter.nextElementSibling;
while (targetElement) {
if (targetElement instanceof HTMLAnchorElement) {
this.#impliedTags.push(targetElement.innerText.trim());
}
// First line break is considered the end of the list.
if (targetElement instanceof HTMLBRElement) {
break;
}
targetElement = targetElement.nextElementSibling;
}
}
#onShowUntaggedImplicationsClicked(event: Event) {
event.preventDefault();
const url = new URL(window.location.href);
url.pathname = '/search';
url.search = '';
const currentTagName = this.#tagElement?.dataset.tagName;
url.searchParams.set('q', `${currentTagName}, !(${this.#impliedTags.join(", ")})`);
location.assign(url.href);
}
static #implicationsStarterText = 'Implies:';
}

View File

@@ -0,0 +1,120 @@
import type StorageEntity from "$lib/extension/base/StorageEntity";
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
import type { ImportableElementsList, ImportableEntityObject } from "$lib/extension/transporting/importables";
import EntitiesTransporter, { type SameSiteStatus } from "$lib/extension/EntitiesTransporter";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import TagGroup from "$entities/TagGroup";
type TransportersMapping = {
[EntityName in keyof App.EntityNamesMap]: EntitiesTransporter<App.EntityNamesMap[EntityName]>;
}
export default class BulkEntitiesTransporter {
#lastSameSiteStatus: SameSiteStatus = null;
get lastImportSameSiteStatus() {
return this.#lastSameSiteStatus;
}
parseAndImportFromJSON(jsonString: string): StorageEntity[] {
let parsedObject: any;
this.#lastSameSiteStatus = null;
try {
parsedObject = JSON.parse(jsonString);
} catch (e) {
throw new TypeError('Invalid JSON!', {cause: e});
}
if (!BulkEntitiesTransporter.isList(parsedObject)) {
throw new TypeError('Invalid or unsupported object!');
}
this.#lastSameSiteStatus = EntitiesTransporter.checkIsSameSiteImportedObject(parsedObject);
let hasDifferentStatuses = false;
const resultEntities = parsedObject.elements
.map(importableObject => {
if (!(importableObject.$type in BulkEntitiesTransporter.#transporters)) {
console.warn('Attempting to import unsupported entity: ' + importableObject.$type);
return null;
}
const transporter = BulkEntitiesTransporter.#transporters[importableObject.$type as keyof App.EntityNamesMap];
const resultEntity = transporter.importFromObject(importableObject);
if (transporter.lastImportSameSiteStatus !== this.#lastSameSiteStatus) {
hasDifferentStatuses = true;
}
return resultEntity;
})
.filter(maybeEntity => !!maybeEntity);
if (hasDifferentStatuses) {
this.#lastSameSiteStatus = 'unknown';
}
return resultEntities;
}
parseAndImportFromCompressedJSON(compressedJsonString: string): StorageEntity[] {
return this.parseAndImportFromJSON(
decompressFromEncodedURIComponent(compressedJsonString)
);
}
exportToJSON(entities: StorageEntity[]): string {
return JSON.stringify({
$type: 'list',
$site: __CURRENT_SITE__,
elements: entities
.map(entity => {
switch (true) {
case entity instanceof MaintenanceProfile:
return BulkEntitiesTransporter.#transporters.profiles.exportToObject(entity);
case entity instanceof TagGroup:
return BulkEntitiesTransporter.#transporters.groups.exportToObject(entity);
}
return null;
})
.filter(value => !!value)
} as ImportableElementsList<ImportableEntityObject<StorageEntity>>, null, 2);
}
exportToCompressedJSON(entities: StorageEntity[]): string {
return compressToEncodedURIComponent(
this.exportToJSON(entities)
);
}
static isList(targetObject: any): targetObject is ImportableElementsList<ImportableEntityObject<StorageEntity>> {
return targetObject.$type
&& targetObject.$type === 'list'
&& targetObject.elements
&& Array.isArray(targetObject.elements);
}
static #transporters: TransportersMapping = {
profiles: new EntitiesTransporter(MaintenanceProfile),
groups: new EntitiesTransporter(TagGroup),
}
/**
* Check if the imported object is created for the same site extension or not.
* @param importedObject Object to check.
* @private
*/
static #checkIsSameSiteImportedObject(importedObject: Record<string, any>): SameSiteStatus {
if (!('$site' in importedObject)) {
return "unknown";
}
return importedObject.$site === __CURRENT_SITE__
? "same"
: "different";
}
}

View File

@@ -1,82 +0,0 @@
import StorageHelper from "$lib/chrome/StorageHelper.js";
export default class ConfigurationController {
/** @type {string} */
#configurationName;
/**
* @param {string} configurationName Name of the configuration to work with.
*/
constructor(configurationName) {
this.#configurationName = configurationName;
}
/**
* Read the setting with the given name.
*
* @param {string} settingName Setting name.
* @param {any} [defaultValue] Default value to return if the setting does not exist. Defaults to `null`.
*
* @return {Promise<any|null>} The setting value or the default value if the setting does not exist.
*/
async readSetting(settingName, defaultValue = null) {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
return settings[settingName] ?? defaultValue;
}
/**
* Write the given value to the setting.
*
* @param {string} settingName Setting name.
* @param {any} value Value to write.
*
* @return {Promise<void>}
*/
async writeSetting(settingName, value) {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
settings[settingName] = value;
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
}
/**
* Delete the specific setting.
*
* @param {string} settingName Setting name to delete.
*
* @return {Promise<void>}
*/
async deleteSetting(settingName) {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
delete settings[settingName];
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
}
/**
* Subscribe to changes in the configuration.
*
* @param {function(Record<string, any>)} callback Callback to call when the configuration changes. The new
* configuration is passed as an argument.
*
* @return {function(): void} Unsubscribe function.
*/
subscribeToChanges(callback) {
/** @param {Record<string, StorageChange>} changes */
const changesSubscriber = changes => {
if (!changes[this.#configurationName]) {
return;
}
callback(changes[this.#configurationName].newValue);
}
ConfigurationController.#storageHelper.subscribe(changesSubscriber);
return () => ConfigurationController.#storageHelper.unsubscribe(changesSubscriber);
}
static #storageHelper = new StorageHelper(chrome.storage.local);
}

View File

@@ -0,0 +1,80 @@
import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/StorageHelper";
export default class ConfigurationController {
readonly #configurationName: string;
readonly #storage: StorageHelper;
/**
* @param {string} configurationName Name of the configuration to work with.
* @param {StorageHelper} [storage] Selected storage where the settings are stored in. If not provided, local storage
* is used.
*/
constructor(configurationName: string, storage: StorageHelper = new StorageHelper(chrome.storage.local)) {
this.#configurationName = configurationName;
this.#storage = storage;
}
/**
* Read the setting with the given name.
*
* @param settingName Setting name.
* @param [defaultValue] Default value to return if the setting does not exist. Defaults to `null`.
*
* @return The setting value or the default value if the setting does not exist.
*/
async readSetting<Type = any, DefaultType = any>(settingName: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
const settings = await this.#storage.read(this.#configurationName, {});
return settings[settingName] ?? defaultValue;
}
/**
* Write the given value to the setting.
*
* @param settingName Setting name.
* @param value Value to write.
*
* @return {Promise<void>}
*/
async writeSetting(settingName: string, value: any): Promise<void> {
const settings = await this.#storage.read(this.#configurationName, {});
settings[settingName] = value;
this.#storage.write(this.#configurationName, settings);
}
/**
* Delete the specific setting.
*
* @param {string} settingName Setting name to delete.
*/
async deleteSetting(settingName: string): Promise<void> {
const settings = await this.#storage.read(this.#configurationName, {});
delete settings[settingName];
this.#storage.write(this.#configurationName, settings);
}
/**
* Subscribe to changes in the configuration.
*
* @param {function(Record<string, any>)} callback Callback to call when the configuration changes. The new
* configuration is passed as an argument.
*
* @return {function(): void} Unsubscribe function.
*/
subscribeToChanges(callback: (record: Record<string, any>) => void): () => void {
const subscriber: StorageChangeSubscriber = changes => {
if (!changes[this.#configurationName]) {
return;
}
callback(changes[this.#configurationName].newValue);
}
this.#storage.subscribe(subscriber);
return () => this.#storage.unsubscribe(subscriber);
}
}

View File

@@ -0,0 +1,128 @@
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
import TagGroup from "$entities/TagGroup";
import { escapeRegExp } from "$lib/utils";
import { emit } from "$lib/components/events/comms";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
export default class CustomCategoriesResolver {
#exactGroupMatches = new Map<string, TagGroup>();
#regExpGroupMatches = new Map<RegExp, TagGroup>();
#tagDropdowns: TagDropdownWrapper[] = [];
#nextQueuedUpdate: Timeout | null = null;
constructor() {
TagGroup.subscribe(this.#onTagGroupsReceived.bind(this));
TagGroup.readAll().then(this.#onTagGroupsReceived.bind(this));
}
public addElement(tagDropdown: TagDropdownWrapper): void {
this.#tagDropdowns.push(tagDropdown);
if (!this.#exactGroupMatches.size && !this.#regExpGroupMatches.size) {
return;
}
this.#queueUpdatingTags();
}
#queueUpdatingTags() {
if (this.#nextQueuedUpdate) {
clearTimeout(this.#nextQueuedUpdate);
}
this.#nextQueuedUpdate = setTimeout(
this.#updateUnprocessedTags.bind(this),
CustomCategoriesResolver.#unprocessedTagsTimeout
);
}
#updateUnprocessedTags() {
this.#tagDropdowns
.filter(this.#applyCustomCategoryForExactMatches.bind(this))
.filter(this.#matchCustomCategoryByRegExp.bind(this))
.forEach(CustomCategoriesResolver.#resetToOriginalCategory);
}
/**
* Apply custom categories for the exact tag names.
* @param tagDropdown Element to try applying the category for.
* @return {boolean} Will return false when tag is processed and true when it is not found.
* @private
*/
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
const tagName = tagDropdown.tagName!;
if (!this.#exactGroupMatches.has(tagName)) {
return true;
}
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
this.#exactGroupMatches.get(tagName)!
);
return false;
}
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
const tagName = tagDropdown.tagName!;
for (const targetRegularExpression of this.#regExpGroupMatches.keys()) {
if (!targetRegularExpression.test(tagName)) {
continue;
}
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
this.#regExpGroupMatches.get(targetRegularExpression)!
);
return false;
}
return true;
}
#onTagGroupsReceived(tagGroups: TagGroup[]) {
this.#exactGroupMatches.clear();
this.#regExpGroupMatches.clear();
if (!tagGroups.length) {
return;
}
for (const tagGroup of tagGroups) {
for (const tagName of tagGroup.settings.tags) {
this.#exactGroupMatches.set(tagName, tagGroup);
}
for (const tagPrefix of tagGroup.settings.prefixes) {
this.#regExpGroupMatches.set(
new RegExp(`^${escapeRegExp(tagPrefix)}`),
tagGroup,
);
}
for (let tagSuffix of tagGroup.settings.suffixes) {
this.#regExpGroupMatches.set(
new RegExp(`${escapeRegExp(tagSuffix)}$`),
tagGroup,
);
}
}
this.#queueUpdatingTags();
}
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
null,
);
}
static #unprocessedTagsTimeout = 0;
}

View File

@@ -1,93 +0,0 @@
import StorageHelper from "$lib/chrome/StorageHelper.js";
export default class EntitiesController {
static #storageHelper = new StorageHelper(chrome.storage.local);
/**
* Read all entities of the given type from the storage. Build the entities from the raw data and return them.
*
* @template EntityClass
*
* @param {string} entityName Name of the entity to read.
* @param {EntityClass} entityClass Class of the entity to read. Must have a constructor that accepts the ID and the
* settings object.
*
* @return {Promise<InstanceType<EntityClass>[]>} List of entities of the given type.
*/
static async readAllEntities(entityName, entityClass) {
const rawEntities = await this.#storageHelper.read(entityName, {});
if (!rawEntities || Object.keys(rawEntities).length === 0) {
return [];
}
return Object
.entries(rawEntities)
.map(([id, settings]) => new entityClass(id, settings));
}
/**
* Update the single entity in the storage. If the entity with the given ID already exists, it will be overwritten.
*
* @param {string} entityName Name of the entity to update.
* @param {StorageEntity} entity Entity to update.
*
* @return {Promise<void>}
*/
static async updateEntity(entityName, entity) {
await this.#storageHelper.write(
entityName,
Object.assign(
await this.#storageHelper.read(
entityName, {}
),
{
[entity.id]: entity.settings
}
)
);
}
/**
* Delete the entity with the given ID.
*
* @param {string} entityName Name of the entity to delete.
* @param {string} entityId ID of the entity to delete.
*
* @return {Promise<void>}
*/
static async deleteEntity(entityName, entityId) {
const entities = await this.#storageHelper.read(entityName, {});
delete entities[entityId];
await this.#storageHelper.write(entityName, entities);
}
/**
* Subscribe to all changes made to the storage.
*
* @template EntityClass
*
* @param {string} entityName Name of the entity to subscribe to.
* @param {EntityClass} entityClass Class of the entity to subscribe to.
* @param {function(InstanceType<EntityClass>[]): any} callback Callback to call when the storage changes.
* @return {function(): void} Unsubscribe function.
*/
static subscribeToEntity(entityName, entityClass, callback) {
/**
* Watch the changes made to the storage and call the callback when the entity changes.
* @param {Object<string, StorageChange>} changes Changes made to the storage.
*/
const storageChangesSubscriber = changes => {
if (!changes[entityName]) {
return;
}
this.readAllEntities(entityName, entityClass)
.then(callback);
}
this.#storageHelper.subscribe(storageChangesSubscriber);
return () => this.#storageHelper.unsubscribe(storageChangesSubscriber);
}
}

View File

@@ -0,0 +1,87 @@
import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/StorageHelper";
import type StorageEntity from "$lib/extension/base/StorageEntity";
export default class EntitiesController {
static #storageHelper = new StorageHelper(chrome.storage.local);
/**
* Read all entities of the given type from the storage. Build the entities from the raw data and return them.
*
* @param entityName Name of the entity to read.
* @param entityClass Class of the entity to read. Must have a constructor that accepts the ID and the settings
* object.
*
* @return List of entities of the given type.
*/
static async readAllEntities<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type): Promise<Type[]> {
const rawEntities = await this.#storageHelper.read(entityName, {});
if (!rawEntities || Object.keys(rawEntities).length === 0) {
return [];
}
return Object
.entries(rawEntities)
.map(([id, settings]) => new entityClass(id, settings));
}
/**
* Update the single entity in the storage. If the entity with the given ID already exists, it will be overwritten.
*
* @param entityName Name of the entity to update.
* @param entity Entity to update.
*/
static async updateEntity(entityName: string, entity: StorageEntity<Object>): Promise<void> {
this.#storageHelper.write(
entityName,
Object.assign(
await this.#storageHelper.read(
entityName, {}
),
{
[entity.id]: entity.settings
}
)
);
}
/**
* Delete the entity with the given ID.
*
* @param entityName Name of the entity to delete.
* @param entityId ID of the entity to delete.
*/
static async deleteEntity(entityName: string, entityId: string): Promise<void> {
const entities = await this.#storageHelper.read(entityName, {});
delete entities[entityId];
this.#storageHelper.write(entityName, entities);
}
/**
* Subscribe to all changes made to the storage.
*
* @template EntityClass
*
* @param entityName Name of the entity to subscribe to.
* @param entityClass Class of the entity to subscribe to.
* @param callback Callback to call when the storage changes.
* @return Unsubscribe function.
*/
static subscribeToEntity<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type, callback: (entities: Type[]) => void): () => void {
/**
* Watch the changes made to the storage and call the callback when the entity changes.
*/
const subscriber: StorageChangeSubscriber = changes => {
if (!changes[entityName]) {
return;
}
this.readAllEntities(entityName, entityClass)
.then(callback);
}
this.#storageHelper.subscribe(subscriber);
return () => this.#storageHelper.unsubscribe(subscriber);
}
}

View File

@@ -0,0 +1,153 @@
import { validateImportedEntity } from "$lib/extension/transporting/validators";
import { exportEntityToObject } from "$lib/extension/transporting/exporters";
import StorageEntity from "$lib/extension/base/StorageEntity";
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
import type { ImportableElement } from "$lib/extension/transporting/importables";
/**
* Status of the last import.
*
* - `NULL` - no import was done yet or was unsuccessful.
* - `"unknown"` — imported object was created before v0.5, when extension started to be built for multiple sites.
* - `"same"` — imported object is marked as generated by the same type of extension.
* - `"different"` — imported object is marked as generated by some other type of extension.
*/
export type SameSiteStatus = null | "unknown" | "same" | "different";
export default class EntitiesTransporter<EntityType> {
readonly #targetEntityConstructor: new (...any: any[]) => EntityType;
#lastSameSiteStatus: SameSiteStatus = null;
/**
* Read the status of the last successful import. This flag could be used to determine if it was for the same site as
* the current extension or when it's generated before site identity was passed to the importable object.
*
* @see {SameSiteStatus} For the list of possible statuses.
*/
get lastImportSameSiteStatus() {
return this.#lastSameSiteStatus;
}
/**
* Name of the entity, exported directly from the constructor.
* @private
*/
get #entityName() {
// How the hell should I even do this?
const entityName = ((this.#targetEntityConstructor as any) as typeof StorageEntity)._entityName;
if (entityName === "entity") {
throw new Error("Generic entity name encountered!");
}
return entityName;
}
/**
* @param entityConstructor Class which should be used for import or export.
*/
constructor(entityConstructor: new (...any: any[]) => EntityType) {
if (!(entityConstructor.prototype instanceof StorageEntity)) {
throw new TypeError('Invalid class provided as the target for importing!');
}
this.#targetEntityConstructor = entityConstructor;
}
isCorrectEntity(entityObject: unknown): entityObject is EntityType {
return entityObject instanceof this.#targetEntityConstructor;
}
importFromObject(importedObject: Record<string, any>): EntityType {
this.#lastSameSiteStatus = null;
// TODO: There should be an auto-upgrader somewhere before the validation. So if even the older version of schema
// was used, we still will will be able to pass the validation. For now we only have non-breaking changes.
validateImportedEntity(
this.#entityName,
importedObject,
);
this.#lastSameSiteStatus = EntitiesTransporter.checkIsSameSiteImportedObject(importedObject);
return new this.#targetEntityConstructor(
importedObject.id,
importedObject
);
}
importFromJSON(jsonString: string): EntityType {
const importedObject = this.#tryParsingAsJSON(jsonString);
if (!importedObject) {
this.#lastSameSiteStatus = null;
throw new Error('Invalid JSON!');
}
return this.importFromObject(importedObject);
}
importFromCompressedJSON(compressedJsonString: string): EntityType {
return this.importFromJSON(
decompressFromEncodedURIComponent(compressedJsonString)
)
}
exportToObject(entityObject: EntityType) {
if (!this.isCorrectEntity(entityObject)) {
throw new TypeError('Transporter should be connected to the same entity to export!');
}
if (!(entityObject instanceof StorageEntity)) {
throw new TypeError('Only storage entities could be exported!');
}
return exportEntityToObject(
this.#entityName,
entityObject
);
}
exportToJSON(entityObject: EntityType): string {
return JSON.stringify(
this.exportToObject(entityObject),
null,
2
);
}
exportToCompressedJSON(entityObject: EntityType): string {
return compressToEncodedURIComponent(this.exportToJSON(entityObject));
}
#tryParsingAsJSON(jsonString: string): Record<string, any> | null {
let jsonObject: Record<string, any> | null = null;
try {
jsonObject = JSON.parse(jsonString);
} catch (e) {
}
if (typeof jsonObject !== "object") {
throw new TypeError("Should be an object!");
}
return jsonObject
}
/**
* Check if the imported object is created for the same site extension or not.
* @param importedObject Object to check.
*/
static checkIsSameSiteImportedObject(importedObject: Record<string, any>): SameSiteStatus {
if (!('$site' in importedObject)) {
return "unknown";
}
return importedObject.$site === __CURRENT_SITE__
? "same"
: "different";
}
}

View File

@@ -0,0 +1,81 @@
import ConfigurationController from "$lib/extension/ConfigurationController";
export default class CacheableSettings<Fields> {
#controller: ConfigurationController;
#cachedValues: Map<keyof Fields, any> = new Map();
#disposables: Function[] = [];
constructor(settingsNamespace: string) {
this.#controller = new ConfigurationController(settingsNamespace);
this.#disposables.push(
this.#controller.subscribeToChanges(settings => {
for (const key of Object.keys(settings)) {
this.#cachedValues.set(
key as keyof Fields,
settings[key]
);
}
})
);
}
/**
* @template SettingType
* @param {string} settingName
* @param {SettingType} defaultValue
* @return {Promise<SettingType>}
* @protected
*/
protected async _resolveSetting<Key extends keyof Fields>(settingName: Key, defaultValue: Fields[Key]): Promise<Fields[Key]> {
if (this.#cachedValues.has(settingName)) {
return this.#cachedValues.get(settingName);
}
const settingValue = await this.#controller.readSetting(settingName as string, defaultValue);
this.#cachedValues.set(settingName, settingValue);
return settingValue;
}
/**
* @param settingName Name of the setting to write.
* @param value Value to pass.
* @param force Ignore the cache and force the update.
* @protected
*/
async _writeSetting<Key extends keyof Fields>(settingName: Key, value: Fields[Key], force: boolean = false): Promise<void> {
if (
!force
&& this.#cachedValues.has(settingName)
&& this.#cachedValues.get(settingName) === value
) {
return;
}
return this.#controller.writeSetting(
settingName as string,
value
);
}
/**
* Subscribe to the changes made to the storage.
* @param {function(Object): void} callback Callback which will receive list of settings.
* @return {function(): void} Unsubscribe function.
*/
subscribe(callback: (settings: Partial<Fields>) => void): () => void {
const unsubscribeCallback = this.#controller.subscribeToChanges(callback as (fields: Record<string, any>) => void);
this.#disposables.push(unsubscribeCallback);
return unsubscribeCallback;
}
dispose() {
for (let disposeCallback of this.#disposables) {
disposeCallback();
}
}
}

View File

@@ -1,56 +0,0 @@
import EntitiesController from "$lib/extension/EntitiesController.js";
class StorageEntity {
/**
* @type {string}
*/
#id;
/**
* @type {Object}
*/
#settings;
/**
* @param {string} id
* @param {Object} settings
*/
constructor(id, settings = {}) {
this.#id = id;
this.#settings = settings;
}
/**
* @return {string}
*/
get id() {
return this.#id;
}
/**
* @return {Object}
*/
get settings() {
return this.#settings;
}
static _entityName = "entity";
async save() {
await EntitiesController.updateEntity(this.constructor._entityName, this);
}
async delete() {
await EntitiesController.deleteEntity(this.constructor._entityName, this.id);
}
/**
* Static function to read all entities of this type from the storage. Must be implemented in the child class.
* @return {Promise<array>}
*/
static async readAll() {
throw new Error("Not implemented");
}
}
export default StorageEntity;

View File

@@ -0,0 +1,63 @@
import EntitiesController from "$lib/extension/EntitiesController";
export default abstract class StorageEntity<SettingsType extends Object = {}> {
/**
* @type {string}
*/
readonly #id: string;
/**
* @type {Object}
*/
readonly #settings: SettingsType;
protected constructor(id: string, settings: SettingsType) {
this.#id = id;
this.#settings = settings;
}
get id(): string {
return this.#id;
}
get settings(): SettingsType {
return this.#settings;
}
get type() {
return (this.constructor as typeof StorageEntity)._entityName;
}
public static readonly _entityName: keyof App.EntityNamesMap | "entity" = "entity";
async save() {
await EntitiesController.updateEntity(
this.type,
this
);
}
async delete() {
await EntitiesController.deleteEntity(
this.type,
this.id
);
}
public static async readAll<Type extends StorageEntity<any>>(this: new (...args: any[]) => Type): Promise<Type[]> {
return await EntitiesController.readAllEntities(
// Voodoo magic, once again.
((this as any) as typeof StorageEntity)._entityName,
this
)
}
public static subscribe<Type extends StorageEntity<any>>(this: new (...args: any[]) => Type, callback: (entities: Type[]) => void): () => void {
return EntitiesController.subscribeToEntity(
// And once more.
((this as any) as typeof StorageEntity)._entityName,
this,
callback
);
}
}

View File

@@ -1,63 +0,0 @@
import StorageEntity from "$lib/extension/base/StorageEntity.js";
import EntitiesController from "$lib/extension/EntitiesController.js";
/**
* @typedef {Object} MaintenanceProfileSettings
* @property {string} name
* @property {string[]} tags
*/
/**
* Class representing the maintenance profile entity.
*/
class MaintenanceProfile extends StorageEntity {
/**
* @param {string} id ID of the entity.
* @param {Partial<MaintenanceProfileSettings>} settings Maintenance profile settings object.
*/
constructor(id, settings) {
super(id, {
name: settings.name || '',
tags: settings.tags || []
});
}
/**
* @return {MaintenanceProfileSettings}
*/
get settings() {
return super.settings;
}
static _entityName = "profiles";
/**
* Read all maintenance profiles from the storage.
*
* @return {Promise<InstanceType<MaintenanceProfile>[]>}
*/
static async readAll() {
return await EntitiesController.readAllEntities(
this._entityName,
MaintenanceProfile
);
}
/**
* Subscribe to the changes and receive the new list of profiles when they change.
*
* @param {function(MaintenanceProfile[]): void} callback Callback to call when the profiles change. The new list of
* profiles is passed as an argument.
*
* @return {function(): void} Unsubscribe function.
*/
static subscribe(callback) {
return EntitiesController.subscribeToEntity(
this._entityName,
MaintenanceProfile,
callback
);
}
}
export default MaintenanceProfile;

View File

@@ -0,0 +1,34 @@
import StorageEntity from "$lib/extension/base/StorageEntity";
export interface MaintenanceProfileSettings {
name: string;
tags: string[];
temporary: boolean;
}
/**
* Class representing the maintenance profile entity.
*/
export default class MaintenanceProfile extends StorageEntity<MaintenanceProfileSettings> {
/**
* @param id ID of the entity.
* @param settings Maintenance profile settings object.
*/
constructor(id: string, settings: Partial<MaintenanceProfileSettings>) {
super(id, {
name: settings.name || '',
tags: settings.tags || [],
temporary: settings.temporary ?? false
});
}
async save(): Promise<void> {
if (this.settings.temporary && !this.settings.tags?.length) {
return this.delete();
}
return super.save();
}
public static readonly _entityName: keyof App.EntityNamesMap = "profiles";
}

View File

@@ -0,0 +1,25 @@
import StorageEntity from "$lib/extension/base/StorageEntity";
export interface TagGroupSettings {
name: string;
tags: string[];
prefixes: string[];
suffixes: string[];
category: string;
separate: boolean;
}
export default class TagGroup extends StorageEntity<TagGroupSettings> {
constructor(id: string, settings: Partial<TagGroupSettings>) {
super(id, {
name: settings.name || '',
tags: settings.tags || [],
prefixes: settings.prefixes || [],
suffixes: settings.suffixes || [],
category: settings.category || '',
separate: Boolean(settings.separate),
});
}
public static readonly _entityName: keyof App.EntityNamesMap = "groups";
}

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