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

51 Commits

Author SHA1 Message Date
d5dee6c615 Replaced custom-made RegExp escaping utility with @std/regexp 2025-04-08 19:18:27 +04:00
2da37e2316 Installed @std/regexp 1.0.1 from JSR
This is a part of the Deno's bundle of standard libraries, so it should
be somewhat trustworthy. For now, it only includes the escaping function
which I actually need.
2025-04-08 19:16:58 +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
47 changed files with 1462 additions and 294 deletions

1
.npmrc
View File

@@ -1 +1,2 @@
engine-strict=true
@jsr:registry=https://npm.jsr.io

View File

@@ -1,7 +1,7 @@
{
"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.4.2",
"version": "0.4.4",
"browser_specific_settings": {
"gecko": {
"id": "furbooru-tagging-assistant@thecore.city"
@@ -44,6 +44,14 @@
"src/styles/content/header.scss"
]
},
{
"matches": [
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/tags-editor.ts"
]
},
{
"matches": [
"*://*.furbooru.org/images?*",
@@ -61,14 +69,6 @@
"js": [
"src/content/tags.ts"
]
},
{
"matches": [
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/tags-editor.ts"
]
}
],
"action": {

710
package-lock.json generated
View File

@@ -1,31 +1,33 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.4.2",
"version": "0.4.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "furbooru-tagging-assistant",
"version": "0.4.2",
"version": "0.4.4",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@std/regexp": "npm:@jsr/std__regexp@^1.0.1",
"lz-string": "^1.5.0"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.2",
"@sveltejs/kit": "^2.20.3",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.304",
"@vitest/coverage-v8": "^3.0.6",
"@types/chrome": "^0.0.313",
"@types/node": "^22.14.0",
"@vitest/coverage-v8": "^3.1.1",
"cheerio": "^1.0.0",
"jsdom": "^26.0.0",
"sass": "^1.85.0",
"svelte": "^5.20.1",
"svelte-check": "^4.1.4",
"typescript": "^5.7.3",
"vite": "^6.1.0",
"vitest": "^3.0.6"
"sass": "^1.86.3",
"svelte": "^5.25.6",
"svelte-check": "^4.1.5",
"typescript": "^5.8.2",
"vite": "^6.2.5",
"vitest": "^3.1.1"
}
},
"node_modules/@ampproject/remapping": {
@@ -230,13 +232,142 @@
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz",
"integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz",
"integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz",
"integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz",
"integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz",
"integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz",
"integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz",
"integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz",
"integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz",
"integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
@@ -245,6 +376,262 @@
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz",
"integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz",
"integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz",
"integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz",
"integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz",
"integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz",
"integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz",
"integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz",
"integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz",
"integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz",
"integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz",
"integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz",
"integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz",
"integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz",
"integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz",
"integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz",
"integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz",
@@ -685,11 +1072,27 @@
"win32"
]
},
"node_modules/@sveltejs/adapter-auto": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-4.0.0.tgz",
"integrity": "sha512-kmuYSQdD2AwThymQF0haQhM8rE5rhutQXG4LNbnbShwhMO4qQGnKaaTy+88DuNSuoQDi58+thpq8XpHc1+oEKQ==",
"node_modules/@std/regexp": {
"name": "@jsr/std__regexp",
"version": "1.0.1",
"resolved": "https://npm.jsr.io/~/11/@jsr/std__regexp/1.0.1.tgz",
"integrity": "sha512-AnGeP//DHpPvhCWjI5dR4o013JhCQioD8yMF8drD7PWb0X4kvmO35hbZi+NZhfSolz4Ts2cpPzJY+DUpi2XE9A=="
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
"integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==",
"dev": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
},
"node_modules/@sveltejs/adapter-auto": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-6.0.0.tgz",
"integrity": "sha512-7mR2/G7vlXakaOj6QBSG9dwBfTgWjV+UnEMB5Z6Xu0ZbdXda6c0su1fNkg0ab0zlilSkloMA2NjCna02/DR7sA==",
"dev": true,
"license": "MIT",
"dependencies": {
"import-meta-resolve": "^4.1.0"
},
@@ -707,9 +1110,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.17.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.17.2.tgz",
"integrity": "sha512-Vypk02baf7qd3SOB1uUwUC/3Oka+srPo2J0a8YN3EfJypRshDkNx9HzNKjSmhOnGWwT+SSO06+N0mAb8iVTmTQ==",
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.3.tgz",
"integrity": "sha512-z1SQ8qra/kGY3DzarG7xc6XsbKm8UY3SnI82XLI3PqMYWbYj/LpjPWuAz9WA5EyLjFNLD7sOAOEW8Gt4yjr5Vg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -776,9 +1179,9 @@
}
},
"node_modules/@types/chrome": {
"version": "0.0.304",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.304.tgz",
"integrity": "sha512-ms9CLILU+FEMK7gcmgz/Mtn2E81YQWiMIzCFF8ktp98EVNIIfoqaDTD4+ailOCq1sGjbnEmfJxQ1FAsQtk5M3A==",
"version": "0.0.313",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.313.tgz",
"integrity": "sha512-9R5T7gTaYZhkxlu+Ho4wk9FL+y/werWQY2yjGWSqCuiTsqS7nL/BE5UMTP6rU7J+oIG2FRKqrEycHhJATeltVA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -814,10 +1217,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.6.tgz",
"integrity": "sha512-JRTlR8Bw+4BcmVTICa7tJsxqphAktakiLsAmibVLAWbu1lauFddY/tXeM6sAyl1cgkPuXtpnUgaCPhTdz1Qapg==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.1.tgz",
"integrity": "sha512-MgV6D2dhpD6Hp/uroUoAIvFqA8AuvXEFBC2eepG3WFc1pxTfdk1LEqqkWoWhjz+rytoqrnUUCdf6Lzco3iHkLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -830,7 +1243,7 @@
"istanbul-reports": "^3.1.7",
"magic-string": "^0.30.17",
"magicast": "^0.3.5",
"std-env": "^3.8.0",
"std-env": "^3.8.1",
"test-exclude": "^7.0.1",
"tinyrainbow": "^2.0.0"
},
@@ -838,8 +1251,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "3.0.6",
"vitest": "3.0.6"
"@vitest/browser": "3.1.1",
"vitest": "3.1.1"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -848,14 +1261,14 @@
}
},
"node_modules/@vitest/expect": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.6.tgz",
"integrity": "sha512-zBduHf/ja7/QRX4HdP1DSq5XrPgdN+jzLOwaTq/0qZjYfgETNFCKf9nOAp2j3hmom3oTbczuUzrzg9Hafh7hNg==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.1.tgz",
"integrity": "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.0.6",
"@vitest/utils": "3.0.6",
"@vitest/spy": "3.1.1",
"@vitest/utils": "3.1.1",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
@@ -864,13 +1277,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.6.tgz",
"integrity": "sha512-KPztr4/tn7qDGZfqlSPQoF2VgJcKxnDNhmfR3VgZ6Fy1bO8T9Fc1stUiTXtqz0yG24VpD00pZP5f8EOFknjNuQ==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.1.tgz",
"integrity": "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.0.6",
"@vitest/spy": "3.1.1",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
@@ -891,9 +1304,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.6.tgz",
"integrity": "sha512-Zyctv3dbNL+67qtHfRnUE/k8qxduOamRfAL1BurEIQSyOEFffoMvx2pnDSSbKAAVxY0Ej2J/GH2dQKI0W2JyVg==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz",
"integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -904,13 +1317,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.6.tgz",
"integrity": "sha512-JopP4m/jGoaG1+CBqubV/5VMbi7L+NQCJTu1J1Pf6YaUbk7bZtaq5CX7p+8sY64Sjn1UQ1XJparHfcvTTdu9cA==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.1.tgz",
"integrity": "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.0.6",
"@vitest/utils": "3.1.1",
"pathe": "^2.0.3"
},
"funding": {
@@ -918,13 +1331,13 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.6.tgz",
"integrity": "sha512-qKSmxNQwT60kNwwJHMVwavvZsMGXWmngD023OHSgn873pV0lylK7dwBTfYP7e4URy5NiBCHHiQGA9DHkYkqRqg==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.1.tgz",
"integrity": "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.0.6",
"@vitest/pretty-format": "3.1.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
@@ -933,9 +1346,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.6.tgz",
"integrity": "sha512-HfOGx/bXtjy24fDlTOpgiAEJbRfFxoX3zIGagCqACkFKKZ/TTOE6gYMKXlqecvxEndKFuNHcHqP081ggZ2yM0Q==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.1.tgz",
"integrity": "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -946,13 +1359,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.6.tgz",
"integrity": "sha512-18ktZpf4GQFTbf9jK543uspU03Q2qya7ZGya5yiZ0Gx0nnnalBvd5ZBislbl2EhLjM8A8rt4OilqKG7QwcGkvQ==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz",
"integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.0.6",
"@vitest/pretty-format": "3.1.1",
"loupe": "^3.1.3",
"tinyrainbow": "^2.0.0"
},
@@ -961,9 +1374,9 @@
}
},
"node_modules/acorn": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true,
"bin": {
"acorn": "bin/acorn"
@@ -972,15 +1385,6 @@
"node": ">=0.4.0"
}
},
"node_modules/acorn-typescript": {
"version": "1.4.13",
"resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz",
"integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==",
"dev": true,
"peerDependencies": {
"acorn": ">=8.9.0"
}
},
"node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
@@ -1540,10 +1944,11 @@
}
},
"node_modules/esbuild": {
"version": "0.24.2",
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz",
"integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
@@ -1551,31 +1956,31 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
"@esbuild/aix-ppc64": "0.25.1",
"@esbuild/android-arm": "0.25.1",
"@esbuild/android-arm64": "0.25.1",
"@esbuild/android-x64": "0.25.1",
"@esbuild/darwin-arm64": "0.25.1",
"@esbuild/darwin-x64": "0.25.1",
"@esbuild/freebsd-arm64": "0.25.1",
"@esbuild/freebsd-x64": "0.25.1",
"@esbuild/linux-arm": "0.25.1",
"@esbuild/linux-arm64": "0.25.1",
"@esbuild/linux-ia32": "0.25.1",
"@esbuild/linux-loong64": "0.25.1",
"@esbuild/linux-mips64el": "0.25.1",
"@esbuild/linux-ppc64": "0.25.1",
"@esbuild/linux-riscv64": "0.25.1",
"@esbuild/linux-s390x": "0.25.1",
"@esbuild/linux-x64": "0.25.1",
"@esbuild/netbsd-arm64": "0.25.1",
"@esbuild/netbsd-x64": "0.25.1",
"@esbuild/openbsd-arm64": "0.25.1",
"@esbuild/openbsd-x64": "0.25.1",
"@esbuild/sunos-x64": "0.25.1",
"@esbuild/win32-arm64": "0.25.1",
"@esbuild/win32-ia32": "0.25.1",
"@esbuild/win32-x64": "0.25.1"
}
},
"node_modules/esm-env": {
@@ -1585,10 +1990,11 @@
"dev": true
},
"node_modules/esrap": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.3.tgz",
"integrity": "sha512-Xddc1RsoFJ4z9nR7W7BFaEPIp4UXoeQ0+077UdWLxbafMQFyU79sQJMk7kxNgRwQ9/aVgaKacCHC2pUACGwmYw==",
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.6.tgz",
"integrity": "sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
@@ -1604,9 +2010,9 @@
}
},
"node_modules/expect-type": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz",
"integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz",
"integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -2239,7 +2645,9 @@
"dev": true
},
"node_modules/nanoid": {
"version": "3.3.8",
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.9.tgz",
"integrity": "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==",
"dev": true,
"funding": [
{
@@ -2247,7 +2655,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -2384,7 +2791,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.1",
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"dev": true,
"funding": [
{
@@ -2400,7 +2809,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
@@ -2496,11 +2904,10 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.85.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz",
"integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==",
"version": "1.86.3",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.86.3.tgz",
"integrity": "sha512-iGtg8kus4GrsGLRDLRBRHY9dNVA78ZaS7xr01cWnS7PEMQyFtTqBiyCrfpTYTZXRWM94akzckYjh8oADfFNTzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -2619,9 +3026,9 @@
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz",
"integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==",
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
"dev": true,
"license": "MIT"
},
@@ -2743,22 +3150,22 @@
}
},
"node_modules/svelte": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.20.1.tgz",
"integrity": "sha512-aCARru2WTdzJl55Ws8SK27+kvQwd8tijl4kY7NoDUXUHtTHhxMa8Lf6QNZKmU7cuPu3jjFloDO1j5HgYJNIIWg==",
"version": "5.25.6",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.25.6.tgz",
"integrity": "sha512-RGkaeAXDuJdvhA1fdSM5GgD++vYfJYijZL0uN6kM2s/TRJ663jktBhZlF0qjzAJGR/34PtaeT3G8MKJY1EKeqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5",
"acorn": "^8.12.1",
"acorn-typescript": "^1.4.13",
"aria-query": "^5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"esm-env": "^1.2.1",
"esrap": "^1.4.3",
"esrap": "^1.4.6",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
@@ -2769,9 +3176,9 @@
}
},
"node_modules/svelte-check": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.1.4.tgz",
"integrity": "sha512-v0j7yLbT29MezzaQJPEDwksybTE2Ups9rUxEXy92T06TiA0cbqcO8wAOwNUVkFW6B0hsYHA+oAX3BS8b/2oHtw==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.1.5.tgz",
"integrity": "sha512-Gb0T2IqBNe1tLB9EB1Qh+LOe+JB8wt2/rNBDGvkxQVvk8vNeAoG+vZgFB/3P5+zC7RWlyBlzm9dVjZFph/maIg==",
"dev": true,
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
@@ -2952,9 +3359,9 @@
}
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
@@ -2972,15 +3379,22 @@
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz",
"integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==",
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.24.2",
"postcss": "^8.5.1",
"esbuild": "^0.25.0",
"postcss": "^8.5.3",
"rollup": "^4.30.1"
},
"bin": {
@@ -3045,9 +3459,9 @@
}
},
"node_modules/vite-node": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.6.tgz",
"integrity": "sha512-s51RzrTkXKJrhNbUzQRsarjmAae7VmMPAsRT7lppVpIg6mK3zGthP9Hgz0YQQKuNcF+Ii7DfYk3Fxz40jRmePw==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.1.tgz",
"integrity": "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3082,31 +3496,31 @@
}
},
"node_modules/vitest": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.6.tgz",
"integrity": "sha512-/iL1Sc5VeDZKPDe58oGK4HUFLhw6b5XdY1MYawjuSaDA4sEfYlY9HnS6aCEG26fX+MgUi7MwlduTBHHAI/OvMA==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz",
"integrity": "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "3.0.6",
"@vitest/mocker": "3.0.6",
"@vitest/pretty-format": "^3.0.6",
"@vitest/runner": "3.0.6",
"@vitest/snapshot": "3.0.6",
"@vitest/spy": "3.0.6",
"@vitest/utils": "3.0.6",
"@vitest/expect": "3.1.1",
"@vitest/mocker": "3.1.1",
"@vitest/pretty-format": "^3.1.1",
"@vitest/runner": "3.1.1",
"@vitest/snapshot": "3.1.1",
"@vitest/spy": "3.1.1",
"@vitest/utils": "3.1.1",
"chai": "^5.2.0",
"debug": "^4.4.0",
"expect-type": "^1.1.0",
"expect-type": "^1.2.0",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
"std-env": "^3.8.0",
"std-env": "^3.8.1",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinypool": "^1.0.2",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0",
"vite-node": "3.0.6",
"vite-node": "3.1.1",
"why-is-node-running": "^2.3.0"
},
"bin": {
@@ -3122,8 +3536,8 @@
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.0.6",
"@vitest/ui": "3.0.6",
"@vitest/browser": "3.1.1",
"@vitest/ui": "3.1.1",
"happy-dom": "*",
"jsdom": "*"
},

View File

@@ -1,6 +1,6 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.4.2",
"version": "0.4.4",
"private": true,
"scripts": {
"build": "npm run build:popup && npm run build:extension",
@@ -12,24 +12,26 @@
"test:watch": "vitest watch --coverage"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.2",
"@sveltejs/kit": "^2.20.3",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.304",
"@vitest/coverage-v8": "^3.0.6",
"@types/chrome": "^0.0.313",
"@types/node": "^22.14.0",
"@vitest/coverage-v8": "^3.1.1",
"cheerio": "^1.0.0",
"jsdom": "^26.0.0",
"sass": "^1.85.0",
"svelte": "^5.20.1",
"svelte-check": "^4.1.4",
"typescript": "^5.7.3",
"vite": "^6.1.0",
"vitest": "^3.0.6"
"sass": "^1.86.3",
"svelte": "^5.25.6",
"svelte-check": "^4.1.5",
"typescript": "^5.8.2",
"vite": "^6.2.5",
"vitest": "^3.1.1"
},
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@std/regexp": "npm:@jsr/std__regexp@^1.0.1",
"lz-string": "^1.5.0"
}
}

3
src/app.d.ts vendored
View File

@@ -4,6 +4,9 @@ import MaintenanceProfile from "$entities/MaintenanceProfile";
import type TagGroup from "$entities/TagGroup";
declare global {
// 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 {}

View File

@@ -9,7 +9,8 @@
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)));
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>
@@ -41,6 +42,18 @@
</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 {

View File

@@ -4,10 +4,12 @@
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([])
tags = $bindable([]),
mapTagNames,
}: TagEditorProps = $props();
let uniqueTags = $state<Set<string>>(new Set());
@@ -87,7 +89,7 @@
<div class="tags-editor">
{#each uniqueTags.values() as tagName}
<div class="tag">
{tagName}
{mapTagNames?.(tagName) ?? tagName}
<span class="remove" onclick={createTagRemoveHandler(tagName)}
onkeydown={createTagRemoveHandler(tagName)}
role="button" tabindex="0">x</span>

View File

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

View File

@@ -4,8 +4,7 @@ import { calculateMediaBoxesPositions, initializeMediaBox } from "$lib/component
import { createMaintenanceStatusIcon } from "$lib/components/MaintenanceStatusIcon";
import { createImageShowFullscreenButton } from "$lib/components/ImageShowFullscreenButton";
/** @type {NodeListOf<HTMLElement>} */
const mediaBoxes = document.querySelectorAll('.media-box');
const mediaBoxes = document.querySelectorAll<HTMLElement>('.media-box');
mediaBoxes.forEach(mediaBoxElement => {
initializeMediaBox(mediaBoxElement, [

View File

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

View File

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

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,7 +1,7 @@
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 { eventSizeLoaded } from "$lib/components/events/fullscreen-viewer-events";
import { EVENT_SIZE_LOADED } from "$lib/components/events/fullscreen-viewer-events";
export class FullscreenViewer extends BaseComponent {
#videoElement: HTMLVideoElement = document.createElement('video');
@@ -173,7 +173,7 @@ export class FullscreenViewer extends BaseComponent {
this.#sizeSelectorElement.value = size;
this.#isSizeFetched = true;
emit(this.container, eventSizeLoaded, size);
emit(this.container, EVENT_SIZE_LOADED, size);
}
#watchForSizeSelectionChanges() {
@@ -224,7 +224,7 @@ export class FullscreenViewer extends BaseComponent {
await new Promise(
resolve => on(
this.container,
eventSizeLoaded,
EVENT_SIZE_LOADED,
resolve
),
);

View File

@@ -47,7 +47,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
}
#onButtonClicked() {
const imageLinks = this.#mediaBoxTools?.mediaBox.imageLinks;
const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks;
if (!imageLinks) {
throw new Error('Failed to resolve image links from media box tools!');

View File

@@ -6,9 +6,9 @@ import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI";
import { tagsBlacklist } from "$config/tags";
import { emitterAt } from "$lib/components/events/comms";
import {
eventActiveProfileChanged,
eventMaintenanceStateChanged,
eventTagsUpdated
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";
@@ -30,7 +30,7 @@ export class MaintenancePopup extends BaseComponent {
#tagsToAdd: Set<string> = new Set();
#isPlanningToSubmit: boolean = false;
#isSubmitting: boolean = false;
#tagsSubmissionTimer: number | null = null;
#tagsSubmissionTimer: Timeout | null = null;
#emitter = emitterAt(this);
/**
@@ -70,6 +70,10 @@ export class MaintenancePopup extends BaseComponent {
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));
}
@@ -79,11 +83,11 @@ export class MaintenancePopup extends BaseComponent {
this.container.classList.toggle('is-active', activeProfile !== null);
this.#refreshTagsList();
this.#emitter.emit(eventActiveProfileChanged, activeProfile);
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
}
#refreshTagsList() {
if (!this.#mediaBoxTools) {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
@@ -109,11 +113,11 @@ export class MaintenancePopup extends BaseComponent {
this.#tagsList[index] = tagElement;
this.#tagsListElement.appendChild(tagElement);
const isPresent = currentPostTags.has(tagName);
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);
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)) {
@@ -173,7 +177,7 @@ export class MaintenancePopup extends BaseComponent {
}
this.#isPlanningToSubmit = true;
this.#emitter.emit(eventMaintenanceStateChanged, 'waiting');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting');
}
}
@@ -193,14 +197,14 @@ export class MaintenancePopup extends BaseComponent {
}
async #onSubmissionTimerPassed() {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools) {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) {
return;
}
this.#isPlanningToSubmit = false;
this.#isSubmitting = true;
this.#emitter.emit(eventMaintenanceStateChanged, 'processing');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing');
let maybeTagsAndAliasesAfterUpdate;
@@ -242,17 +246,17 @@ export class MaintenancePopup extends BaseComponent {
MaintenancePopup.#notifyAboutPendingSubmission(false);
this.#emitter.emit(eventMaintenanceStateChanged, 'failed');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
this.#isSubmitting = false;
return;
}
if (maybeTagsAndAliasesAfterUpdate) {
this.#emitter.emit(eventTagsUpdated, maybeTagsAndAliasesAfterUpdate);
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
}
this.#emitter.emit(eventMaintenanceStateChanged, 'complete');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete');
this.#tagsToAdd.clear();
this.#tagsToRemove.clear();
@@ -264,7 +268,7 @@ export class MaintenancePopup extends BaseComponent {
}
#revealInvalidTags() {
if (!this.#mediaBoxTools) {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}

View File

@@ -1,7 +1,7 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { on } from "$lib/components/events/comms";
import { eventMaintenanceStateChanged } from "$lib/components/events/maintenance-popup-events";
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
export class MaintenanceStatusIcon extends BaseComponent {
@@ -22,7 +22,7 @@ export class MaintenanceStatusIcon extends BaseComponent {
throw new Error('Status icon element initialized outside of the media box!');
}
on(this.#mediaBoxTools, eventMaintenanceStateChanged, this.#onMaintenanceStateChanged.bind(this));
on(this.#mediaBoxTools, EVENT_MAINTENANCE_STATE_CHANGED, this.#onMaintenanceStateChanged.bind(this));
}
#onMaintenanceStateChanged(stateChangeEvent: CustomEvent<string>) {

View File

@@ -2,7 +2,7 @@ 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 { eventActiveProfileChanged } from "$lib/components/events/maintenance-popup-events";
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";
@@ -39,7 +39,7 @@ export class MediaBoxTools extends BaseComponent {
}
}
on(this, eventActiveProfileChanged, this.#onActiveProfileChanged.bind(this));
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
}
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {

View File

@@ -2,7 +2,7 @@ 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 { eventTagsUpdated } from "$lib/components/events/maintenance-popup-events";
import { EVENT_TAGS_UPDATED } from "$lib/components/events/maintenance-popup-events";
export class MediaBoxWrapper extends BaseComponent {
#thumbnailContainer: HTMLElement | null = null;
@@ -13,7 +13,7 @@ export class MediaBoxWrapper extends BaseComponent {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
on(this, eventTagsUpdated, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
@@ -90,5 +90,10 @@ export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf<HTMLElem
lastMediaBox = mediaBoxElement;
lastMediaBoxPosition = yPosition;
}
// Last-ever media box is checked separately
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
lastMediaBox.classList.add('media-box--last');
}
})
}

View File

@@ -4,7 +4,9 @@ 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 { eventFormEditorUpdated } from "$lib/components/events/tags-form-events";
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();
@@ -51,6 +53,23 @@ export class TagDropdownWrapper extends BaseComponent {
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() {
@@ -188,7 +207,7 @@ export class TagDropdownWrapper extends BaseComponent {
* @param onActiveProfileChange Callback to call when profile was
* changed.
*/
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile|null) => void) {
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile | null) => void) {
let lastActiveProfile: string | null = null;
this.#maintenanceSettings.subscribe((settings) => {
@@ -283,7 +302,7 @@ export function watchTagDropdownsInTagsEditor() {
});
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
on(document.body, eventFormEditorUpdated, event => {
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
for (const tagDropdownElement of event.detail.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}

View File

@@ -1,15 +1,15 @@
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 { eventFetchComplete } from "$lib/components/events/booru-events";
import { eventFormEditorUpdated } from "$lib/components/events/tags-form-events";
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,
eventFetchComplete,
EVENT_FETCH_COMPLETE,
() => this.#waitAndDetectUpdatedForm(unsubscribe),
);
}
@@ -36,7 +36,7 @@ export class TagsForm extends BaseComponent {
const fullTagEditor = tagFormComponent.parentTagEditorElement;
if (fullTagEditor) {
emit(document.body, eventFormEditorUpdated, fullTagEditor);
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
} else {
console.info('Tag form is not in the tag editor. Event is not sent.');
}

View File

@@ -0,0 +1,243 @@
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%';
// 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,6 +1,6 @@
import type { BaseComponent } from "$lib/components/base/BaseComponent";
const instanceSymbol = Symbol('instance');
const instanceSymbol = Symbol.for('instance');
interface ElementWithComponent<T> extends HTMLElement {
[instanceSymbol]?: T;

View File

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

View File

@@ -3,12 +3,14 @@ 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;
& TagsFormEventsMap
& TagDropdownEvents;
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
export type UnsubscribeFunction = () => void;

View File

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

View File

@@ -1,13 +1,13 @@
import type MaintenanceProfile from "$entities/MaintenanceProfile";
export const eventActiveProfileChanged = 'active-profile-changed';
export const eventMaintenanceStateChanged = 'maintenance-state-change';
export const eventTagsUpdated = 'tags-updated';
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 {
[eventActiveProfileChanged]: MaintenanceProfile | null;
[eventMaintenanceStateChanged]: MaintenanceState;
[eventTagsUpdated]: Map<string, string> | null;
[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

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

View File

@@ -2,12 +2,16 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag
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) {
constructor(configurationName: string, storage: StorageHelper = new StorageHelper(chrome.storage.local)) {
this.#configurationName = configurationName;
this.#storage = storage;
}
/**
@@ -19,7 +23,7 @@ export default class ConfigurationController {
* @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 ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
return settings[settingName] ?? defaultValue;
}
@@ -32,11 +36,11 @@ export default class ConfigurationController {
* @return {Promise<void>}
*/
async writeSetting(settingName: string, value: any): Promise<void> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
settings[settingName] = value;
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
this.#storage.write(this.#configurationName, settings);
}
/**
@@ -45,11 +49,11 @@ export default class ConfigurationController {
* @param {string} settingName Setting name to delete.
*/
async deleteSetting(settingName: string): Promise<void> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
delete settings[settingName];
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
this.#storage.write(this.#configurationName, settings);
}
/**
@@ -69,10 +73,8 @@ export default class ConfigurationController {
callback(changes[this.#configurationName].newValue);
}
ConfigurationController.#storageHelper.subscribe(subscriber);
this.#storage.subscribe(subscriber);
return () => ConfigurationController.#storageHelper.unsubscribe(subscriber);
return () => this.#storage.unsubscribe(subscriber);
}
static #storageHelper = new StorageHelper(chrome.storage.local);
}

View File

@@ -1,12 +1,14 @@
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
import TagGroup from "$entities/TagGroup";
import { escapeRegExp } from "$lib/utils";
import { escape as escapeRegExp } from "@std/regexp";
import { emit } from "$lib/components/events/comms";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
export default class CustomCategoriesResolver {
#tagCategories = new Map<string, string>();
#compiledRegExps = new Map<RegExp, string>();
#exactGroupMatches = new Map<string, TagGroup>();
#regExpGroupMatches = new Map<RegExp, TagGroup>();
#tagDropdowns: TagDropdownWrapper[] = [];
#nextQueuedUpdate = -1;
#nextQueuedUpdate: Timeout | null = null;
constructor() {
TagGroup.subscribe(this.#onTagGroupsReceived.bind(this));
@@ -16,7 +18,7 @@ export default class CustomCategoriesResolver {
public addElement(tagDropdown: TagDropdownWrapper): void {
this.#tagDropdowns.push(tagDropdown);
if (!this.#tagCategories.size && !this.#compiledRegExps.size) {
if (!this.#exactGroupMatches.size && !this.#regExpGroupMatches.size) {
return;
}
@@ -24,7 +26,9 @@ export default class CustomCategoriesResolver {
}
#queueUpdatingTags() {
clearTimeout(this.#nextQueuedUpdate);
if (this.#nextQueuedUpdate) {
clearTimeout(this.#nextQueuedUpdate);
}
this.#nextQueuedUpdate = setTimeout(
this.#updateUnprocessedTags.bind(this),
@@ -34,7 +38,6 @@ export default class CustomCategoriesResolver {
#updateUnprocessedTags() {
this.#tagDropdowns
.filter(CustomCategoriesResolver.#skipTagsWithOriginalCategory)
.filter(this.#applyCustomCategoryForExactMatches.bind(this))
.filter(this.#matchCustomCategoryByRegExp.bind(this))
.forEach(CustomCategoriesResolver.#resetToOriginalCategory);
@@ -49,23 +52,33 @@ export default class CustomCategoriesResolver {
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
const tagName = tagDropdown.tagName!;
if (!this.#tagCategories.has(tagName)) {
if (!this.#exactGroupMatches.has(tagName)) {
return true;
}
tagDropdown.tagCategory = this.#tagCategories.get(tagName)!;
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
this.#exactGroupMatches.get(tagName)!
);
return false;
}
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
const tagName = tagDropdown.tagName!;
for (const targetRegularExpression of this.#compiledRegExps.keys()) {
for (const targetRegularExpression of this.#regExpGroupMatches.keys()) {
if (!targetRegularExpression.test(tagName)) {
continue;
}
tagDropdown.tagCategory = this.#compiledRegExps.get(targetRegularExpression)!;
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
this.#regExpGroupMatches.get(targetRegularExpression)!
);
return false;
}
@@ -73,24 +86,29 @@ export default class CustomCategoriesResolver {
}
#onTagGroupsReceived(tagGroups: TagGroup[]) {
this.#tagCategories.clear();
this.#compiledRegExps.clear();
this.#exactGroupMatches.clear();
this.#regExpGroupMatches.clear();
if (!tagGroups.length) {
return;
}
for (const tagGroup of tagGroups) {
const categoryName = tagGroup.settings.category;
for (const tagName of tagGroup.settings.tags) {
this.#tagCategories.set(tagName, categoryName);
this.#exactGroupMatches.set(tagName, tagGroup);
}
for (const tagPrefix of tagGroup.settings.prefixes) {
this.#compiledRegExps.set(
this.#regExpGroupMatches.set(
new RegExp(`^${escapeRegExp(tagPrefix)}`),
categoryName
tagGroup,
);
}
for (let tagSuffix of tagGroup.settings.suffixes) {
this.#regExpGroupMatches.set(
new RegExp(`${escapeRegExp(tagSuffix)}$`),
tagGroup,
);
}
}
@@ -98,12 +116,12 @@ export default class CustomCategoriesResolver {
this.#queueUpdatingTags();
}
static #skipTagsWithOriginalCategory(tagDropdown: TagDropdownWrapper): boolean {
return !tagDropdown.originalCategory;
}
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
tagDropdown.tagCategory = tagDropdown.originalCategory;
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
null,
);
}
static #unprocessedTagsTimeout = 0;

View File

@@ -4,7 +4,9 @@ export interface TagGroupSettings {
name: string;
tags: string[];
prefixes: string[];
suffixes: string[];
category: string;
separate: boolean;
}
export default class TagGroup extends StorageEntity<TagGroupSettings> {
@@ -13,7 +15,9 @@ export default class TagGroup extends StorageEntity<TagGroupSettings> {
name: settings.name || '',
tags: settings.tags || [],
prefixes: settings.prefixes || [],
category: settings.category || ''
suffixes: settings.suffixes || [],
category: settings.category || '',
separate: Boolean(settings.separate),
});
}

View File

@@ -0,0 +1,19 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
interface TagSettingsFields {
groupSeparation: boolean;
}
export default class TagSettings extends CacheableSettings<TagSettingsFields> {
constructor() {
super("tag");
}
async resolveGroupSeparation() {
return this._resolveSetting("groupSeparation", true);
}
async setGroupSeparation(value: boolean) {
return this._writeSetting("groupSeparation", Boolean(value));
}
}

View File

@@ -20,6 +20,9 @@ const entitiesExporters: ExportersMap = {
name: entity.settings.name,
tags: entity.settings.tags,
prefixes: entity.settings.prefixes,
suffixes: entity.settings.suffixes,
category: entity.settings.category,
separate: entity.settings.separate,
}
}
};

48
src/lib/popup-links.ts Normal file
View File

@@ -0,0 +1,48 @@
function resolveReplaceableLink(target: EventTarget | null = null): HTMLAnchorElement | null {
if (!(target instanceof HTMLElement)) {
return null;
}
const closestLink = target.closest('a');
if (
closestLink instanceof HTMLAnchorElement
&& !closestLink.search
&& closestLink.origin === location.origin
) {
return closestLink;
}
return null;
}
function replaceLink(linkElement: HTMLAnchorElement) {
const params = new URLSearchParams([
['path', linkElement.pathname]
]);
linkElement.search = params.toString();
linkElement.pathname = "/index.html";
}
export function initializeLinksReplacement(): () => void {
const abortController = new AbortController();
const replacementHandler = (event: Event) => {
const closestLink = resolveReplaceableLink(event.target);
if (closestLink) {
replaceLink(closestLink);
}
}
// Dynamically replace the links from the Svelte default links to the links usable for the popup.
document.body.addEventListener('mousedown', replacementHandler, {
signal: abortController.signal,
});
document.body.addEventListener('click', replacementHandler, {
signal: abortController.signal,
})
return () => abortController.abort();
}

View File

@@ -21,21 +21,3 @@ export function findDeepObject(targetObject: Record<string, any>, path: string[]
return result;
}
/**
* Matches all the characters needing replacement.
*
* Gathered from right here: https://stackoverflow.com/a/3561711/16048617. Because I don't want to introduce some
* library for that.
*/
const unsafeRegExpCharacters: RegExp = /[/\-\\^$*+?.()|[\]{}]/g;
/**
* Escape all the RegExp syntax-related characters in the following value.
* @param value Original value.
* @return Resulting value with all needed characters escaped.
*/
export function escapeRegExp(value: string): string {
unsafeRegExpCharacters.lastIndex = 0;
return value.replace(unsafeRegExpCharacters, "\\$&");
}

View File

@@ -2,6 +2,8 @@
import "../styles/popup.scss";
import Header from "$components/layout/Header.svelte";
import Footer from "$components/layout/Footer.svelte";
import { initializeLinksReplacement } from "$lib/popup-links";
import { onDestroy } from "svelte";
interface Props {
children?: import('svelte').Snippet;
@@ -12,6 +14,12 @@
// Sort of a hack, detect if we rendered in the browser tab or in the popup.
// Popup will always should have fixed 320px size, otherwise we consider it opened in the tab.
document.body.classList.toggle('is-in-tab', window.innerWidth > 320);
const disconnectLinkReplacement = initializeLinksReplacement();
onDestroy(() => {
disconnectLinkReplacement();
})
</script>
<Header/>

View File

@@ -11,6 +11,7 @@
import TagsEditor from "$components/tags/TagsEditor.svelte";
import TagGroup from "$entities/TagGroup";
import { tagGroups } from "$stores/entities/tag-groups";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
let groupId = $derived(page.params.id);
@@ -25,7 +26,9 @@
let groupName = $state<string>('');
let tagsList = $state<string[]>([]);
let prefixesList = $state<string[]>([]);
let tagCategory = $state<string>('');
let suffixesList = $state<string[]>([]);
let tagCategory = $state<string>('')
let separateGroup = $state<boolean>(false);
$effect(() => {
if (groupId === 'new') {
@@ -40,7 +43,9 @@
groupName = targetGroup.settings.name;
tagsList = [...targetGroup.settings.tags].sort((a, b) => a.localeCompare(b));
prefixesList = [...targetGroup.settings.prefixes].sort((a, b) => a.localeCompare(b));
suffixesList = [...targetGroup.settings.suffixes].sort((a, b) => a.localeCompare(b));
tagCategory = targetGroup.settings.category;
separateGroup = targetGroup.settings.separate;
});
async function saveGroup() {
@@ -52,21 +57,36 @@
targetGroup.settings.name = groupName;
targetGroup.settings.tags = [...tagsList];
targetGroup.settings.prefixes = [...prefixesList];
targetGroup.settings.suffixes = [...suffixesList];
targetGroup.settings.category = tagCategory;
targetGroup.settings.separate = separateGroup;
await targetGroup.save();
await goto(`/features/groups/${targetGroup.id}`);
}
function mapPrefixNames(tagName: string): string {
return `${tagName}*`;
}
function mapSuffixNames(tagName: string): string {
return `*${tagName}`;
}
</script>
<Menu>
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/groups/{groupId === 'new' ? '' : groupId}" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl label="Group Name">
<TextField bind:value={groupName} placeholder="Group Name"></TextField>
</FormControl>
<FormControl>
<CheckboxField bind:checked={separateGroup}>
Display tags found by this group in separate list after all other tags.
</CheckboxField>
</FormControl>
<FormControl label="Group Color">
<TagCategorySelectField bind:value={tagCategory}/>
</FormControl>
@@ -77,7 +97,12 @@
</TagsColorContainer>
<TagsColorContainer targetCategory={tagCategory}>
<FormControl label="Tag Prefixes">
<TagsEditor bind:tags={prefixesList}/>
<TagsEditor bind:tags={prefixesList} mapTagNames={mapPrefixNames}/>
</FormControl>
</TagsColorContainer>
<TagsColorContainer targetCategory={tagCategory}>
<FormControl label="Tag Suffixes">
<TagsEditor bind:tags={suffixesList} mapTagNames={mapSuffixNames}/>
</FormControl>
</TagsColorContainer>
</FormContainer>

View File

@@ -5,6 +5,7 @@
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { stripBlacklistedTagsEnabled } from "$stores/preferences/maintenance";
import { shouldSeparateTagGroups } from "$stores/preferences/tag";
</script>
<Menu>
@@ -17,4 +18,9 @@
Automatically remove black-listed tags from the images
</CheckboxField>
</FormControl>
<FormControl>
<CheckboxField bind:checked={$shouldSeparateTagGroups}>
Enable separation of custom tag groups on the image pages
</CheckboxField>
</FormControl>
</FormContainer>

View File

@@ -0,0 +1,18 @@
import { writable } from "svelte/store";
import TagSettings from "$lib/extension/settings/TagSettings";
const tagSettings = new TagSettings();
export const shouldSeparateTagGroups = writable(false);
tagSettings.resolveGroupSeparation()
.then(value => shouldSeparateTagGroups.set(value))
.then(() => {
shouldSeparateTagGroups.subscribe(value => {
void tagSettings.setGroupSeparation(value);
});
tagSettings.subscribe(settings => {
shouldSeparateTagGroups.set(Boolean(settings.groupSeparation));
});
})

View File

@@ -1,9 +1,6 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
function randomString() {
return crypto.randomUUID();
}
import { randomString } from "$tests/utils";
describe('BaseComponent', () => {
it('should bind the component to the element', () => {

View File

@@ -0,0 +1,186 @@
import ConfigurationController from "$lib/extension/ConfigurationController";
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
import StorageHelper from "$lib/browser/StorageHelper";
import { randomString } from "$tests/utils";
describe('ConfigurationController', () => {
const mockedStorageArea = new ChromeStorageArea();
const mockedStorageHelper = new StorageHelper(mockedStorageArea);
beforeEach(() => {
mockedStorageArea.clear();
});
it('should read setting from the field inside the configuration object', async () => {
const name = randomString();
const field = randomString();
const value = randomString();
mockedStorageArea.insertMockedData({
[name]: {
[field]: value
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
const returnedValue = await controller.readSetting(field);
expect(returnedValue).toBe(value);
});
it('should return fallback value if configuration field does not exist', async () => {
const controller = new ConfigurationController(randomString(), mockedStorageHelper);
const fallbackValue = randomString();
const returnedValue = await controller.readSetting(randomString(), fallbackValue);
expect(returnedValue).toBe(fallbackValue);
});
it('should treat existing falsy values as existing values', async () => {
const name = randomString();
const falsyValuesStorage = [0, false, ''].reduce((record, value) => {
record[randomString()] = value;
return record;
}, {} as Record<string, any>);
mockedStorageArea.insertMockedData({
[name]: falsyValuesStorage
});
const controller = new ConfigurationController(name, mockedStorageHelper);
for (const fieldName of Object.keys(falsyValuesStorage)) {
const returnedValue = await controller.readSetting(fieldName, randomString());
expect(returnedValue).toBe(falsyValuesStorage[fieldName]);
}
});
it('should write data to storage', async () => {
const name = randomString();
const field = randomString();
const value = randomString();
const controller = new ConfigurationController(name, mockedStorageHelper);
await controller.writeSetting(field, value);
const expectedStructure = {
[name]: {
[field]: value,
}
};
expect(mockedStorageArea.mockedData).toEqual(expectedStructure);
});
it('should update existing object without touching other entries', async () => {
const name = randomString();
const existingField = randomString();
const existingValue = randomString();
const addedField = randomString();
const addedValue = randomString();
mockedStorageArea.insertMockedData({
[name]: {
[existingField]: existingValue,
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
await controller.writeSetting(addedField, addedValue);
const expectedStructure = {
[name]: {
[existingField]: existingValue,
[addedField]: addedValue,
}
}
expect(mockedStorageArea.mockedData).toEqual(expectedStructure);
});
it('should delete setting from storage', async () => {
const name = randomString();
const field = randomString();
const value = randomString();
mockedStorageArea.insertMockedData({
[name]: {
[field]: value
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
await controller.deleteSetting(field);
expect(mockedStorageArea.mockedData).toEqual({
[name]: {},
});
});
it('should return updated settings contents on changes', async () => {
const name = randomString();
const initialField = randomString();
const initialValue = randomString();
const addedField = randomString();
const addedValue = randomString();
const updatedInitialValue = randomString();
const receivedData: Record<string, string>[] = [];
mockedStorageArea.insertMockedData({
[name]: {
[initialField]: initialValue,
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
const subscriber = vi.fn((storageState: Record<string, string>) => {
receivedData.push(JSON.parse(JSON.stringify(storageState)));
});
controller.subscribeToChanges(subscriber);
await controller.writeSetting(addedField, addedValue);
await controller.writeSetting(initialField, updatedInitialValue);
await controller.deleteSetting(initialField);
expect(subscriber).toBeCalledTimes(3);
const expectedData: Record<string, string>[] = [
// First, initial data and added field are present
{
[initialField]: initialValue,
[addedField]: addedValue,
},
// Then we get new value on initial field
{
[initialField]: updatedInitialValue,
[addedField]: addedValue,
},
// And then the initial value is dropped
{
[addedField]: addedValue,
}
];
expect(receivedData).toEqual(expectedData);
});
it('should stop listening once unsubscribe called', async () => {
const controller = new ConfigurationController(randomString(), mockedStorageHelper);
const subscriber = vi.fn();
const unsubscribe = controller.subscribeToChanges(subscriber);
await controller.writeSetting(randomString(), randomString());
expect(subscriber).toBeCalledTimes(1);
unsubscribe();
subscriber.mockReset();
await controller.writeSetting(randomString(), randomString())
expect(subscriber).not.toBeCalled();
});
});

View File

@@ -0,0 +1,75 @@
import { randomString } from "$tests/utils";
import { initializeLinksReplacement } from "$lib/popup-links";
describe('popup-links', () => {
let expectedPath = '';
let testLink: HTMLAnchorElement = document.createElement('a');
let disconnectCallback: (() => void) | null = null;
function fireEventAt(target: EventTarget, eventName: string) {
target.dispatchEvent(new Event(eventName, {bubbles: true}));
}
beforeEach(() => {
expectedPath = `/test/${randomString()}`;
testLink.href = expectedPath;
document.body.append(testLink);
});
afterEach(() => {
if (disconnectCallback) {
disconnectCallback();
disconnectCallback = null;
}
});
it('should replace link on any mouse button down', () => {
disconnectCallback = initializeLinksReplacement();
fireEventAt(testLink, "mousedown");
const resultUrl = new URL(testLink.href);
expect(resultUrl.searchParams.get('path')).toBe(expectedPath);
});
it('should replace link when link is pressed by keyboard or clicked', () => {
disconnectCallback = initializeLinksReplacement();
fireEventAt(testLink, "click");
const resultUrl = new URL(testLink.href);
expect(resultUrl.searchParams.get('path')).toBe(expectedPath);
});
it('should not replace already replaced links', () => {
disconnectCallback = initializeLinksReplacement();
fireEventAt(testLink, "click");
const hrefAfterFirstClick = testLink.href;
fireEventAt(testLink, "click");
const hrefAfterSecondClick = testLink.href;
expect(hrefAfterFirstClick).toBe(hrefAfterSecondClick);
});
it('should stop replacing links once disconnect is called', () => {
const hrefBefore = testLink.href;
disconnectCallback = initializeLinksReplacement();
disconnectCallback();
fireEventAt(testLink, "mousedown");
fireEventAt(testLink, "click");
expect(hrefBefore).toBe(testLink.href);
});
it('should not touch links with different origin', () => {
testLink.href = "https://external.example.com/" + randomString() + "/";
const hrefBefore = testLink.href;
disconnectCallback = initializeLinksReplacement();
fireEventAt(testLink, "click");
expect(testLink.href).toBe(hrefBefore);
});
});

View File

@@ -1,9 +1,9 @@
export default class ChromeEvent<T extends Function> implements chrome.events.Event<T> {
addListener = vi.fn();
getRules = vi.fn();
hasListener = vi.fn();
removeRules = vi.fn();
addRules = vi.fn();
removeListener = vi.fn();
hasListeners = vi.fn();
addListener = vi.fn();
getRules = vi.fn();
hasListener = vi.fn();
removeRules = vi.fn();
addRules = vi.fn();
removeListener = vi.fn();
hasListeners = vi.fn();
}

View File

@@ -1,5 +1,5 @@
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
export class ChromeLocalStorageArea extends ChromeStorageArea implements chrome.storage.LocalStorageArea {
QUOTA_BYTES = 100000;
QUOTA_BYTES = 100000;
}

View File

@@ -1,4 +1,4 @@
import ChromeEvent from "./ChromeEvent";
import ChromeStorageChangeEvent from "$tests/mocks/ChromeStorageChangeEvent";
type ChangedEventCallback = (changes: chrome.storage.StorageChange) => void
@@ -13,8 +13,20 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea {
})
});
set = vi.fn((...args: any[]): Promise<void> => {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
const change: Record<string, chrome.storage.StorageChange> = {};
const setter = args[0];
for (let targetKey of Object.keys(setter)) {
change[targetKey] = {
oldValue: this.#mockedData[targetKey] ?? undefined,
newValue: setter[targetKey],
};
}
this.#mockedData = Object.assign(this.#mockedData, args[0]);
this.onChanged.mockEmitStorageChange(change);
resolve();
})
});
@@ -23,7 +35,16 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea {
const key = args[0];
if (typeof key === 'string') {
const change: chrome.storage.StorageChange = {
oldValue: this.#mockedData[key],
};
delete this.#mockedData[key];
this.onChanged.mockEmitStorageChange({
[key]: change
});
resolve();
}
@@ -58,7 +79,7 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea {
});
});
setAccessLevel = vi.fn();
onChanged = new ChromeEvent<ChangedEventCallback>();
onChanged = new ChromeStorageChangeEvent();
getKeys = vi.fn();
insertMockedData(data: Record<string, any>) {

View File

@@ -0,0 +1,27 @@
import ChromeEvent from "$tests/mocks/ChromeEvent";
import { EventEmitter } from "node:events";
type MockedStorageChanges = Record<string, chrome.storage.StorageChange>;
type IncomingStorageChangeListener = (changes: MockedStorageChanges) => void;
const storageChangeEvent = Symbol();
interface StorageChangeEventMap {
[storageChangeEvent]: [MockedStorageChanges];
}
export default class ChromeStorageChangeEvent extends ChromeEvent<IncomingStorageChangeListener> {
#emitter = new EventEmitter<StorageChangeEventMap>();
addListener = vi.fn((actualListener: IncomingStorageChangeListener) => {
this.#emitter.addListener(storageChangeEvent, actualListener);
});
removeListener = vi.fn((actualListener: IncomingStorageChangeListener) => {
this.#emitter.removeListener(storageChangeEvent, actualListener);
});
mockEmitStorageChange(changes: MockedStorageChanges) {
this.#emitter.emit(storageChangeEvent, changes);
}
}

7
tests/utils.ts Normal file
View File

@@ -0,0 +1,7 @@
export function randomString(): string {
return crypto.randomUUID();
}
export function copyValue<T>(object: T): T {
return JSON.parse(JSON.stringify(object));
}