Compare commits

...
97 Commits
Author SHA1 Message Date
koloml c831d19c81 Merge pull request #191 from koloml/feature/build-extension-for-local-philomena
Added build option to build it against local Philomena
2026-11-01 03:26:47 +01:00
koloml bcdc89efbb Added build option to build it against local Philomena
Only intended to be used for locally hosted Philomena
2026-11-01 03:22:24 +01:00
koloml 223b72d9fb Merge pull request #181 from koloml/release/0.8.0
Release: 0.8.0
2026-10-14 03:37:10 +02:00
koloml 5432e63d6e Updated dependencies (#186)
* Updated `@sveltejs/kit` from 2.65.0 to 2.70.3

* Updated `svelte` from 5.56.3 to 5.57.2

* Updated `vitest` and `@vitest/coverage-v8` from 4.1.8 to 4.1.11

* Updated `svelte-check` from 4.6.0 to 4.7.6

* Updated `sass` from 1.101.0 to 1.105.1

* Updated `vite` from 7.3.5 to 7.3.7

* Updated `@fortawesome/fontawesome-free` from 7.2.0 to 7.3.1

* Updated `@types/node` from 25.9.3 to 25.9.9

* Updated `@types/chrome` from 0.1.43 to 0.3.5
2026-10-13 21:22:30 -04:00
koloml a06fed078e Update Fullscreen Viewer showcase in README 2026-10-14 03:11:01 +02:00
koloml 221051ff41 Bumped version to 0.8.0 2026-10-14 03:10:57 +02:00
koloml 87df78730b Mark extension as supported for Firefox Android version 2026-10-14 03:10:57 +02:00
koloml c6c48b2c36 Merge pull request #184 from koloml/feature/fullscreen-tagging-profile
Upgraded fullscreen viewer with toggleable tags
2026-10-14 03:10:39 +02:00
koloml 5578e64306 Allow toggling tags in fullscreen on/off, fixing long tags lists 2026-10-14 02:43:13 +02:00
koloml 7fd5a20b2f Merge remote-tracking branch 'origin/release/0.8.0' into feature/fullscreen-tagging-profile 2026-10-14 01:46:05 +02:00
koloml 4dcddc3969 Merge pull request #185 from koloml/feature/duplicate-reports-helpers
Fixed image container not being initialized properly causing errors
2026-10-14 01:43:26 +02:00
koloml c7e1a95172 Fixed image container not being initialized properly causing errors 2026-10-14 01:40:14 +02:00
koloml ecd95d3b4f Merge remote-tracking branch 'origin/release/0.8.0' into feature/fullscreen-tagging-profile
# Conflicts:
#	src/content/components/philomena/MediaBox.ts
2026-10-14 01:31:14 +02:00
koloml f62c150724 Merge pull request #182 from koloml/feature/duplicate-reports-helpers
Staff Duplicate Reports: Additional info on ratings and source icons, mobile friendly layout
2026-10-14 01:30:21 +02:00
koloml 665057d9f3 Fixed some diff rows with excessive amount of sources breaking layout 2026-10-14 01:26:58 +02:00
koloml d75a9b434d More mobile-friendly dupe reports view 2026-10-14 01:26:37 +02:00
koloml bdac2692ce Fixed dupe reports rows detection accidentally trying to use diff rows 2026-10-14 01:25:51 +02:00
koloml 28c2f2f2e7 Merge pull request #183 from koloml/feature/unify-store-and-storage-sync
Trying to unify the preference syncing logic into single class
2026-10-14 00:58:05 +02:00
koloml d150d87d89 Fixed tags not synced to the profile toggler 2026-10-14 00:44:19 +02:00
koloml 690faa854f Added tags list and active profile tags into fullscreen viewer
Images now can be toggled with tagging profiles using fullscreen viewer
and not just with the popup over the small media box.

Submission of tag changes is still processed by the popup inside
original media box. Fullscreen viewer is just sending these changes
there and then, once it's closed, tag changes will be sent.
2026-10-14 00:42:51 +02:00
koloml a2452a5fea Extracted tags list toggler from profile popup to reuse it later 2026-10-13 22:09:45 +02:00
koloml b1c85efe03 Support updated duplicate reports table from latest Philomena update 2026-10-01 07:23:20 +02:00
koloml 34c0a11b4e Added separate class for syncing multiple preference fields with stores
This will make the sync code less boilerplate-y. Instead of manually
subscribing back and forth, this will be done inside the class in
correct order and in any amount.

Types are a bit wonky though.
2026-08-30 11:52:07 +02:00
koloml f4e92e313a Filling mapping of FA icons to different domains
Partially ported this list from the Philomena source code.
2026-08-30 07:13:21 +02:00
koloml e1b56586be Work with dupe reports list with different arguments 2026-08-30 06:30:32 +02:00
koloml b87a8a43e0 Display ratings when duplicate reports have different ratings 2026-08-30 06:23:58 +02:00
koloml d19764699f Find and modify duplicate reports, show source url icons in the diff 2026-08-30 05:44:26 +02:00
koloml f873cae544 Support creating favicon items of different types
I need that for brand icons. Without specified subtype, will point to
regular solid icons.
2026-08-30 05:27:20 +02:00
koloml c23c871337 Extracted image container into separate class for dupes
It uses the same element, and it's probably just better to extract this
logic into separate class instead of doing work in different classes.
2026-08-30 04:26:13 +02:00
koloml 38d84d0830 Merge pull request #178 from koloml/feature/test-storage-entities
Covering base entity and entities controller classes into tests
2026-08-29 21:39:46 -04:00
koloml 4eecde6b02 Merge pull request #179 from koloml/feature/bump-actions
Bumped CI action versions and set required node version to 25
2026-08-29 21:39:31 -04:00
koloml 394df69936 Merge pull request #180 from koloml/feature/store-last-user-flags
Store last seen user flags from Philomena, show warning in profiles list when user is logged off
2026-08-29 21:39:17 -04:00
koloml c268eb564d Show warning for profiles when user is not logged in 2026-06-21 21:54:20 +04:00
koloml cfb4c71f58 Capture and store last seen user flags from the booru
Main use case for these flags is to notify when something they're trying
to use will not be usable due to authorization or their role on the
site. Specifically, it will be also used later to detect when user
is a part of staff and show them staff-specific features.
2026-06-21 21:53:50 +04:00
koloml 67cd17d2b6 Force node version to be v25.x, updated lock file 2026-06-21 19:47:36 +04:00
koloml 1b4cb284da Bumped CI action versions 2026-06-21 19:31:58 +04:00
koloml 7af6124278 Cover 2 more functions in tag-utils module 2026-06-21 19:19:34 +04:00
koloml 51ea806ddc Shorten waiting timeout & interval to reduce test time 2026-06-21 17:54:08 +04:00
koloml 234db4f147 Testing internal EntitiesController class used by the entities 2026-06-21 17:52:08 +04:00
koloml 978918735d Extracted preferences & entity stubs to make them reusable 2026-06-21 16:18:11 +04:00
koloml a6eae657c7 Added comment for the storage helper instance in EntitiesController 2026-06-21 15:36:14 +04:00
koloml 9a245ed0f5 Testing StorageEntity class behavior 2026-06-21 15:31:03 +04:00
koloml 3e5266ca7b Expose storage in EntitiesController for testing purposes
- Storage can now be mocked
- Class can be loaded even if chrome storage is not present globally
- It will not throw an error if storage not found in runtime
2026-06-21 15:29:42 +04:00
koloml 515485b3b8 Merge pull request #174 from koloml/release/0.7.2
Release: 0.7.2
2026-06-20 14:17:55 -04:00
koloml f311676097 Merge pull request #177 from koloml/feature/test-preferences-api
Testing behavior of internal preferences class
2026-06-20 14:16:06 -04:00
koloml a0e52955e6 Sonar: Added readonly to private members 2026-06-20 22:10:51 +04:00
koloml 74554bc9f6 Covering cacheable preferences into tests 2026-06-20 22:03:11 +04:00
koloml 4094babc82 Bumped version to 0.7.2 2026-06-14 04:16:17 +04:00
koloml 1de2f3f97a Updated dependencies (#176)
* Updated `vite` from 7.3.1 to 7.3.5
* Updated `vitest` and `@vitest/coverage-v8` from 4.1.0 to 4.1.8
* Updated `typescript` from 5.9.3 to 6.0.3
* Updated `@sveltejs/kit` from 2.55.0 to 2.65.0
* Updated `svelte` from 5.53.12 to 5.56.3
* Updated `@types/chrome` from 0.1.37 to 0.1.43
* Updated `@types/node` from 25.5.0 to 25.9.3
* Updated `jsdom` from 28.1.0 to 29.1.1
* Updated `svelte-check` from 4.4.5 to 4.6.0
* Updated `sass` from 1.98.0 to 1.101.0
2026-06-14 04:13:36 +04:00
koloml c1c774f4bb Merge pull request #175 from koloml/feature/footer-author-head
Added avali head into the footer near the author name
2026-06-13 19:58:13 -04:00
koloml 2f5c37d21f Merge pull request #172 from koloml/bugfix/tags-in-forums-with-encoded-text
Fixed wrong parsing of tag links inside forum posts
2026-06-13 19:55:32 -04:00
koloml 3404877091 Sonar: Replacing string.match(regexp) with regexp.exec(string) 2026-06-14 03:19:54 +04:00
koloml ed3db1240c Signaling to TypeScript that match is present when function returns true 2026-06-14 03:15:51 +04:00
koloml 9a6274c815 Sonar: Marked special characters and RegExps as readonly 2026-06-14 03:13:45 +04:00
koloml b8043bace6 Merge pull request #173 from koloml/feature/update-repo-links
Updated link to repository inside the extension
2026-06-13 18:30:14 -04:00
koloml 3a31eb2519 Formatting, missing import 2026-06-14 02:05:00 +04:00
koloml ab255e535c Testing word-like operators and escaping them inside quotes 2026-06-14 02:03:41 +04:00
koloml f8758306b7 Added boost to the list of queries to test 2026-06-14 02:03:08 +04:00
koloml 7c462e1b5c Added missing coverage for "NOT" operator 2026-06-14 01:31:10 +04:00
koloml a909180798 Fixed terms not trimming out the whitespaces 2026-06-14 01:29:17 +04:00
koloml 6ceeabe170 Added tests for search query lexer 2026-06-14 01:28:37 +04:00
koloml 40aa02ff70 Fixed quoted term not processing starting & ending quotes 2026-06-14 00:32:47 +04:00
koloml 8c4c32c4bf Covering quoted term decoding/encoding behavior 2026-06-14 00:29:33 +04:00
koloml 88ebcef18a Removed imports of already global functions 2026-06-13 22:57:37 +04:00
koloml de57432594 Fixed wrong index used when checking for ) character 2026-06-13 22:46:08 +04:00
koloml 74f113412e Fixed space being mandatory before ) and ^ 2026-06-13 22:44:57 +04:00
koloml 3e0495a529 Moved parentheses check to run before dirty text content extraction 2026-06-13 22:42:57 +04:00
koloml 24d17416b5 Cover the case when first term is not a term or when nothing is there 2026-06-13 22:07:11 +04:00
koloml 736c0917c0 Reversed + → conversion with dash-encoded characters conversion
This way encoded `+` character will properly decode after other `+` were
dealt with
2026-06-13 22:01:14 +04:00
koloml f01bfe8ae0 Removed erroneous - → conversion for tag links 2026-06-13 22:00:20 +04:00
koloml 726c4dfe58 Added tests for tag name extraction from tag links & search queries 2026-06-13 21:56:31 +04:00
koloml 4142c1053b Updated links to the repository 2026-04-15 16:46:13 +04:00
koloml 7559f0a802 Added avali head into the footer near the author name 2026-04-05 22:26:58 +04:00
koloml b905ed668c Merge pull request #170 from koloml/release/0.7.1
Release: 0.7.1
2026-04-05 11:54:19 -04:00
koloml 5c26888292 Bumped version to 0.7.1 2026-04-05 19:49:28 +04:00
koloml b6329bc4ef Merge pull request #171 from koloml/bugfix/preset-cls
Presets: Preserve correct scroll position when exclusive/conditional
2026-04-05 11:48:23 -04:00
koloml 4f52906123 Extracted CLS compensation logic into separate method 2026-04-05 19:46:34 +04:00
koloml 7d41524b4a Fixed scroll jump when preset becomes hidden 2026-04-05 19:21:09 +04:00
koloml 81b3d61a20 Fixed content layout shift caused by exclusive tags warning 2026-04-05 19:15:39 +04:00
koloml dc78b2fe84 Merge pull request #169 from koloml/feature/conditional-presets
Presets: Added option to make presets conditional
2026-04-05 10:45:49 -04:00
koloml c12b00817b Compare value to undefined instead of calling typeof 2026-04-05 18:35:26 +04:00
koloml b4419b5de3 Validate exclusive and conditional as optional booleans 2026-04-05 18:33:34 +04:00
koloml 66fd093e5a Marking unchanged properties as readonly 2026-04-05 18:29:47 +04:00
koloml bc7f85eaf9 Merge pull request #168 from koloml/feature/tag-profile-popup-cancel
Tagging Profiles: Cancel pending submission in popup if user decides to cancel their changes
2026-04-05 10:27:17 -04:00
koloml 3f9412b02d A bit more concrete wording for checkbox 2026-04-05 18:27:02 +04:00
koloml a75dd098dc Show when preset is conditional in the viewer block 2026-04-05 18:18:52 +04:00
koloml a45248cebf Fixed crashing build due to missing trailing comma 2026-04-05 18:02:54 +04:00
koloml c777b57efb Merge remote-tracking branch 'origin/release/0.7.1' into feature/conditional-presets
# Conflicts:
#	src/content/components/extension/presets/PresetTableRow.ts
#	src/lib/extension/entities/TagEditorPreset.ts
#	src/lib/extension/transporting/exporters.ts
#	src/routes/features/presets/[id]/edit/+page.svelte
2026-04-05 18:01:33 +04:00
koloml 025cbaebb7 Merge pull request #167 from koloml/feature/exclusive-tags-in-preset
Presets: Added "exclusive" mode where only one tag is meant to be active at a time
2026-04-05 09:57:25 -04:00
koloml b031b88512 Fixed tags being user-selectable 2026-04-05 17:52:46 +04:00
koloml 399e75809b Display presets when tag is found or hide it otherwise 2026-04-05 17:52:35 +04:00
koloml f581f84065 Presets: Added conditional presets option
Now presets can be configured to show up only when specific tag is
provided.
2026-04-05 17:52:15 +04:00
koloml e8b0afc81f And even more renaming of popup across multiple files 2026-03-29 04:17:12 +04:00
koloml daceb9ad59 Cancel the planned submission when pending changes canceled 2026-03-29 03:57:06 +04:00
koloml c36929b824 Renaming the file with events as well 2026-03-29 03:49:22 +04:00
koloml 9b262393fa Fixed event & underlying type not updated 2026-03-29 03:48:12 +04:00
koloml 83c7608e99 Presets: Added flag for making it "exclusive"
This will make it so only one tag will be active from marked preset.
This can be useful for some tags that cannot be together in the editor,
for example, rating tags.
2026-03-22 04:09:18 +04:00
68 changed files with 3256 additions and 1053 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1015 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+3 -3
View File
@@ -14,12 +14,12 @@ jobs:
name: 'Run Unit Tests'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '22'
node-version: '25'
- name: Install npm dependencies
run: npm ci
+6 -6
View File
@@ -14,12 +14,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '25'
cache: 'npm'
- name: Install dependencies
@@ -39,7 +39,7 @@ jobs:
zip -r "../${{ matrix.site }}-tagging-assistant-extension.zip" .
- name: Upload extension artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.site }}-tagging-assistant-extension
path: ${{ matrix.site }}-tagging-assistant-extension.zip
@@ -51,12 +51,12 @@ jobs:
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: artifacts
- name: Create combined artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: all-extensions
path: artifacts/
+6
View File
@@ -69,6 +69,12 @@ export async function packExtension(settings) {
})
switch (process.env.SITE) {
case 'localhost':
manifest.replaceHostTo('localhost');
manifest.replaceBooruNameWith('Philomena');
manifest.setGeckoIdentifier('philomena-tagging-assistant@thecore.city');
break;
case 'derpibooru':
manifest.replaceHostTo([
'derpibooru.org',
+5
View File
@@ -49,6 +49,11 @@ feature is opt-in and should be enabled in the settings first.
![Fullscreen Viewer Showcase](.github/assets/fullscreen-viewer-showcase.png)
Fullscreen view now also has interface for quick addition and removal of tags. It will show both currently active
tagging profile and tags currently assigned to the image.
![Fullscreen Viewer with Tags](.github/assets/fullscreen-viewer-tags.png)
## Building
Recommendations on environment:
+17 -3
View File
@@ -1,7 +1,7 @@
{
"name": "Furbooru Tagging Assistant",
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
"version": "0.7.0",
"version": "0.8.0",
"browser_specific_settings": {
"gecko": {
"id": "furbooru-tagging-assistant@thecore.city",
@@ -10,7 +10,8 @@
"none"
]
}
}
},
"gecko_android": {}
},
"icons": {
"16": "icon16.png",
@@ -28,7 +29,8 @@
"*://*.furbooru.org/*"
],
"js": [
"src/content/deps/amd.ts"
"src/content/deps/amd.ts",
"src/content/user-details.ts"
]
},
{
@@ -103,6 +105,18 @@
"css": [
"src/styles/content/tag-presets.scss"
]
},
{
"matches": [
"*://*.furbooru.org/duplicate_reports",
"*://*.furbooru.org/duplicate_reports?*"
],
"js": [
"src/content/duplicate-reports.ts"
],
"css": [
"src/styles/content/duplicate-reports.scss"
]
}
],
"action": {
+545 -603
View File
File diff suppressed because it is too large. Load diff
+17 -13
View File
@@ -1,12 +1,13 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.7.0",
"version": "0.8.0",
"private": true,
"type": "module",
"scripts": {
"build": "npm run build:popup && npm run build:extension",
"build:derpibooru": "cross-env SITE=derpibooru npm run build",
"build:tantabus": "cross-env SITE=tantabus npm run build",
"build:local": "cross-env SITE=localhost npm run build",
"build:popup": "vite build",
"build:extension": "node build-extension.js",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@@ -16,24 +17,27 @@
},
"dependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.55.0",
"@fortawesome/fontawesome-free": "^7.2.0",
"@sveltejs/kit": "^2.70.3",
"@fortawesome/fontawesome-free": "^7.3.1",
"amd-lite": "^1.0.1",
"lz-string": "^1.5.0",
"sass": "^1.98.0",
"svelte": "^5.53.12"
"sass": "^1.105.1",
"svelte": "^5.57.2"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@types/chrome": "^0.1.37",
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "^4.1.0",
"@types/chrome": "^0.3.5",
"@types/node": "^25.9.9",
"@vitest/coverage-v8": "^4.1.11",
"cheerio": "^1.2.0",
"cross-env": "^10.1.0",
"jsdom": "^28.1.0",
"svelte-check": "^4.4.5",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.1.0"
"jsdom": "^29.1.1",
"svelte-check": "^4.7.6",
"typescript": "^6.0.3",
"vite": "^7.3.7",
"vitest": "^4.1.11"
},
"engines": {
"node": ">=25"
}
}
+14
View File
@@ -0,0 +1,14 @@
# Author Heads
Just small little head appearing inside the footer of popup. For now only for Furbooru version, maybe pony head will be
added later.
## Credits
- Original avali head sketch by [@canaryarachnid](https://x.com/canaryarachnid).
- Vectorized by me.
## License
These heads icons are available under [CC0](https://creativecommons.org/publicdomain/zero/1.0/) license. Feel free to
use in any way you want.
+19
View File
@@ -0,0 +1,19 @@
<svg width="236.02mm" height="148.13mm" version="1.1" viewBox="0 0 236.02 148.13" xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"><g transform="translate(11.797 -86.754)"><g fill="#968e99"><path d="m186.16 195.05 5.9113 8.1132-25.874 23.732-16.665-20.556 5.9795-8.3411z"/><path
d="m26.25 195.06-5.9113 8.1132 25.874 23.732 16.666-20.582-5.9805-8.3147z"/><path
d="m203.89 93.217-13.663 4.8447-32.193 45.799 3.6721 9.9725 37.277-16.965z"/><path
d="m8.4989 93.206 13.663 4.8447 32.193 45.799-3.6721 9.9725-37.277-16.965z"/><path
d="m63.729 147.62-3.547 9.6564 46.015 67.389 46.042-67.393-3.5471-9.6544z"/></g><g fill="#4f85c2"><path d="m20.339 203.17c-5.551 7.6763-11.833 16.516-18.939 26.694 27.59 13.761 53.74-7.154 65.586-19.106-0.49035-0.60034-3.0245-3.1978-4.1062-4.4385-4.2321 2.3924-10.049 3.5511-17.508 3.545-7.9048-0.35181-18.303-2.0451-25.032-6.6947z"/>
<path d="m192.07 203.16c-7.4239 4.6488-16.449 6.3228-25.051 6.7081-7.4927 6e-3 -13.307-1.1725-17.486-3.5295-2.4713 2.8329-2.9096 2.911-4.1212 4.4013 11.836 11.949 38.001 32.897 65.607 19.128-7.1107-10.185-13.396-19.029-18.949-26.708z"/>
<path d="m152.24 157.27c-13.612 1.3595-21.056 0.76683-30.326 12.205-4.1088 5.543-4.2586 10.12-3.699 14.359 0.45057 4.3583 3.1102 9.099 0.87695 13.343-2.1841 3.7829-5.9067 5.0599-8.4982 5.5645-2.4209 0.47143-6.339 0.47752-8.7912 0-2.5915-0.50464-6.3141-1.7816-8.4982-5.5645-2.1245-4.2051 0.25648-9.0329 0.87695-13.343 0.55962-4.2396 0.40976-8.8163-3.699-14.359-8.9349-11.92-17.402-10.288-30.299-12.201-0.88134 3.6253-1.4784 7.3599-1.4785 11.262-3.8e-5 13.042 5.3573 25.506 14.822 34.479 6.5392 7.1685 12.467 15.901 21.358 20.363 3.4326 1.6502 18.928 1.778 22.626 0 8.6078-4.1381 20.376-19.41 21.275-20.259 9.5319-8.9788 14.934-21.487 14.934-34.582-3e-5 -3.9035-0.59853-7.6394-1.4805-11.266z"/></g><g
fill="#29191a"><path d="m106.21 121.62c-9.7679 8e-5 -18.634 3.0833-26.383 8.0279-6.7812 4.3268-12.47 10.607-16.098 17.978 15.479 0.41008 27.128 5.5727 34.785 15.902 5.7694 7.7834 6.3441 15.834 5.58 21.623-0.22478 2.4881-1.0451 4.8662-1.4805 7.3127 1.5355 0.93671 5.6306 0.93688 7.1665 0-0.55728-2.4263-1.1272-4.8453-1.4805-7.3127-0.76407-5.7885-0.18935-13.84 5.58-21.623 7.6614-10.336 19.319-15.5 34.812-15.904-3.6285-7.3698-9.3054-13.557-16.052-17.87-7.7665-4.9644-16.626-8.1339-26.429-8.1339z"/>
<path d="m190.22 98.064c-24.54 9.3131-40.338 18.775-49.075 24.879 7.1493 5.5115 12.974 12.668 16.911 20.891 12.415-9.4444 26.363-34.709 32.164-45.77z"/>
<path d="m22.162 98.05c7.7439 14.767 20.662 37.041 32.193 45.799 3.9367-8.23 9.7635-15.392 16.917-20.907-8.7414-6.1066-24.551-15.575-49.111-24.893z"/></g><path
d="m165.29 167.8c20.288-15.729 58.433-49.439 58.433-80.549-7.0704 1.9242-13.674 3.925-19.83 5.9635-6.54 13.411-25.14 49.274-42.158 60.585 1.1931 4.4859 1.9164 9.1612 1.9834 14"
fill="#4f85c2"/><path
d="m163.71 167.8c0.0869 7.1007-1.1747 18.463-8.196 30.211 1.8946 0.85694 5.1546 1.8566 11.496 1.8516 9.5462-8e-3 17.335-3.8489 19.149-4.8188-9.0279-12.291-15.805-21.03-20.877-27.244"
fill="#29191a"/><path
d="m48.708 167.8c0.06664-4.8318 0.78533-9.5006 1.9752-13.981-17.491-11.602-36.614-49.192-42.185-60.616-6.1461-2.0346-12.739-4.0315-19.796-5.9521 0 31.11 38.144 64.82 58.432 80.549"
fill="#4f85c2"/><path
d="m47.135 167.8c-5.074 6.2158-11.853 14.958-20.885 27.255 1.8518 0.98731 9.6175 4.8001 19.129 4.808 6.3617 5e-3 9.63-1.0011 11.52-1.8598-7.141-11.953-8.2835-23.483-8.1903-30.203"
fill="#29191a"/></g></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="236.01562mm"
height="148.13016mm"
viewBox="0 0 236.01562 148.13015"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
sodipodi:docname="avali.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showguides="true"
inkscape:zoom="0.5"
inkscape:cx="164"
inkscape:cy="261"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"><sodipodi:guide
position="91.628075,161.11368"
orientation="0,-1"
id="guide4"
inkscape:locked="false" /><sodipodi:guide
position="91.975095,138.23615"
orientation="0,-1"
id="guide5"
inkscape:locked="false" /><sodipodi:guide
position="0.50000074,203.49375"
orientation="0,-1"
id="guide12"
inkscape:locked="false" /><sodipodi:guide
position="22.993504,210.90259"
orientation="-1,0"
id="guide13"
inkscape:locked="false"
inkscape:label=""
inkscape:color="rgb(0,134,229)" /><sodipodi:guide
position="212.99349,242.70766"
orientation="-1,0"
id="guide14"
inkscape:locked="false"
inkscape:label=""
inkscape:color="rgb(0,134,229)" /></sodipodi:namedview><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(11.797294,-86.753747)"><path
style="fill:#968e99;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 186.163,195.04664 5.91126,8.1132 -25.87369,23.73181 -16.66531,-20.55576 5.97948,-8.34111 z"
id="path78"
sodipodi:nodetypes="cccccc" /><path
style="fill:#968e99;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 26.250284,195.05749 -5.911266,8.1132 25.873694,23.73181 16.666345,-20.58213 -5.980515,-8.31474 z"
id="path77" /><path
style="fill:#968e99;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 203.88854,93.217193 -13.66325,4.844666 -32.19339,45.799271 3.67213,9.97252 37.27686,-16.96487 z"
id="path76" /><path
style="fill:#968e99;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 8.4989134,93.205824 13.6632486,4.844665 32.193383,45.799271 -3.672126,9.97252 -37.276861,-16.96487 z"
id="path75" /><path
style="fill:#968e99;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 63.728581,147.62093 -3.547036,9.65644 46.014665,67.38877 46.04225,-67.39291 -3.54711,-9.65437 z"
id="path74" /><path
style="fill:#4f85c2;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 20.339018,203.17069 c -5.551004,7.67626 -11.8329296,16.5156 -18.9394127,26.69449 27.5897217,13.76092 53.7397197,-7.15402 65.5856607,-19.10632 -0.490346,-0.60034 -3.024527,-3.19784 -4.106209,-4.43849 -4.232092,2.39241 -10.049149,3.55113 -17.507976,3.54498 -7.904761,-0.35181 -18.303247,-2.04506 -25.032064,-6.69468 z"
id="path67"
sodipodi:nodetypes="scccccs" /><path
style="fill:#4f85c2;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 192.0722,203.15725 c -7.42386,4.64877 -16.44936,6.32276 -25.05066,6.70812 -7.49273,0.006 -13.30657,-1.1725 -17.48628,-3.52948 -2.47131,2.83288 -2.90958,2.91097 -4.1212,4.40128 11.83592,11.94876 38.00109,32.89721 65.60737,19.12803 -7.11066,-10.18486 -13.39576,-19.02853 -18.94924,-26.70793 z"
id="path66"
sodipodi:nodetypes="sscccss" /><path
style="fill:#4f85c2;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 152.23846,157.27323 c -13.61237,1.35951 -21.05605,0.76683 -30.32631,12.20546 -4.10876,5.54304 -4.25862,10.11968 -3.699,14.35933 0.45057,4.35831 3.11017,9.09905 0.87695,13.34337 -2.18406,3.7829 -5.90669,5.05988 -8.49819,5.56452 -2.42092,0.47143 -6.33897,0.47752 -8.7912,0 -2.591495,-0.50464 -6.314128,-1.78162 -8.498187,-5.56452 -2.124547,-4.20509 0.256478,-9.03293 0.876949,-13.34337 0.559622,-4.23965 0.409755,-8.81629 -3.698999,-14.35933 -8.93488,-11.92025 -17.401686,-10.28815 -30.298926,-12.20132 -0.881339,3.62533 -1.478432,7.35986 -1.478463,11.26184 -3.8e-5,13.04221 5.357287,25.50569 14.822351,34.47852 6.539201,7.16852 12.466617,15.90057 21.358387,20.36258 3.432644,1.65018 18.927508,1.77799 22.626008,0 8.60782,-4.13814 20.37587,-19.41036 21.27519,-20.25923 9.53191,-8.97878 14.9341,-21.48697 14.93397,-34.58187 -3e-5,-3.90348 -0.59853,-7.63938 -1.48053,-11.26598 z"
id="path65"
sodipodi:nodetypes="csssssssscccsscsc" /><path
style="fill:#29191a;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 106.21026,121.61546 c -9.767937,8e-5 -18.633933,3.08331 -26.3834,8.02793 -6.78116,4.32678 -12.469879,10.60703 -16.098277,17.97754 15.479213,0.41008 27.128138,5.57271 34.784957,15.90239 5.76937,7.78335 6.34409,15.83448 5.58003,21.62297 -0.22478,2.48809 -1.04514,4.86621 -1.48054,7.31273 1.53551,0.93671 5.63064,0.93688 7.16648,0 -0.55728,-2.42629 -1.12715,-4.84531 -1.48053,-7.31273 -0.76407,-5.78849 -0.18935,-13.83962 5.58002,-21.62297 7.66138,-10.33584 19.31928,-15.49987 34.81235,-15.90446 -3.62846,-7.36979 -9.30538,-13.55739 -16.05174,-17.86971 -7.7665,-4.96439 -16.62622,-8.1339 -26.4294,-8.13387 z"
id="path64"
sodipodi:nodetypes="caccssscssacc" /><path
style="fill:#29191a;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 190.22322,98.063925 c -24.53952,9.313115 -40.33776,18.775385 -49.07504,24.879105 7.14927,5.51153 12.97385,12.66765 16.9106,20.89123 12.41453,-9.44437 26.36344,-34.70935 32.16444,-45.770335 z"
id="path63"
sodipodi:nodetypes="cccc" /><path
style="fill:#29191a;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 22.162162,98.050489 c 7.743943,14.767141 20.661921,37.040771 32.193384,45.799271 3.936731,-8.23004 9.763525,-15.39177 16.917314,-20.90673 -8.74141,-6.10665 -24.550557,-15.57494 -49.110698,-24.892541 z"
id="path62"
sodipodi:nodetypes="cccc" /><path
id="path61"
style="fill:#4f85c2;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 165.28573,167.80282 v 0 c 20.28784,-15.72918 58.4326,-49.43941 58.4326,-80.549088 v 0 c -7.07035,1.924173 -13.674,3.925012 -19.82979,5.963461 -6.53997,13.410897 -25.13956,49.274067 -42.15815,60.585447 1.19314,4.48589 1.91642,9.16123 1.98341,14.00018"
sodipodi:nodetypes="csssccc" /><path
id="path73"
style="fill:#29191a;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 163.7138,167.80282 c 0.0869,7.10074 -1.17473,18.46252 -8.19605,30.21108 1.89459,0.85694 5.15458,1.85656 11.49594,1.85156 9.54622,-0.008 17.33453,-3.84892 19.14921,-4.81882 -9.02792,-12.29058 -15.8048,-21.02979 -20.87727,-27.24382"
sodipodi:nodetypes="ccccc" /><path
id="path2"
style="fill:#4f85c2;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 48.708262,167.80282 c 0.06664,-4.83184 0.785332,-9.50063 1.975157,-13.98054 C 33.192531,142.22039 14.069501,104.63007 8.4989136,93.205824 2.3527811,91.171256 -4.2399168,89.174373 -11.297294,87.253732 v 0 c 0,31.109548 38.144181,64.819828 58.432092,80.549088"
sodipodi:nodetypes="cccssc" /><path
id="path71"
style="fill:#29191a;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 47.134798,167.80282 v 0 c -5.073961,6.21585 -11.852773,14.95841 -20.884513,27.25467 1.8518,0.98731 9.617497,4.80014 19.128548,4.80797 6.361731,0.005 9.629998,-1.00112 11.51971,-1.85983 -7.140975,-11.95305 -8.283505,-23.48271 -8.190265,-30.20281"
sodipodi:nodetypes="cscccc" /></g></svg>

After

Width:  |  Height:  |  Size: 9.0 KiB

+15
View File
@@ -10,6 +10,7 @@
let { preset }: PresetViewProps = $props();
const sortedTagsList = $derived(preset.settings.tags.toSorted((a, b) => a.localeCompare(b)));
const requiredTagsList = $derived(preset.settings.requiredTags.toSorted((a, b) => a.localeCompare(b)));
</script>
<DetailsBlock title="Preset Name">
@@ -18,3 +19,17 @@
<DetailsBlock title="Tags">
<TagsList tags={sortedTagsList}></TagsList>
</DetailsBlock>
{#if preset.settings.exclusive}
<DetailsBlock title="Exclusivity">
Only one tag in this preset should be active at a time. If you will click on other non-active tag, other tags will
be automatically removed from the editor.
</DetailsBlock>
{/if}
{#if preset.settings.conditional}
<DetailsBlock title="Conditional">
This preset will only appear when one of the tags below are present on image.
</DetailsBlock>
<DetailsBlock title="Conditional Tags">
<TagsList tags={requiredTagsList}></TagsList>
</DetailsBlock>
{/if}
+19 -2
View File
@@ -3,14 +3,16 @@
</script>
<footer>
<a href="https://github.com/koloml/furbooru-tagging-assistant/releases/tag/{version}" target="_blank">
<a href="https://github.com/koloml/philomena-tagging-assistant/releases/tag/{version}" target="_blank">
v{version}
</a>
<span>, made with ♥ by KoloMl.</span>
<span>, made with ♥ by
{#if __CURRENT_SITE__ === 'furbooru'}<i class="head"></i>{/if} KoloMl.</span>
</footer>
<style lang="scss">
@use '$styles/colors';
@use '$styles/environment';
footer {
display: flex;
@@ -29,4 +31,19 @@
}
}
}
@if environment.$current-site == 'furbooru' {
.head {
content: '';
height: 24px;
width: 32px;
display: inline-block;
vertical-align: middle;
background: {
image: url(/src/assets/heads/avali-optimized.svg);
size: contain;
repeat: no-repeat;
};
}
}
</style>
+45 -27
View File
@@ -1,18 +1,24 @@
/**
* Enum-like object with categories used by the Philomena, available for calling in the code. Values are technical
* names of these categories.
*/
export const CategoriesEnum = {
Rating: 'rating',
Spoiler: 'spoiler',
Origin: 'origin',
OC: 'oc',
Error: 'error',
Character: 'character',
ContentOfficial: 'content-official',
ContentFanmade: 'content-fanmade',
Species: 'species',
BodyType: 'body-type',
}
/**
* List of categories defined by the sites.
*/
export const categories: string[] = [
'rating',
'spoiler',
'origin',
'oc',
'error',
'character',
'content-official',
'content-fanmade',
'species',
'body-type',
];
export const categories: string[] = Object.values(CategoriesEnum);
/**
* Mapping of namespaces to their respective categories. These namespaces are automatically assigned to them, so we can
@@ -23,21 +29,21 @@ export const categories: string[] = [
* @see https://github.com/philomena-dev/philomena/blob/6086757b654da8792ae52adb2a2f501ea6c30d12/lib/philomena/tags/tag.ex#L33-L45
*/
export const namespaceCategories: Map<string, string> = new Map([
['artist', 'origin'],
['art pack', 'content-fanmade'],
['colorist', 'origin'],
['comic', 'content-fanmade'],
['editor', 'origin'],
['fanfic', 'content-fanmade'],
['oc', 'oc'],
['photographer', 'origin'],
['series', 'content-fanmade'],
['spoiler', 'spoiler'],
['video', 'content-fanmade'],
...(__CURRENT_SITE__ === 'tantabus' ? <const> [
["prompter", "origin"],
["creator", "origin"],
["generator", "origin"]
['artist', CategoriesEnum.Origin],
['art pack', CategoriesEnum.ContentFanmade],
['colorist', CategoriesEnum.Origin],
['comic', CategoriesEnum.ContentFanmade],
['editor', CategoriesEnum.Origin],
['fanfic', CategoriesEnum.ContentFanmade],
['oc', CategoriesEnum.OC],
['photographer', CategoriesEnum.Origin],
['series', CategoriesEnum.ContentFanmade],
['spoiler', CategoriesEnum.Spoiler],
['video', CategoriesEnum.ContentFanmade],
...(__CURRENT_SITE__ === 'tantabus' ? <const>[
["prompter", CategoriesEnum.Origin],
["creator", CategoriesEnum.Origin],
["generator", CategoriesEnum.Origin]
] : [])
]);
@@ -128,3 +134,15 @@ export const tagsBlacklist: string[] = (__CURRENT_SITE__ === 'furbooru' ? [
"solo oc",
"tag your shit"
]);
/**
* Core rating tags used in the Philomena. These rarely change, so they're pretty safe to hardcode into the source code.
*/
export const ratingTags: string[] = [
'safe',
'suggestive',
'questionable',
'explicit',
'grimdark',
'grotesque',
];
+5 -3
View File
@@ -1,18 +1,20 @@
import type { MaintenancePopupEventsMap } from "$content/components/events/maintenance-popup-events";
import type { TaggingProfilePopupEventsMap } from "$content/components/events/tagging-profile-popup-events";
import { BaseComponent } from "$content/components/base/BaseComponent";
import type { FullscreenViewerEventsMap } from "$content/components/events/fullscreen-viewer-events";
import type { BooruEventsMap } from "$content/components/events/booru-events";
import type { TagsFormEventsMap } from "$content/components/events/tags-form-events";
import type { TagDropdownEvents } from "$content/components/events/tag-dropdown-events";
import type { PresetBlockEventsMap } from "$content/components/events/preset-block-events";
import type { TagsListTogglerEventsMap } from "$content/components/events/tags-list-toggler-events";
type EventsMapping =
MaintenancePopupEventsMap
TaggingProfilePopupEventsMap
& FullscreenViewerEventsMap
& BooruEventsMap
& TagsFormEventsMap
& TagDropdownEvents
& PresetBlockEventsMap;
& PresetBlockEventsMap
& TagsListTogglerEventsMap;
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
export type UnsubscribeFunction = () => void;
@@ -1,13 +1,13 @@
import type TaggingProfile from "$entities/TaggingProfile";
export const EVENT_ACTIVE_PROFILE_CHANGED = 'active-profile-changed';
export const EVENT_MAINTENANCE_STATE_CHANGED = 'maintenance-state-change';
export const EVENT_PROFILE_POPUP_STATE_CHANGED = 'maintenance-state-change';
export const EVENT_TAGS_UPDATED = 'tags-updated';
type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting';
export type ProfilePopupState = 'ready' | 'processing' | 'failed' | 'complete' | 'waiting';
export interface MaintenancePopupEventsMap {
export interface TaggingProfilePopupEventsMap {
[EVENT_ACTIVE_PROFILE_CHANGED]: TaggingProfile | null;
[EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState;
[EVENT_PROFILE_POPUP_STATE_CHANGED]: ProfilePopupState;
[EVENT_TAGS_UPDATED]: Map<string, string> | null;
}
@@ -0,0 +1,5 @@
export const EVENT_TAG_TOGGLED = 'tag-toggled';
export interface TagsListTogglerEventsMap {
[EVENT_TAG_TOGGLED]: [string, boolean];
}
@@ -1,28 +1,62 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import MiscPreferences, { type FullscreenViewerSize } from "$lib/extension/preferences/MiscPreferences";
import { emit, on } from "$content/components/events/comms";
import { emit, on, type UnsubscribeFunction } from "$content/components/events/comms";
import { EVENT_SIZE_LOADED } from "$content/components/events/fullscreen-viewer-events";
import type { MediaBox } from "$content/components/philomena/MediaBox";
import TagsListToggler from "$content/components/extension/profiles/TagsListToggler";
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/tagging-profile-popup-events";
import type TaggingProfile from "$entities/TaggingProfile";
import { EVENT_TAG_TOGGLED } from "$content/components/events/tags-list-toggler-events";
import type { TaggingProfilePopup } from "$content/components/extension/profiles/TaggingProfilePopup";
export class FullscreenViewer extends BaseComponent {
#currentMediaBox: MediaBox | null = null;
#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');
#tagsPopupElement: HTMLElement = document.createElement('div');
#imageTagsToggler: TagsListToggler = TagsListToggler.create();
#profileTagsToggler: TagsListToggler = TagsListToggler.create();
#tagsPopupHider: HTMLElement = document.createElement('div');
#activeProfileListenerUnsubscriber: UnsubscribeFunction | null = null;
#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.#imageTagsToggler.initialize();
this.#profileTagsToggler.initialize();
const overflowContainer = document.createElement('div');
overflowContainer.classList.add('tags-panel__scroller');
overflowContainer.append(
this.#profileTagsToggler.container,
document.createElement('hr'),
this.#imageTagsToggler.container,
);
this.#tagsPopupElement.append(
this.#tagsPopupHider,
overflowContainer,
);
this.#tagsPopupHider.classList.add('tags-panel__toggle');
this.#tagsPopupHider.textContent = 'Toggle Tags';
this.#tagsPopupElement.classList.add('tags-panel');
this.container.append(
this.#spinnerElement,
this.#sizeSelectorElement,
this.#closeButtonElement,
this.#tagsPopupElement,
);
this.#spinnerElement.classList.add('spinner', 'fa', 'fa-circle-notch', 'fa-spin');
@@ -52,11 +86,20 @@ export class FullscreenViewer extends BaseComponent {
this.#videoElement.addEventListener('loadeddata', this.#onLoaded.bind(this));
this.#imageElement.addEventListener('load', this.#onLoaded.bind(this));
this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation());
this.#tagsPopupElement.addEventListener('click', event => event.stopPropagation());
this.#tagsPopupHider.addEventListener('click', () => this.#togglePopupVisibility());
FullscreenViewer.#preferences
.fullscreenViewerSize.get()
.then(this.#onSizeResolved.bind(this))
.then(this.#watchForSizeSelectionChanges.bind(this));
FullscreenViewer.#preferences
.fullscreenViewerPopupShown.get()
.then(this.#togglePopupVisibility.bind(this));
on(this.#profileTagsToggler, EVENT_TAG_TOGGLED, this.#onTagToggled.bind(this));
on(this.#imageTagsToggler, EVENT_TAG_TOGGLED, this.#onTagToggled.bind(this));
}
#onLoaded() {
@@ -193,8 +236,8 @@ export class FullscreenViewer extends BaseComponent {
this.#sizeSelectorElement.addEventListener('input', () => {
const targetSize = this.#sizeSelectorElement.value;
if (this.#currentURIs) {
void this.show(this.#currentURIs);
if (this.#currentMediaBox) {
void this.show(this.#currentMediaBox);
}
if (!targetSize || targetSize === lastActiveSize || !(targetSize in FullscreenViewer.#previewSizes)) {
@@ -206,8 +249,55 @@ export class FullscreenViewer extends BaseComponent {
});
}
#onTagToggled(event: CustomEvent<[string, boolean]>) {
const sourceTogglerContainer = event.currentTarget;
if (!(sourceTogglerContainer instanceof HTMLElement)) {
return;
}
if (sourceTogglerContainer !== this.#imageTagsToggler.container) {
this.#imageTagsToggler.refreshStateFromChanges();
}
if (sourceTogglerContainer !== this.#profileTagsToggler.container) {
this.#profileTagsToggler.refreshStateFromChanges();
}
this.#currentMediaBox?.tools?.popup?.tagsToggler.refreshStateFromChanges();
}
#onActiveProfileChanged(eventOrTaggingProfile: CustomEvent<TaggingProfile | null> | TaggingProfile | null) {
let resolvedActiveTaggingProfile: TaggingProfile | null = null;
if (eventOrTaggingProfile instanceof CustomEvent) {
resolvedActiveTaggingProfile = eventOrTaggingProfile.detail;
} else {
resolvedActiveTaggingProfile = eventOrTaggingProfile;
}
this.#profileTagsToggler.container.classList.toggle('is-hidden', !resolvedActiveTaggingProfile);
if (!resolvedActiveTaggingProfile) {
return;
}
const uniqueTagsSet = this.#currentMediaBox?.uniqueTagsSet;
this.#profileTagsToggler.replaceTags(resolvedActiveTaggingProfile.settings.tags, uniqueTagsSet ?? new Set());
}
#close() {
this.#currentURIs = null;
const taggingPopup = this.#currentMediaBox?.tools?.popup;
if (taggingPopup) {
this.#activeProfileListenerUnsubscriber?.();
taggingPopup.tagsToggler.disconnectChangeSet();
taggingPopup.toggleSubmissionLock(false);
}
this.#currentMediaBox = null;
this.container.classList.remove(FullscreenViewer.#shownState);
document.body.style.removeProperty('overflow');
@@ -247,16 +337,23 @@ export class FullscreenViewer extends BaseComponent {
return imageUris[targetSize as FullscreenViewerSize];
}
async show(imageUris: App.ImageURIs): Promise<void> {
this.#currentURIs = imageUris;
async show(mediaBox: MediaBox): Promise<void> {
const isSameMediaBox = this.#currentMediaBox === mediaBox;
this.#currentMediaBox = mediaBox;
const url = await this.#resolveCurrentSelectedSizeUrl(imageUris);
const url = await this.#resolveCurrentSelectedSizeUrl(mediaBox.imageLinks);
if (!url) {
console.warn('Failed to resolve media for the viewer!');
return;
}
const taggingPopup = mediaBox?.tools?.popup;
if (taggingPopup && !isSameMediaBox) {
this.#connectAndRefreshTagTogglers(taggingPopup, mediaBox);
}
this.container.classList.add('loading');
requestAnimationFrame(() => {
@@ -285,6 +382,45 @@ export class FullscreenViewer extends BaseComponent {
this.container.append(this.#imageElement);
}
/**
* When connecting to the new popup, we need to connect the tag editor inside the fullscreen viewer to the original
* popup, since submission is still being sent from the popup inside media box.
* @param taggingPopup
* @param mediaBox
* @private
*/
#connectAndRefreshTagTogglers(taggingPopup: TaggingProfilePopup, mediaBox: MediaBox) {
taggingPopup.toggleSubmissionLock(true);
const sourceToggler = taggingPopup.tagsToggler;
sourceToggler.connectChangeSet(this.#profileTagsToggler);
sourceToggler.connectChangeSet(this.#imageTagsToggler);
const uniqueTagsSet = mediaBox.uniqueTagsSet ?? new Set();
const uniqueTags = Array.from(uniqueTagsSet);
this.#imageTagsToggler.replaceTags(uniqueTags, uniqueTagsSet);
this.#activeProfileListenerUnsubscriber?.();
this.#activeProfileListenerUnsubscriber = on(
taggingPopup,
EVENT_ACTIVE_PROFILE_CHANGED,
this.#onActiveProfileChanged.bind(this),
);
this.#onActiveProfileChanged(taggingPopup.activeProfile);
}
#togglePopupVisibility(maybeState: boolean | undefined = undefined) {
if (typeof maybeState === 'undefined') {
const isHidden = this.#tagsPopupElement.classList.toggle(FullscreenViewer.#collapsedPopupState);
void FullscreenViewer.#preferences.fullscreenViewerPopupShown.set(!isHidden);
return;
}
this.#tagsPopupElement.classList.toggle(FullscreenViewer.#collapsedPopupState, !maybeState);
}
static #isVideoUrl(url: string): boolean {
return url.endsWith('.mp4') || url.endsWith('.webm');
}
@@ -296,6 +432,7 @@ export class FullscreenViewer extends BaseComponent {
static #shownState = 'shown';
static #swipeState = 'swiped';
static #minRequiredDistance = 50;
static #collapsedPopupState = 'is-collapsed';
static #previewSizes: Record<FullscreenViewerSize, string> = {
full: 'Full',
@@ -45,15 +45,13 @@ export class ImageShowFullscreenButton extends BaseComponent {
}
#onButtonClicked() {
const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks;
const mediaBox = this.#mediaBoxTools?.mediaBox;
if (!imageLinks) {
if (!mediaBox) {
throw new Error('Failed to resolve image links from media box tools!');
}
ImageShowFullscreenButton
.#resolveViewer()
?.show(imageLinks);
ImageShowFullscreenButton.#resolveViewer()?.show(mediaBox);
}
static create(): HTMLElement {
@@ -2,13 +2,13 @@ import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { TaggingProfilePopup } from "$content/components/extension/profiles/TaggingProfilePopup";
import { on } from "$content/components/events/comms";
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/maintenance-popup-events";
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/tagging-profile-popup-events";
import type { MediaBox } from "$content/components/philomena/MediaBox";
import type TaggingProfile from "$entities/TaggingProfile";
export class MediaBoxTools extends BaseComponent {
#mediaBox: MediaBox | null = null;
#maintenancePopup: TaggingProfilePopup | null = null;
#popup: TaggingProfilePopup | null = null;
init() {
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
@@ -34,8 +34,8 @@ export class MediaBoxTools extends BaseComponent {
component.initialize();
}
if (!this.#maintenancePopup && component instanceof TaggingProfilePopup) {
this.#maintenancePopup = component;
if (!this.#popup && component instanceof TaggingProfilePopup) {
this.#popup = component;
}
}
@@ -46,14 +46,14 @@ export class MediaBoxTools extends BaseComponent {
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
}
get maintenancePopup(): TaggingProfilePopup | null {
return this.#maintenancePopup;
}
get mediaBox(): MediaBox | null {
return this.#mediaBox;
}
get popup(): TaggingProfilePopup | null {
return this.#popup;
}
/**
* Create a maintenance popup element.
* @param childrenElements List of children elements to append to the component.
@@ -5,10 +5,13 @@ import { EVENT_PRESET_TAG_CHANGE_APPLIED } from "$content/components/events/pres
import { createFontAwesomeIcon } from "$lib/dom-utils";
export default class PresetTableRow extends BaseComponent {
#preset: TagEditorPreset;
readonly #preset: TagEditorPreset;
readonly #applyAllButton = document.createElement('button');
readonly #removeAllButton = document.createElement('button');
readonly #exclusiveWarning = document.createElement('div');
readonly #alternateColorDummy = document.createElement('span');
#tagsList: HTMLElement[] = [];
#applyAllButton = document.createElement('button');
#removeAllButton = document.createElement('button');
constructor(container: HTMLElement, preset: TagEditorPreset) {
super(container);
@@ -32,6 +35,7 @@ export default class PresetTableRow extends BaseComponent {
nameCell.textContent = this.#preset.settings.name;
const tagsCell = document.createElement('td');
tagsCell.style.width = '70%';
const tagsListContainer = document.createElement('div');
tagsListContainer.classList.add('tag-list');
@@ -52,6 +56,18 @@ export default class PresetTableRow extends BaseComponent {
this.#removeAllButton.append(createFontAwesomeIcon('circle-minus'));
this.#removeAllButton.title = 'Remove all tags from this preset from the editor';
if (this.#preset.settings.exclusive) {
this.#applyAllButton.disabled = true;
this.#applyAllButton.title = "You can't add all tags from this preset since it only allows one tag to be active";
this.#exclusiveWarning.classList.add('block', 'block--fixed', 'block--warning');
this.#exclusiveWarning.textContent = ' Multiple tags from this preset present in the editor! If you will click one of the tags here, other tags will be cleared automatically.'
this.#exclusiveWarning.prepend(createFontAwesomeIcon('triangle-exclamation'));
this.#exclusiveWarning.style.display = 'none';
tagsCell.append(this.#exclusiveWarning);
}
actionsContainer.append(
this.#applyAllButton,
this.#removeAllButton,
@@ -64,6 +80,8 @@ export default class PresetTableRow extends BaseComponent {
tagsCell,
actionsCell,
);
this.#alternateColorDummy.style.display = 'none';
}
protected init() {
@@ -85,6 +103,30 @@ export default class PresetTableRow extends BaseComponent {
const tagName = targetElement.dataset.tagName;
const isMissing = targetElement.classList.contains(PresetTableRow.#tagMissingClassName);
if (!tagName) {
return;
}
// If a user clicks on the tag which was missing, then we have to remove all other active tags that are in this
// preset. But only when clicking on a tag which is missing, just so they will be able to remove any cases where
// multiple tags from exclusive present are active.
if (this.#preset.settings.exclusive && isMissing) {
const tagNamesToRemove = this.#tagsList
.filter(
tagElement => tagElement !== targetElement
&& !tagElement.classList.contains(PresetTableRow.#tagMissingClassName)
)
.map(tagElement => tagElement.dataset.tagName)
.filter(tagName => typeof tagName === 'string');
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
addedTags: new Set([tagName]),
removedTags: new Set(tagNamesToRemove)
});
return;
}
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
[isMissing ? 'addedTags' : 'removedTags']: new Set([tagName])
});
@@ -108,13 +150,53 @@ export default class PresetTableRow extends BaseComponent {
});
}
#maybeRefreshVisibilityFromTags(sourceTags: Set<string>) {
if (!this.#preset.settings.conditional || this.#isMatchesConditional(sourceTags)) {
this.container.style.display = '';
this.#alternateColorDummy.remove();
return;
}
this.container.style.display = 'none';
this.container.after(this.#alternateColorDummy);
}
#isMatchesConditional(sourceTags: Set<string>): boolean {
const listOfRequiredTags = this.#preset.settings.requiredTags;
return Boolean(
listOfRequiredTags.length
&& listOfRequiredTags.some(tagName => sourceTags.has(tagName))
);
}
updateTags(tags: Set<string>) {
let presentTagsAmount = 0;
for (const tagElement of this.#tagsList) {
tagElement.classList.toggle(
const isTagMissing = tagElement.classList.toggle(
PresetTableRow.#tagMissingClassName,
!tags.has(tagElement.dataset.tagName || ''),
);
if (!isTagMissing) {
presentTagsAmount++;
}
}
if (this.#preset.settings.exclusive) {
const multipleTagsInExclusivePreset = presentTagsAmount > 1;
this.container.classList.toggle(PresetTableRow.#presetWarningClassName, multipleTagsInExclusivePreset);
if (multipleTagsInExclusivePreset) {
this.#exclusiveWarning.style.removeProperty('display');
} else {
this.#exclusiveWarning.style.display = 'none';
}
}
this.#maybeRefreshVisibilityFromTags(tags);
}
remove() {
@@ -126,4 +208,5 @@ export default class PresetTableRow extends BaseComponent {
}
static #tagMissingClassName = 'is-missing';
static #presetWarningClassName = 'has-warning';
}
@@ -4,31 +4,22 @@ import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import ScrapedAPI from "$lib/philomena/scraping/ScrapedAPI";
import { tagsBlacklist } from "$config/tags";
import { emitterAt } from "$content/components/events/comms";
import { emitterAt, on } from "$content/components/events/comms";
import {
EVENT_ACTIVE_PROFILE_CHANGED,
EVENT_MAINTENANCE_STATE_CHANGED,
EVENT_PROFILE_POPUP_STATE_CHANGED,
EVENT_TAGS_UPDATED
} from "$content/components/events/maintenance-popup-events";
} from "$content/components/events/tagging-profile-popup-events";
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
import { resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils";
import TagsListToggler, { BlackListedTagsEncounteredError } from "$content/components/extension/profiles/TagsListToggler";
import { EVENT_TAG_TOGGLED } from "$content/components/events/tags-list-toggler-events";
class BlackListedTagsEncounteredError extends Error {
constructor(tagName: string) {
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
cause: tagName
});
}
}
export class TaggingProfilePopup extends BaseComponent {
#tagsListElement: HTMLElement = document.createElement('div');
#tagsList: HTMLElement[] = [];
#suggestedInvalidTags: Map<string, HTMLElement> = new Map();
#tagsListToggler = TagsListToggler.create();
#activeProfile: TaggingProfile | null = null;
#mediaBoxTools: MediaBoxTools | null = null;
#tagsToRemove: Set<string> = new Set();
#tagsToAdd: Set<string> = new Set();
#isSubmissionLocked: boolean = false;
#isPlanningToSubmit: boolean = false;
#isSubmitting: boolean = false;
#tagsSubmissionTimer: Timeout | null = null;
@@ -41,11 +32,8 @@ export class TaggingProfilePopup extends BaseComponent {
this.container.innerHTML = '';
this.container.classList.add('maintenance-popup');
this.#tagsListElement.classList.add('tags-list');
this.container.append(
this.#tagsListElement,
);
this.#tagsListToggler.initialize();
this.container.append(this.#tagsListToggler.container);
}
/**
@@ -67,7 +55,7 @@ export class TaggingProfilePopup extends BaseComponent {
this.#mediaBoxTools = mediaBoxTools;
TaggingProfilePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
this.#tagsListElement.addEventListener('click', this.#handleTagClick.bind(this));
on(this.#tagsListToggler, EVENT_TAG_TOGGLED, this.#onTagToggled.bind(this));
const mediaBox = this.#mediaBoxTools.mediaBox;
@@ -75,126 +63,89 @@ export class TaggingProfilePopup extends BaseComponent {
throw new Error('Media box component not found!');
}
mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this));
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
mediaBox.on('mouseout', this.#maybeEnqueueSubmission.bind(this));
mediaBox.on('mouseover', this.#cancelQueuedSubmission.bind(this));
}
get tagsToggler() {
return this.#tagsListToggler;
}
get activeProfile() {
return this.#activeProfile;
}
toggleSubmissionLock(shouldLockSubmission: boolean) {
this.#isSubmissionLocked = shouldLockSubmission;
if (shouldLockSubmission) {
this.#cancelQueuedSubmission();
return;
}
// First notify that tags might have changed after submission lock was released and then enqueue it.
this.#onTagToggled();
this.#maybeEnqueueSubmission();
}
#onActiveProfileChanged(activeProfile: TaggingProfile | null) {
this.#activeProfile = activeProfile;
this.container.classList.toggle('is-active', activeProfile !== null);
this.#refreshTagsList();
this.#refreshTagsToggler();
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
}
#refreshTagsList() {
#refreshTagsToggler() {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || [];
const currentPostTags = new Set(this.#mediaBoxTools.mediaBox.tagsAndAliases?.keys() || []);
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 = TaggingProfilePopup.#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)) {
TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error');
this.#suggestedInvalidTags.set(tagName, tagElement);
} else {
TaggingProfilePopup.#markTagElementWithCategory(
tagElement,
resolveTagCategoryFromTagName(tagName) ?? '',
);
}
});
this.#tagsListToggler.replaceTags(activeProfileTagsList, currentPostTags);
}
/**
* Detect and process clicks made directly to the tags.
*/
#handleTagClick(event: MouseEvent) {
const targetObject = event.target;
#onTagToggled() {
const hasPendingChanges = this.#tagsListToggler.hasChanges;
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) {
if (hasPendingChanges) {
// Notify only once, when first planning to submit
if (!this.#isPlanningToSubmit) {
TaggingProfilePopup.#notifyAboutPendingSubmission(true);
}
this.#isPlanningToSubmit = true;
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting');
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'waiting');
}
// Whenever user undoes the change they wanted to do in the popup, it's better to not send the submission and just
// do nothing.
if (!hasPendingChanges && this.#isPlanningToSubmit) {
this.#isPlanningToSubmit = false;
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'ready');
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
// Probably shouldn't ever happen, but make sure we cancel any delayed submission.
if (this.#tagsSubmissionTimer) {
clearTimeout(this.#tagsSubmissionTimer);
}
}
}
#onMouseEnteredArea() {
#cancelQueuedSubmission() {
if (this.#tagsSubmissionTimer) {
clearTimeout(this.#tagsSubmissionTimer);
}
}
#onMouseLeftArea() {
if (this.#isPlanningToSubmit && !this.#isSubmitting) {
#maybeEnqueueSubmission() {
if (this.#isPlanningToSubmit && !this.#isSubmitting && !this.#isSubmissionLocked) {
this.#tagsSubmissionTimer = setTimeout(
this.#onSubmissionTimerPassed.bind(this),
TaggingProfilePopup.#delayBeforeSubmissionMs
@@ -203,14 +154,14 @@ export class TaggingProfilePopup extends BaseComponent {
}
async #onSubmissionTimerPassed() {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox || this.#isSubmissionLocked) {
return;
}
this.#isPlanningToSubmit = false;
this.#isSubmitting = true;
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing');
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'processing');
let maybeTagsAndAliasesAfterUpdate;
@@ -219,29 +170,7 @@ export class TaggingProfilePopup extends BaseComponent {
try {
maybeTagsAndAliasesAfterUpdate = await TaggingProfilePopup.#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;
}
this.#tagsListToggler.createTagsUpdater(shouldAutoRemove),
);
} catch (e) {
if (e instanceof BlackListedTagsEncounteredError) {
@@ -252,7 +181,7 @@ export class TaggingProfilePopup extends BaseComponent {
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'failed');
this.#isSubmitting = false;
return;
@@ -262,12 +191,10 @@ export class TaggingProfilePopup extends BaseComponent {
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
}
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete');
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'complete');
this.#tagsToAdd.clear();
this.#tagsToRemove.clear();
this.#refreshTagsList();
this.#tagsListToggler.clearChangeSet();
this.#refreshTagsToggler();
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
this.#isSubmitting = false;
@@ -278,37 +205,13 @@ export class TaggingProfilePopup extends BaseComponent {
return;
}
const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases;
const activeTags = new Set(this.#mediaBoxTools.mediaBox.tagsAndAliases?.keys() || []);
if (!tagsAndAliases) {
if (!activeTags.size) {
return;
}
const firstTagInList = this.#tagsList[0];
for (let tagName of tagsBlacklist) {
if (tagsAndAliases.has(tagName)) {
if (this.#suggestedInvalidTags.has(tagName)) {
continue;
}
const tagElement = TaggingProfilePopup.#buildTagElement(tagName);
TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error');
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');
this.#tagsListToggler.prependTags(tagsBlacklist, activeTags);
}
static create(): HTMLElement {
@@ -319,24 +222,6 @@ export class TaggingProfilePopup extends BaseComponent {
return container;
}
static #buildTagElement(tagName: string): HTMLElement {
const tagElement = document.createElement('span');
tagElement.classList.add('tag');
tagElement.innerText = tagName;
tagElement.dataset.name = tagName;
return tagElement;
}
/**
* Mark the tag element with specified category.
* @param tagElement Element to mark.
* @param category Code name of category to mark.
*/
static #markTagElementWithCategory(tagElement: HTMLElement, category: string) {
tagElement.dataset.tagCategory = category;
tagElement.setAttribute('data-tag-category', category);
}
/**
* Controller with maintenance settings.
@@ -360,7 +245,7 @@ export class TaggingProfilePopup extends BaseComponent {
}
});
const unsubscribeFromMaintenanceSettings = this.#preferences.subscribe(settings => {
const unsubscribeFromPreferences = this.#preferences.subscribe(settings => {
if (settings.activeProfile === lastActiveProfileId) {
return;
}
@@ -382,7 +267,7 @@ export class TaggingProfilePopup extends BaseComponent {
return () => {
unsubscribeFromProfilesChanges();
unsubscribeFromMaintenanceSettings();
unsubscribeFromPreferences();
}
}
@@ -420,5 +305,5 @@ export class TaggingProfilePopup extends BaseComponent {
/**
* Amount of pending submissions or NULL if logic was not yet initialized.
*/
static #pendingSubmissionCount: number|null = null;
static #pendingSubmissionCount: number | null = null;
}
@@ -1,7 +1,10 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { on } from "$content/components/events/comms";
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$content/components/events/maintenance-popup-events";
import {
EVENT_PROFILE_POPUP_STATE_CHANGED,
type ProfilePopupState
} from "$content/components/events/tagging-profile-popup-events";
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
export class TaggingProfileStatusIcon extends BaseComponent {
@@ -22,10 +25,10 @@ export class TaggingProfileStatusIcon extends BaseComponent {
throw new Error('Status icon element initialized outside of the media box!');
}
on(this.#mediaBoxTools, EVENT_MAINTENANCE_STATE_CHANGED, this.#onMaintenanceStateChanged.bind(this));
on(this.#mediaBoxTools, EVENT_PROFILE_POPUP_STATE_CHANGED, this.#onPopupStateChanged.bind(this));
}
#onMaintenanceStateChanged(stateChangeEvent: CustomEvent<string>) {
#onPopupStateChanged(stateChangeEvent: CustomEvent<ProfilePopupState>) {
// TODO Replace those with FontAwesome icons later. Those icons can probably be sourced from the website itself.
switch (stateChangeEvent.detail) {
case "ready":
@@ -0,0 +1,230 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { CategoriesEnum, tagsBlacklist } from "$config/tags";
import { resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils";
import { EVENT_TAG_TOGGLED } from "$content/components/events/tags-list-toggler-events";
import { emit } from "$content/components/events/comms";
import type { TagsUpdaterFunction } from "$lib/philomena/scraping/ScrapedAPI";
export default class TagsListToggler extends BaseComponent {
#tagsList: HTMLElement[] = [];
#invalidTagsList: Map<string, HTMLElement> = new Map();
#tagsToAdd: Set<string> = new Set();
#tagsToRemove: Set<string> = new Set();
protected build() {
this.container.classList.add('tags-list');
}
protected init() {
this.container.addEventListener('click', this.#handleTagClick.bind(this));
}
get hasChanges(): boolean {
return (this.#tagsToAdd.size + this.#tagsToRemove.size) > 0;
}
replaceTags(tags: string[], activeTags: Set<string>) {
for (const tagElement of this.#tagsList) {
tagElement.remove();
}
for (const tagElement of this.#invalidTagsList.values()) {
tagElement.remove();
}
this.#tagsList = [];
this.#invalidTagsList = new Map();
this.#renderAndInsertTags(tags, activeTags);
}
prependTags(tags: string[], activeTags: Set<string>) {
this.#renderAndInsertTags(tags, activeTags, true);
}
refreshStateFromChanges() {
for (const tagElement of this.#tagsList) {
const tagName = tagElement.dataset.name;
if (!tagName) {
throw new Error('Missing tag name!');
}
tagElement.classList.toggle(TagsListToggler.#addedClass, this.#tagsToAdd.has(tagName));
tagElement.classList.toggle(TagsListToggler.#removedClass, this.#tagsToRemove.has(tagName));
}
}
#renderAndInsertTags(toggleableTags: string[], activeTags: Set<string>, shouldPrepend: boolean = false) {
toggleableTags.sort((a, b) => a.localeCompare(b));
let lastInserted: HTMLElement|null = null;
const hasStoredChanges = this.hasChanges;
for (let tagIndex = 0; tagIndex < toggleableTags.length; tagIndex++) {
const tagName = toggleableTags[tagIndex];
const tagElement = TagsListToggler.#buildTagElement(tagName);
this.#tagsList.push(tagElement);
const isPresent = activeTags.has(tagName);
tagElement.classList.toggle(TagsListToggler.#missingClass, !isPresent);
tagElement.classList.toggle(TagsListToggler.#containsClass, isPresent);
if (hasStoredChanges) {
tagElement.classList.toggle(TagsListToggler.#addedClass, !isPresent && this.#tagsToAdd.has(tagName));
tagElement.classList.toggle(TagsListToggler.#removedClass, isPresent && this.#tagsToRemove.has(tagName));
}
const tagCategory = tagsBlacklist.includes(tagName) && CategoriesEnum.Error
|| resolveTagCategoryFromTagName(tagName)
|| '';
TagsListToggler.#markTagElementWithCategory(tagElement, tagCategory);
if (tagCategory === CategoriesEnum.Error) {
this.#invalidTagsList.set(tagName, tagElement);
}
if (shouldPrepend) {
if (lastInserted) {
lastInserted.after(tagElement);
} else {
this.container.prepend(tagElement);
}
lastInserted = tagElement;
} else {
this.container.append(tagElement);
}
}
}
createTagsUpdater(shouldAutoRemoveInvalid: boolean): TagsUpdaterFunction {
return tagsList => {
for (let tagName of this.#tagsToRemove) {
tagsList.delete(tagName);
}
for (let tagName of this.#tagsToAdd) {
tagsList.add(tagName);
}
if (shouldAutoRemoveInvalid) {
for (let tagName of tagsBlacklist) {
tagsList.delete(tagName);
}
} else {
for (let tagName of tagsList) {
if (tagsBlacklist.includes(tagName)) {
throw new BlackListedTagsEncounteredError(tagName);
}
}
}
return tagsList;
}
}
connectChangeSet(secondaryToggler: TagsListToggler) {
secondaryToggler.#tagsToAdd = this.#tagsToAdd;
secondaryToggler.#tagsToRemove = this.#tagsToRemove;
}
disconnectChangeSet() {
this.#tagsToAdd = new Set(this.#tagsToAdd.values());
this.#tagsToRemove = new Set(this.#tagsToRemove.values());
}
clearChangeSet() {
this.#tagsToAdd.clear();
this.#tagsToRemove.clear();
}
#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;
let willBeActive = false;
if (tagElement.classList.contains(TagsListToggler.#containsClass)) {
const isToBeRemoved = tagElement.classList.toggle(TagsListToggler.#removedClass);
willBeActive = !isToBeRemoved;
if (isToBeRemoved) {
this.#tagsToRemove.add(tagName);
} else {
this.#tagsToRemove.delete(tagName);
}
}
if (tagElement.classList.contains(TagsListToggler.#missingClass)) {
const isToBeAdded = tagElement.classList.toggle(TagsListToggler.#addedClass);
willBeActive = isToBeAdded;
if (isToBeAdded) {
this.#tagsToAdd.add(tagName);
} else {
this.#tagsToAdd.delete(tagName);
}
}
emit(this.container, EVENT_TAG_TOGGLED, [tagName, willBeActive]);
}
static #buildTagElement(tagName: string): HTMLElement {
const tagElement = document.createElement('span');
tagElement.classList.add('tag');
tagElement.innerText = tagName;
tagElement.dataset.name = tagName;
return tagElement;
}
/**
* Mark the tag element with specified category.
* @param tagElement Element to mark.
* @param category Code name of category to mark.
*/
static #markTagElementWithCategory(tagElement: HTMLElement, category: string) {
tagElement.dataset.tagCategory = category;
tagElement.setAttribute('data-tag-category', category);
}
static create(): TagsListToggler {
const tagsListContainer = document.createElement('div');
return new TagsListToggler(tagsListContainer);
}
static #containsClass = 'is-present';
static #missingClass = 'is-missing';
static #addedClass = 'is-added';
static #removedClass = 'is-removed';
}
export class BlackListedTagsEncounteredError extends Error {
constructor(tagName: string) {
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
cause: tagName
});
}
}
@@ -0,0 +1,40 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
export default class ImageContainer extends BaseComponent {
#imageLink: HTMLAnchorElement | null = null;
protected init() {
this.#imageLink = this.container.querySelector('a');
}
extractActualTags(): string[] {
return this.#imageLink?.title.split(' | Tagged: ')[1]?.split(', ') || [];
}
extractTagsAndAliases(): string[] {
return this.container.dataset.imageTagAliases?.split(', ') || [];
}
extractImageLinks(): App.ImageURIs {
const jsonUris = this.container?.dataset.uris;
if (!jsonUris) {
throw new Error('Missing URIs!');
}
return JSON.parse(jsonUris);
}
extractSources(): string[] {
const jsonSourceUrls = this.container.dataset.sourceUrls;
let sourceUrls: string[] | null = null;
try {
sourceUrls = JSON.parse(jsonSourceUrls || '[]');
} catch (e) {
console.warn('Failed to parse source URLs for the image!', this, e);
}
return sourceUrls || [];
}
}
+40 -18
View File
@@ -2,16 +2,23 @@ import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { buildTagsAndAliasesMap } from "$lib/philomena/tag-utils";
import { on } from "$content/components/events/comms";
import { EVENT_TAGS_UPDATED } from "$content/components/events/maintenance-popup-events";
import { EVENT_TAGS_UPDATED } from "$content/components/events/tagging-profile-popup-events";
import ImageContainer from "$content/components/philomena/ImageContainer";
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
export class MediaBox extends BaseComponent {
#thumbnailContainer: HTMLElement | null = null;
#imageLinkElement: HTMLAnchorElement | null = null;
#imageContainer: ImageContainer | null = null;
#tagsAndAliases: Map<string, string> | null = null;
#mediaBoxTools: MediaBoxTools | null = null;
init() {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
const imageContainerElement = this.container.querySelector('.image-container');
this.#imageContainer = imageContainerElement instanceof HTMLElement
? new ImageContainer(imageContainerElement)
: null;
this.#imageContainer?.initialize();
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
@@ -27,12 +34,22 @@ export class MediaBox extends BaseComponent {
}
#calculateMediaBoxTags() {
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
const tagAliases = this.#imageContainer?.extractTagsAndAliases() || [];
const actualTags = this.#imageContainer?.extractActualTags() || [];
return buildTagsAndAliasesMap(tagAliases, actualTags);
}
get uniqueTagsSet(): Set<string> | null {
const tagsAndAliases = this.tagsAndAliases;
if (tagsAndAliases === null) {
return null;
}
return new Set(tagsAndAliases.values());
}
get tagsAndAliases(): Map<string, string> | null {
if (!this.#tagsAndAliases) {
this.#tagsAndAliases = this.#calculateMediaBoxTags();
@@ -52,26 +69,31 @@ export class MediaBox extends BaseComponent {
}
get imageLinks(): App.ImageURIs {
const jsonUris = this.#thumbnailContainer?.dataset.uris;
const sourceUrls = this.#imageContainer?.extractImageLinks();
if (!jsonUris) {
throw new Error('Missing URIs!');
if (!sourceUrls) {
throw new Error('Missing image container!');
}
return JSON.parse(jsonUris);
return sourceUrls;
}
get tools(): MediaBoxTools | null {
return this.#mediaBoxTools;
}
/**
* Wrap the media box element into the special wrapper.
*/
static initialize(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
new MediaBox(mediaBoxContainer)
.initialize();
static initialize(mediaBoxContainer: HTMLElement, mediaBoxToolsElement: HTMLElement) {
const mediaBox = new MediaBox(mediaBoxContainer);
mediaBox.initialize();
for (let childComponentElement of childComponentElements) {
mediaBoxContainer.appendChild(childComponentElement);
getComponent(childComponentElement)?.initialize();
}
mediaBoxContainer.appendChild(mediaBoxToolsElement);
const mediaBoxToolsComponent = getComponent<MediaBoxTools>(mediaBoxToolsElement);
mediaBoxToolsComponent?.initialize();
mediaBox.#mediaBoxTools = mediaBoxToolsComponent;
}
static findElements(): NodeListOf<HTMLElement> {
+36 -17
View File
@@ -9,8 +9,8 @@ import { EVENT_PRESET_TAG_CHANGE_APPLIED, type PresetTagChange } from "$content/
export class TagsForm extends BaseComponent {
#togglePresetsButton: HTMLButtonElement = document.createElement('button');
#presetsList = EditorPresetsBlock.create();
#plainEditorTextarea: HTMLTextAreaElement|null = null;
#fancyEditorInput: HTMLInputElement|null = null;
#plainEditorTextarea: HTMLTextAreaElement | null = null;
#fancyEditorInput: HTMLInputElement | null = null;
#tagsSet: Set<string> = new Set();
protected build() {
@@ -172,7 +172,8 @@ export class TagsForm extends BaseComponent {
}
#onTagChangeRequested(event: CustomEvent<PresetTagChange>) {
const { addedTags = null, removedTags = null } = event.detail;
const targetElement = event.target instanceof HTMLElement ? event.target : null;
const {addedTags = null, removedTags = null} = event.detail;
let tagChangeList: string[] = [];
if (addedTags) {
@@ -187,23 +188,33 @@ export class TagsForm extends BaseComponent {
);
}
const offsetBeforeSubmission = this.#presetsList.container.offsetTop;
this.#applyTagChangesWithFancyTagEditor(
tagChangeList.join(',')
this.#executeAndCompensateForLayoutShift(
() => this.#applyTagChangesWithFancyTagEditor(tagChangeList.join(',')),
[this.#presetsList.container, targetElement],
);
}
const offsetDifference = this.#presetsList.container.offsetTop - offsetBeforeSubmission;
#executeAndCompensateForLayoutShift(executeOperation: () => void, elements: (HTMLElement | null)[]) {
const offsetsListBefore = TagsForm.#gatherOffsetsFromElements(elements);
executeOperation();
const offsetsListAfter = TagsForm.#gatherOffsetsFromElements(elements);
// Compensating for the layout shift: when user clicks on a tag (or on "add/remove all tags"), tag editor might
// overflow the current line and wrap tags around to the next line, causing presets section to shift. We need to
// avoid that for better UX.
if (offsetDifference !== 0) {
window.scrollTo({
top: window.scrollY + offsetDifference,
behavior: 'instant',
});
const resultDifference = offsetsListAfter
.map((offsetAfter, index) =>
offsetAfter !== null && offsetsListBefore[index] !== null
? offsetAfter - offsetsListBefore[index]
: null)
.filter(difference => difference !== null)
.reduce((summary, difference) => summary + difference, 0);
if (resultDifference === 0) {
return;
}
window.scrollTo({
top: scrollY + resultDifference,
behavior: 'instant',
})
}
#applyTagChangesWithFancyTagEditor(tagsListWithChanges: string): void {
@@ -232,7 +243,7 @@ export class TagsForm extends BaseComponent {
this.refreshTagColors();
}
#onPlainEditorReloadRequested(event: CustomEvent<ReloadCustomOptions|null>) {
#onPlainEditorReloadRequested(event: CustomEvent<ReloadCustomOptions | null>) {
if (!event.detail?.skipTagColorRefresh) {
this.refreshTagColors();
}
@@ -242,6 +253,14 @@ export class TagsForm extends BaseComponent {
}
}
static #gatherOffsetsFromElements(elements: (HTMLElement | null)[]): (number | null)[] {
return elements.map(
maybeElement => maybeElement?.checkVisibility()
? maybeElement?.offsetTop
: null
);
}
static watchForEditors() {
document.body.addEventListener('click', event => {
const targetElement = event.target;
@@ -0,0 +1,232 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { createFontAwesomeIcon } from "$lib/dom-utils";
const brandsSubtype = 'brands';
export default class DupeDiff extends BaseComponent {
#sourcesLine: HTMLElement | null = null;
#ratingsLine: HTMLElement | null = null;
protected init() {
const {
"6": sourcesLine,
"7": ratingsLine,
} = this.container.querySelectorAll<HTMLElement>('table tr > td')
this.#sourcesLine = sourcesLine;
this.#ratingsLine = ratingsLine;
}
renderSources(leftSources: string[], rightSources: string[]): void {
if (!this.#sourcesLine) {
console.error("Can't render sources since no sources line in diff fonud!");
return;
}
for (const childElement of this.#sourcesLine.children) {
childElement.remove();
}
const sourceIconsContainer = document.createElement('span');
sourceIconsContainer.classList.add('source-icons');
sourceIconsContainer.append(
this.#renderSourceIcons(leftSources),
" vs ",
this.#renderSourceIcons(rightSources),
);
this.#sourcesLine.append(sourceIconsContainer);
}
renderRatings(leftRating: string | null, rightRating: string | null) {
if (!this.#ratingsLine) {
return;
}
for (const childElement of this.#ratingsLine.children) {
childElement.remove();
}
if (leftRating === rightRating) {
return;
}
const ratingDifference = document.createElement('span');
ratingDifference.classList.add('ratings-difference');
ratingDifference.textContent = `(${leftRating || '(none)'} vs ${rightRating || '(none)'})`;
this.#ratingsLine.append(ratingDifference);
}
#renderSourceIcons(sourcesList: string[]): HTMLElement {
const iconsContainer = document.createElement('span');
iconsContainer.classList.add('source-icons__list');
for (const sourceUrl of sourcesList) {
iconsContainer.append(this.#renderIcon(sourceUrl));
}
if (!sourcesList.length) {
iconsContainer.append('(none)');
}
return iconsContainer;
}
#renderIcon(url: string): HTMLElement {
let iconSlug = 'globe';
let maybeSubtype: string | undefined;
for (const [iconOrIconWithSubtype, singleOrMultiPattern] of DupeDiff.#iconsToPatternsMap) {
if (
typeof singleOrMultiPattern === 'string' && url.includes(singleOrMultiPattern)
|| Array.isArray(singleOrMultiPattern) && singleOrMultiPattern.some(singlePattern => url.includes(singlePattern))
) {
if (Array.isArray(iconOrIconWithSubtype)) {
[iconSlug, maybeSubtype] = iconOrIconWithSubtype;
} else {
iconSlug = iconOrIconWithSubtype;
}
break;
}
}
const sourceIcon = createFontAwesomeIcon(iconSlug, maybeSubtype);
sourceIcon.title = url;
return sourceIcon;
}
/**
* Mirroring of mapping from source URL domains to appropriate FontAwesome icons which is used in Philomena. Doesn't
* match 1-to-1, but pretty close.
*
* Keys are the icons and values are the patterns extension should check for each URL.
*/
static #iconsToPatternsMap = new Map<string[] | string, string[] | string>([
[
['artstation', brandsSubtype],
'artstation.com'
],
[
'bed',
'pillowfort.social',
],
[
'bolt-lightning',
'boosty.to',
],
[
['bluesky', brandsSubtype],
'bsky.app',
],
[
'brush',
['artfight.net', 'newgrounds.com'],
],
[
'coffee',
['ko-fi.com', 'buymeacoffee.com'],
],
[
['deviantart', brandsSubtype],
['deviantart.com', 'sta.sh', 'fav.me'],
],
[
['discord', brandsSubtype],
['discordapp.com', 'discord.com', 'discord.gg'],
],
[
'dove',
'itaku.ee',
],
[
['etsy', brandsSubtype],
'etsy.com',
],
[
['facebook', brandsSubtype],
['facebook.com', 'fb.me'],
],
[
['flickr', brandsSubtype],
'flickr.com',
],
[
['instagram', brandsSubtype],
'instagram.com',
],
[
['mastodon', brandsSubtype],
[
'awoo.space',
'bark.light',
'equestria.social',
'mastodon.social',
'meow.social',
'pawoo.net',
'pettingzoo.co',
'pony.social',
'vulpine.club',
'yiff.life',
'socel.net',
'octodon.social',
'filly.social',
'pone.social',
'hooves.social',
'baraag.net',
'furries.club',
],
],
[
'palette',
['ych.art', 'commishes.com']
],
[
['patreon', brandsSubtype],
'patreon.com'
],
[
'paw',
['furaffinity.net', 'e621.net', 'furbooru.org', 'inkbunny.net', 'e926.net', 'sofurry.com', 'weasyl.co'],
],
[
['pixiv', brandsSubtype],
['pixiv.net', 'pixiv.me'],
],
[
['reddit', brandsSubtype],
['reddit.com', 'redd.it'],
],
// Patreon piracy website, applying custom icon to easily catch such posts.
[
'skull-crossbones',
'kemono.cr',
],
[
['telegram', brandsSubtype],
't.me',
],
[
['tiktok', brandsSubtype],
'tiktok.com'
],
[
['tumblr', brandsSubtype],
['tumblr.com', 'tmblr.co', 'tumbex.com'],
],
[
['vk', brandsSubtype],
['vk.com', 'vk.ru'],
],
[
['x-twitter', brandsSubtype],
['x.com', 'twitter.com', 'twimg.com']
],
[
['youtube', brandsSubtype],
['youtube.com', 'youtu.be'],
],
]);
}
@@ -0,0 +1,19 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import ImageContainer from "$content/components/philomena/ImageContainer";
export default class DupeImage extends BaseComponent {
readonly imageContainer: ImageContainer;
constructor(container: HTMLElement) {
super(container);
const imageContainerElement = container.querySelector<HTMLElement>('.image-container');
if (!imageContainerElement) {
throw new Error('Missing image container inside the dupe row!');
}
this.imageContainer = new ImageContainer(imageContainerElement);
this.imageContainer.initialize();
}
}
@@ -0,0 +1,47 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import DupeDiff from "$content/components/philomena/dupe/DupeDiff";
import DupeImage from "$content/components/philomena/dupe/DupeImage";
import { ratingTags } from "$config/tags";
export default class DupeReportRow extends BaseComponent {
#leftImage: DupeImage;
#rightImage: DupeImage;
#difference: DupeDiff;
constructor(leftCell: HTMLElement, rightCell: HTMLElement, diffCell: HTMLElement, reportOptions: HTMLElement) {
super(reportOptions);
this.#leftImage = new DupeImage(leftCell);
this.#rightImage = new DupeImage(rightCell);
this.#difference = new DupeDiff(diffCell);
}
protected build() {
this.#leftImage.initialize();
this.#rightImage.initialize();
this.#difference.initialize();
}
protected init() {
this.#extractAndRenderSourcesIcons();
this.#extractAndDisplayRatings();
}
#extractAndRenderSourcesIcons() {
this.#difference.renderSources(
this.#leftImage.imageContainer.extractSources(),
this.#rightImage.imageContainer.extractSources(),
);
}
#extractAndDisplayRatings() {
this.#difference.renderRatings(
DupeReportRow.#extractRating(this.#leftImage),
DupeReportRow.#extractRating(this.#rightImage),
);
}
static #extractRating(image: DupeImage): string | null {
return image.imageContainer.extractActualTags().find(tagName => ratingTags.includes(tagName)) || null;
}
}
@@ -0,0 +1,43 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import DupeReportRow from "$content/components/philomena/dupe/DupeReportRow";
export default class GridDupeReportsList extends BaseComponent {
readonly #reports: DupeReportRow[] = [];
protected build() {
const repotRowsList = this.container.querySelectorAll(':scope > tbody > tr');
for (const reportRow of repotRowsList) {
const startingCell = reportRow.firstElementChild;
const rightImageCell = startingCell?.nextElementSibling || null;
const diffCell = rightImageCell?.nextElementSibling || null;
const reportOptionsCell = diffCell?.nextElementSibling || null;
if (!(startingCell instanceof HTMLElement) || !(rightImageCell instanceof HTMLElement) || !(diffCell instanceof HTMLElement) || !(reportOptionsCell instanceof HTMLElement)) {
console.error(`Unable to capture duplicate report!`, reportRow);
continue;
}
this.#reports.push(
new DupeReportRow(
startingCell,
rightImageCell,
diffCell,
reportOptionsCell,
)
);
}
}
protected init() {
for (const report of this.#reports) {
report.initialize();
}
}
static findAndInitialize() {
for (const container of document.querySelectorAll<HTMLElement>('.dupe-report-list')) {
new GridDupeReportsList(container).initialize();
}
}
}
+3
View File
@@ -0,0 +1,3 @@
import GridDupeReportsList from "$content/components/philomena/dupe/GridDupeReportsList";
GridDupeReportsList.findAndInitialize();
+5 -7
View File
@@ -8,13 +8,11 @@ import { ImageListContainer } from "$content/components/philomena/listing/ImageL
const mediaBoxes = MediaBox.findElements();
mediaBoxes.forEach(mediaBoxElement => {
MediaBox.initialize(mediaBoxElement, [
MediaBoxTools.create(
TaggingProfilePopup.create(),
TaggingProfileStatusIcon.create(),
ImageShowFullscreenButton.create(),
)
]);
MediaBox.initialize(mediaBoxElement, MediaBoxTools.create(
TaggingProfilePopup.create(),
TaggingProfileStatusIcon.create(),
ImageShowFullscreenButton.create(),
));
// Attempt to fix misplacement of media boxes
requestAnimationFrame(() => {
+14
View File
@@ -0,0 +1,14 @@
import { UserDetails } from "$lib/extension/preferences/UserDetails";
(async () => {
const userDetails = new UserDetails();
const userDataStore = document.querySelector<HTMLElement>('.js-datastore');
if (!userDataStore) {
return;
}
await userDetails.isAuthorized.set(userDataStore.dataset.userIsSignedIn === 'true');
})();
+3 -2
View File
@@ -2,10 +2,11 @@
* Reusable function to create icons from FontAwesome. Usable only for website, since extension doesn't host its own
* copy of FA styles. Extension should use imports of SVGs inside CSS instead.
* @param iconSlug Slug of the icon to be added.
* @param [subtype="solid"] Subtype of the icon. Some icons only exist in specific subtypes.
* @return Element with classes for FontAwesome icon added.
*/
export function createFontAwesomeIcon(iconSlug: string): HTMLElement {
export function createFontAwesomeIcon(iconSlug: string, subtype: string = 'solid'): HTMLElement {
const iconElement = document.createElement('i');
iconElement.classList.add('fa-solid', `fa-${iconSlug}`);
iconElement.classList.add(`fa-${subtype}`, `fa-${iconSlug}`);
return iconElement;
}
+1 -1
View File
@@ -70,7 +70,7 @@ export default class ConfigurationController {
return;
}
callback(changes[this.#configurationName].newValue);
callback(changes[this.#configurationName].newValue as Record<string, any>);
}
this.#storage.subscribe(subscriber);
+34 -8
View File
@@ -2,7 +2,15 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag
import type StorageEntity from "$lib/extension/base/StorageEntity";
export default class EntitiesController {
static #storageHelper = new StorageHelper(chrome.storage.local);
/**
* Instance of storage helper used to store/read/subscribe to storage changes.
*
* Mainly exposed for the testing purposes. When class is loaded outside of extension context, will hold `null`
* instead. Any operations of entities will throw an error in this case.
*/
static storage: StorageHelper | null = typeof chrome !== 'undefined'
? new StorageHelper(chrome.storage.local)
: null;
/**
* Read all entities of the given type from the storage. Build the entities from the raw data and return them.
@@ -14,7 +22,11 @@ export default class EntitiesController {
* @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 (!this.storage) {
throw new Error('Missing storage!');
}
const rawEntities = await this.storage.read(entityName, {});
if (!rawEntities || Object.keys(rawEntities).length === 0) {
return [];
@@ -32,10 +44,14 @@ export default class EntitiesController {
* @param entity Entity to update.
*/
static async updateEntity(entityName: string, entity: StorageEntity<Object>): Promise<void> {
this.#storageHelper.write(
if (!this.storage) {
throw new Error('Missing storage!');
}
this.storage.write(
entityName,
Object.assign(
await this.#storageHelper.read(
await this.storage.read(
entityName, {}
),
{
@@ -52,9 +68,13 @@ export default class EntitiesController {
* @param entityId ID of the entity to delete.
*/
static async deleteEntity(entityName: string, entityId: string): Promise<void> {
const entities = await this.#storageHelper.read(entityName, {});
if (!this.storage) {
throw new Error('Missing storage!');
}
const entities = await this.storage.read(entityName, {});
delete entities[entityId];
this.#storageHelper.write(entityName, entities);
this.storage.write(entityName, entities);
}
/**
@@ -68,6 +88,12 @@ export default class EntitiesController {
* @return Unsubscribe function.
*/
static subscribeToEntity<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type, callback: (entities: Type[]) => void): () => void {
if (!this.storage) {
throw new Error('Missing storage!');
}
const storage = this.storage;
/**
* Watch the changes made to the storage and call the callback when the entity changes.
*/
@@ -80,8 +106,8 @@ export default class EntitiesController {
.then(callback);
}
this.#storageHelper.subscribe(subscriber);
storage.subscribe(subscriber);
return () => this.#storageHelper.unsubscribe(subscriber);
return () => storage.unsubscribe(subscriber);
}
}
+10 -5
View File
@@ -70,6 +70,10 @@ export class PreferenceField<
set(value: Fields[Key]) {
return this.#preferences.writeRaw(this.#fieldKey, value);
}
get key(): Key {
return this.#fieldKey;
}
}
/**
@@ -89,16 +93,17 @@ export type WithFields<FieldsType extends Record<string, any>> = {
* API.
*/
export default abstract class CacheablePreferences<Fields> {
#controller: ConfigurationController;
#cachedValues: Map<keyof Fields, any> = new Map();
#disposables: Function[] = [];
readonly #controller: ConfigurationController;
readonly #cachedValues: Map<keyof Fields, any> = new Map();
readonly #disposables: Function[] = [];
/**
* @param settingsNamespace Name of the field inside the extension storage where these preferences stored.
* @param [controller] Configuration controller. If not provided, default controller will be used.
* @protected
*/
protected constructor(settingsNamespace: string) {
this.#controller = new ConfigurationController(settingsNamespace);
protected constructor(settingsNamespace: string, controller: ConfigurationController = new ConfigurationController(settingsNamespace)) {
this.#controller = controller;
this.#disposables.push(
this.#controller.subscribeToChanges(settings => {
@@ -3,6 +3,9 @@ import StorageEntity from "$lib/extension/base/StorageEntity";
interface TagEditorPresetSettings {
name: string;
tags: string[];
exclusive: boolean;
conditional: boolean;
requiredTags: string[];
}
export default class TagEditorPreset extends StorageEntity<TagEditorPresetSettings> {
@@ -10,6 +13,9 @@ export default class TagEditorPreset extends StorageEntity<TagEditorPresetSettin
super(id, {
name: settings.name || '',
tags: settings.tags || [],
exclusive: settings.exclusive ?? false,
conditional: settings.conditional || false,
requiredTags: settings.requiredTags || [],
});
}
@@ -8,6 +8,7 @@ export type FullscreenViewerSize = keyof App.ImageURIs;
interface MiscPreferencesFields {
fullscreenViewer: boolean;
fullscreenViewerSize: FullscreenViewerSize;
fullscreenViewerPopupShown: boolean;
}
export default class MiscPreferences extends CacheablePreferences<MiscPreferencesFields> implements WithFields<MiscPreferencesFields> {
@@ -24,4 +25,9 @@ export default class MiscPreferences extends CacheablePreferences<MiscPreference
field: "fullscreenViewerSize",
defaultValue: "large",
});
readonly fullscreenViewerPopupShown = new PreferenceField(this, {
field: "fullscreenViewerPopupShown",
defaultValue: true,
});
}
@@ -0,0 +1,16 @@
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
export interface UserDetailsFields {
isAuthorized: boolean;
}
export class UserDetails extends CacheablePreferences<UserDetailsFields> implements WithFields<UserDetailsFields> {
constructor() {
super('userDetails');
}
isAuthorized = new PreferenceField(this, {
field: 'isAuthorized',
defaultValue: false,
});
}
@@ -42,6 +42,9 @@ const entitiesExporters: ExportersMap = {
id: entity.id,
name: entity.settings.name,
tags: entity.settings.tags,
exclusive: entity.settings.exclusive,
conditional: entity.settings.conditional,
requiredTags: entity.settings.requiredTags,
}
}
};
+12 -1
View File
@@ -29,7 +29,15 @@ function validateRequiredString(value: unknown): boolean {
* @param value Value to be checked.
*/
function validateOptionalArray(value: unknown): boolean {
return typeof value === 'undefined' || value === null || Array.isArray(value);
return value === undefined || value === null || Array.isArray(value);
}
/**
* Check if the following value is not set or is a valid boolean.
* @param value Value to be checked.
*/
function validateOptionalBoolean(value: unknown): boolean {
return value === undefined || typeof value === 'boolean';
}
/**
@@ -73,6 +81,9 @@ const entitiesValidators: EntitiesValidationMap = {
!validateRequiredString(importedObject?.id)
|| !validateRequiredString(importedObject?.name)
|| !validateOptionalArray(importedObject?.tags)
|| !validateOptionalBoolean(importedObject?.exclusive)
|| !validateOptionalBoolean(importedObject?.conditional)
|| !validateOptionalArray(importedObject?.requiredTags)
) {
throw new Error('Invalid preset format detected!');
}
+2 -2
View File
@@ -1,6 +1,6 @@
import PostParser from "$lib/philomena/scraping/parsing/PostParser";
type UpdaterFunction = (tags: Set<string>) => Set<string>;
export type TagsUpdaterFunction = (tags: Set<string>) => Set<string>;
export default class ScrapedAPI {
/**
@@ -9,7 +9,7 @@ export default class ScrapedAPI {
* @param callback Callback to call to change the content.
* @return Updated tags and aliases list for updating internal cached state.
*/
async updateImageTags(imageId: number, callback: UpdaterFunction): Promise<Map<string, string> | null> {
async updateImageTags(imageId: number, callback: TagsUpdaterFunction): Promise<Map<string, string> | null> {
const postParser = new PostParser(imageId);
const formData = await postParser.resolveTagEditorFormData();
const tagsFieldValue = formData.get(PostParser.tagsInputName);
+45 -39
View File
@@ -41,21 +41,27 @@ export class QuotedTermToken extends Token {
}
static decode(value: string): string {
return value.replace(/\\([\\"])/g, "$1");
return value
.replaceAll(/\\([\\"])/g, "$1")
.replaceAll(/^"|"$/g, '');
}
static encode(value: string): string {
return value.replace(/[\\"]/g, "\\$&");
return `"${value.replaceAll(/[\\"]/g, "\\$&")}"`;
}
}
export class TermToken extends Token {
}
type MatchResultCarry = {
interface MatchResultCarry {
match?: RegExpMatchArray | null
}
interface SuccessfulMatchResultCarry {
match: RegExpMatchArray;
}
/**
* 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.
@@ -94,26 +100,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;
}
@@ -130,26 +136,26 @@ 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;
}
dirtyText = this.#parseDirtyText(this.#index);
if (dirtyText) {
tokens.push(new TermToken(this.#index, dirtyText));
tokens.push(new TermToken(this.#index, dirtyText.trim()));
this.#index += dirtyText.length;
continue;
}
@@ -168,7 +174,7 @@ export class QueryLexer {
*
* @return Is there a match?
*/
#match(targetRegExp: RegExp, resultCarrier: MatchResultCarry = {}): boolean {
#match(targetRegExp: RegExp, resultCarrier: MatchResultCarry = {}): resultCarrier is SuccessfulMatchResultCarry {
return this.#matchAt(targetRegExp, this.#index, resultCarrier);
}
@@ -181,9 +187,9 @@ export class QueryLexer {
*
* @return Is there a match?
*/
#matchAt(targetRegExp: RegExp, index: number, resultCarrier: MatchResultCarry = {}): boolean {
#matchAt(targetRegExp: RegExp, index: number, resultCarrier: MatchResultCarry = {}): resultCarrier is SuccessfulMatchResultCarry {
targetRegExp.lastIndex = index;
resultCarrier.match = this.#value.match(targetRegExp);
resultCarrier.match = targetRegExp.exec(this.#value);
return resultCarrier.match !== null;
}
@@ -207,16 +213,10 @@ export class QueryLexer {
break;
}
if (this.#matchAt(QueryLexer.#dirtyTextContent, index, result)) {
resultValue += result.match![0];
index += result.match![0].length;
continue;
}
if (this.#value[index] === QueryLexer.#bracketsOpenCharacter) {
let bracketsContent = QueryLexer.#bracketsOpenCharacter + this.#parseDirtyText(index + 1);
if (this.#value[index + bracketsContent.length + 1] === QueryLexer.#bracketsCloseCharacter) {
if (this.#value[index + bracketsContent.length] === QueryLexer.#bracketsCloseCharacter) {
bracketsContent += QueryLexer.#bracketsCloseCharacter;
}
@@ -227,22 +227,28 @@ export class QueryLexer {
continue;
}
if (this.#matchAt(QueryLexer.#dirtyTextContent, index, result)) {
resultValue += result.match[0];
index += result.match[0].length;
continue;
}
break;
}
return resultValue;
}
static #commaCharacter = ',';
static #negotiationOperator = /[!-]/y;
static #andOperator = /\s+(?:AND|&&)\s+/y;
static #orOperator = /\s+(?:OR|\|\|)\s+/y;
static #notOperator = /NOT\s+/y;
static #bracketsOpenCharacter = "(";
static #bracketsCloseCharacter = ")";
static #boostOperator = /\^[+-]?\d+(?:\.\d+)?/y;
static #whitespaces = /\s+/y;
static #quotedText = /"((?:\\.|[^\\"])+)"/y;
static #dirtyTextStopWords = /,|\s+(?:AND|&&|OR|\|\|)\s+|\s+(?:\)|\^[+-]?\d+(?:\.\d+)?)/y;
static #dirtyTextContent = /\\.|[^()]/y;
static readonly #commaCharacter = ',';
static readonly #negotiationOperator = /[!-]/y;
static readonly #andOperator = /\s+(?:AND|&&)\s+/y;
static readonly #orOperator = /\s+(?:OR|\|\|)\s+/y;
static readonly #notOperator = /NOT\s+/y;
static readonly #bracketsOpenCharacter = "(";
static readonly #bracketsCloseCharacter = ")";
static readonly #boostOperator = /\^[+-]?\d+(?:\.\d+)?/y;
static readonly #whitespaces = /\s+/y;
static readonly #quotedText = /"\s*((?:\\.|[^\\"])+?)\s*"/y;
static readonly #dirtyTextStopWords = /,|\s+(?:AND|&&|OR|\|\|)\s+|\s*(?:\)|\^[+-]?\d+(?:\.\d+)?)/y;
static readonly #dirtyTextContent = /\\.|[^()]/y;
}
+3 -4
View File
@@ -42,7 +42,7 @@ const tagLinkRegExp = /\/tags\/(?<encodedTagName>[^/?#]+)/;
*
* @see https://github.com/philomena-dev/philomena/blob/6086757b654da8792ae52adb2a2f501ea6c30d12/lib/philomena/slug.ex#L52-L57
*/
const slugEncodedCharacters: Map<string, string> = new Map([
export const slugEncodedCharacters: Map<string, string> = new Map([
['-dash-', '-'],
['-fwslash-', '/'],
['-bwslash-', '\\'],
@@ -101,9 +101,8 @@ export function resolveTagNameFromLink(tagLink: URL): string | null {
}
return decodeURIComponent(encodedTagName)
.replaceAll(/-[a-z]+-/gi, match => slugEncodedCharacters.get(match) ?? match)
.replaceAll('-', ' ')
.replaceAll('+', ' ');
.replaceAll('+', ' ')
.replaceAll(/-[a-z]+-/gi, match => slugEncodedCharacters.get(match) ?? match);
}
/**
+65
View File
@@ -0,0 +1,65 @@
import type { Writable } from "svelte/store";
import CacheablePreferences, { type PreferenceField } from "$lib/extension/base/CacheablePreferences";
interface PrerefenceConnectionOptions<ValueType> {
/**
* Optional callback to apply modifications to the value when sending them from the preference field to the single
* storage. If not provided, value itself will be sent instead.
*/
updateToStore?: (value: any) => ValueType;
}
export class PreferenceSync<PreferencesType extends Record<string, any> = Record<string, any>> {
readonly #preferences: CacheablePreferences<PreferencesType>;
readonly #syncedPairs: [PreferenceField<PreferencesType, string>, Writable<any>, PrerefenceConnectionOptions<any>][] = [];
protected constructor(preferences: CacheablePreferences<PreferencesType>) {
this.#preferences = preferences;
}
/**
* Connect the specific preference field to the provided storage.
* @param field Field object.
* @param store Storage object. Should contain the appropriate type for the value.
* @param [options] Additional settings.
*/
connect<FieldKey extends keyof PreferencesType>(
field: PreferenceField<PreferencesType, FieldKey>,
store: Writable<PreferencesType[FieldKey]>,
options: PrerefenceConnectionOptions<PreferencesType[FieldKey]> = {}
): this {
this.#syncedPairs.push([field as any, store, options]);
return this;
}
async startSync(): Promise<void> {
await Promise.allSettled(
this.#syncedPairs.map(
([field, store, options]) => field.get()
.then(initialValue => store.set(options.updateToStore?.(initialValue) ?? initialValue))
)
);
for (const [field, store] of this.#syncedPairs) {
store.subscribe(value => field.set(value));
}
this.#preferences.subscribe(updatedSettings => {
for (const [field, store, options] of this.#syncedPairs) {
if (!(field.key in updatedSettings)) {
continue;
}
const updatedValue = updatedSettings[field.key];
store.set(options.updateToStore?.(updatedValue) ?? updatedValue);
}
});
}
static for<
PreferencesType extends Record<string, any> = Record<string, any>
>(preferences: CacheablePreferences<PreferencesType>): PreferenceSync<PreferencesType> {
return new PreferenceSync<PreferencesType>(preferences);
}
}
+1 -1
View File
@@ -34,7 +34,7 @@
<MenuItem href={currentSiteUrl} icon="globe" target="_blank">
Visit {__CURRENT_SITE_NAME__}
</MenuItem>
<MenuItem href="https://github.com/koloml/furbooru-tagging-assistant" icon="info-circle" target="_blank">
<MenuItem href="https://github.com/koloml/philomena-tagging-assistant" icon="info-circle" target="_blank">
GitHub Repo
</MenuItem>
</Menu>
@@ -10,6 +10,7 @@
import FormControl from "$components/ui/forms/FormControl.svelte";
import TextField from "$components/ui/forms/TextField.svelte";
import TagsEditor from "$components/tags/TagsEditor.svelte";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
let presetId = $derived(page.params.id);
@@ -23,6 +24,9 @@
let presetName = $state('');
let tagsList = $state<string[]>([]);
let isExclusive = $state(false);
let isConditional = $state<boolean>(false);
let requiredTags = $state<string[]>([]);
$effect(() => {
if (presetId === 'new') {
@@ -39,6 +43,9 @@
presetName = targetPreset.settings.name;
tagsList = [...targetPreset.settings.tags].sort((a, b) => a.localeCompare(b));
isExclusive = targetPreset.settings.exclusive;
isConditional = targetPreset.settings.conditional;
requiredTags = [...targetPreset.settings.requiredTags].sort((a, b) => a.localeCompare(b));
});
async function savePreset() {
@@ -49,6 +56,9 @@
targetPreset.settings.name = presetName;
targetPreset.settings.tags = [...tagsList];
targetPreset.settings.exclusive = isExclusive;
targetPreset.settings.conditional = isConditional;
targetPreset.settings.requiredTags = [...requiredTags];
await targetPreset.save();
await goto(`/features/presets/${targetPreset.id}`);
@@ -67,6 +77,21 @@
<FormControl label="Tags">
<TagsEditor bind:tags={tagsList}></TagsEditor>
</FormControl>
<FormControl>
<CheckboxField bind:checked={isExclusive}>
Keep only one tag from this preset active at a time.
</CheckboxField>
</FormControl>
<FormControl>
<CheckboxField bind:checked={isConditional}>
Show this preset only when any of specified tags are provided.
</CheckboxField>
</FormControl>
{#if isConditional}
<FormControl label="Required Tags">
<TagsEditor bind:tags={requiredTags}></TagsEditor>
</FormControl>
{/if}
</FormContainer>
<Menu>
<hr>
+14
View File
@@ -5,6 +5,8 @@
import { activeTaggingProfile, taggingProfiles } from "$stores/entities/tagging-profiles";
import TaggingProfile from "$entities/TaggingProfile";
import { popupTitle } from "$stores/popup";
import { user } from "$stores/preferences/user";
import Notice from "$components/ui/Notice.svelte";
$popupTitle = 'Tagging Profiles';
@@ -28,6 +30,18 @@
<Menu>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/profiles/new/edit" icon="plus">Create New</MenuItem>
</Menu>
{#if $user?.isAuthorized === false}
<Menu>
<hr>
</Menu>
<Notice level="warning">
<strong>Tagging profiles will only work when you're signed in!</strong>
<br><br>
Unauthorized users have to solve the captcha before sending any submissions to the site.
</Notice>
{/if}
<Menu>
{#if profiles.length}
<hr>
{/if}
+4 -11
View File
@@ -1,18 +1,11 @@
import { writable } from "svelte/store";
import MiscPreferences from "$lib/extension/preferences/MiscPreferences";
import { PreferenceSync } from "$lib/store-sync";
export const fullScreenViewerEnabled = writable(true);
const preferences = new MiscPreferences();
Promise.allSettled([
preferences.fullscreenViewer.get().then(v => fullScreenViewerEnabled.set(v))
]).then(() => {
fullScreenViewerEnabled.subscribe(value => {
void preferences.fullscreenViewer.set(value);
});
preferences.subscribe(settings => {
fullScreenViewerEnabled.set(Boolean(settings.fullscreenViewer));
});
});
void PreferenceSync.for(preferences)
.connect(preferences.fullscreenViewer, fullScreenViewerEnabled, {updateToStore: Boolean})
.startSync();
+6 -11
View File
@@ -1,18 +1,13 @@
import { writable } from "svelte/store";
import TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences";
import { PreferenceSync } from "$lib/store-sync";
export const stripBlacklistedTagsEnabled = writable(true);
const preferences = new TaggingProfilesPreferences();
Promise
.all([
preferences.stripBlacklistedTags.get().then(v => stripBlacklistedTagsEnabled.set(v ?? true))
])
.then(() => {
preferences.subscribe(settings => {
stripBlacklistedTagsEnabled.set(typeof settings.stripBlacklistedTags === 'boolean' ? settings.stripBlacklistedTags : true);
});
stripBlacklistedTagsEnabled.subscribe(v => preferences.stripBlacklistedTags.set(v));
});
void PreferenceSync.for(preferences)
.connect(preferences.stripBlacklistedTags, stripBlacklistedTagsEnabled, {
updateToStore: maybeBoolean => typeof maybeBoolean === 'boolean' ? maybeBoolean : true,
})
.startSync();
+6 -25
View File
@@ -1,5 +1,6 @@
import { writable } from "svelte/store";
import TagsPreferences from "$lib/extension/preferences/TagsPreferences";
import { PreferenceSync } from "$lib/store-sync";
const preferences = new TagsPreferences();
@@ -7,28 +8,8 @@ export const shouldSeparateTagGroups = writable(false);
export const shouldReplaceLinksOnForumPosts = writable(false);
export const shouldReplaceTextOfTagLinks = writable(true);
Promise
.allSettled([
preferences.groupSeparation.get().then(value => shouldSeparateTagGroups.set(value)),
preferences.replaceLinks.get().then(value => shouldReplaceLinksOnForumPosts.set(value)),
preferences.replaceLinkText.get().then(value => shouldReplaceTextOfTagLinks.set(value)),
])
.then(() => {
shouldSeparateTagGroups.subscribe(value => {
void preferences.groupSeparation.set(value);
});
shouldReplaceLinksOnForumPosts.subscribe(value => {
void preferences.replaceLinks.set(value);
});
shouldReplaceTextOfTagLinks.subscribe(value => {
void preferences.replaceLinkText.set(value);
});
preferences.subscribe(settings => {
shouldSeparateTagGroups.set(Boolean(settings.groupSeparation));
shouldReplaceLinksOnForumPosts.set(Boolean(settings.replaceLinks));
shouldReplaceTextOfTagLinks.set(Boolean(settings.replaceLinkText));
});
});
void PreferenceSync.for(preferences)
.connect(preferences.groupSeparation, shouldSeparateTagGroups, {updateToStore: Boolean})
.connect(preferences.replaceLinks, shouldReplaceLinksOnForumPosts, {updateToStore: Boolean})
.connect(preferences.replaceLinkText, shouldReplaceTextOfTagLinks, {updateToStore: Boolean})
.startSync();
+18
View File
@@ -0,0 +1,18 @@
import { UserDetails, type UserDetailsFields } from "$lib/extension/preferences/UserDetails";
import { readable } from "svelte/store";
const userDetails = new UserDetails();
export const user = readable<UserDetailsFields | null>(null, set => {
userDetails.subscribe(settings => {
set({
isAuthorized: Boolean(settings.isAuthorized)
});
});
userDetails.isAuthorized.get().then(isAuthorized => {
set({
isAuthorized
});
});
});
+8
View File
@@ -4,8 +4,16 @@ $media-box-color: var(--media-box-color);
$padding-small: var(--padding-small);
$padding-normal: var(--padding-normal);
$padding-large: var(--padding-large);
$block-spacing: var(--block-spacing);
$block-header-light-color: var(--block-header-light-color);
$block-header-link-text-color: var(--block-header-link-text-color);
$border: var(--border);
$header-spacing: var(--header-spacing);
$block-header-sub-height: var(--block-header-sub-height);
// These variables are defined dynamically based on the category of the tag
$resolved-tag-background: var(--tag-background);
$resolved-tag-border: var(--tag-border);
$resolved-tag-color: var(--tag-color);
$limited-layout-width: var(--limited-layout-width);
+46
View File
@@ -0,0 +1,46 @@
.dr__diff {
.source-icons {
&__list {
display: inline-flex;
gap: .25em;
}
}
td {
display: flex;
flex-wrap: wrap;
column-gap: .5em;
}
}
// Trying to make it more usable on mobile by regrouping table cells into grid.
@media screen and (max-width: 800px) {
.dupe-report-list {
& > thead {
display: none;
}
& > tbody > tr {
display: grid;
grid-template-areas: "source target" "diff actions";
grid-template-columns: 1fr 1fr;
.dupe-report-list--image {
grid-area: source;
width: auto;
+ .dupe-report-list--image {
grid-area: target;
}
}
.dr__diff {
grid-area: diff;
& + td {
grid-area: actions;
}
}
}
}
}
+97 -23
View File
@@ -2,6 +2,38 @@
@use '$styles/booru-vars';
@use '$styles/environment';
%box {
background: booru-vars.$background-color;
padding: 5px;
}
%tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
.tag {
margin: 0;
}
}
%toggleable-tag {
cursor: pointer;
user-select: none;
padding: 0 5px;
gap: 0;
&:hover {
background: booru-vars.$resolved-tag-color;
color: booru-vars.$resolved-tag-background;
}
&.is-missing:not(.is-added),
&.is-present.is-removed {
opacity: 0.5;
}
}
// This will fix wierd misplacing of the modified media boxes in the listing.
.js-resizable-media-container {
display: flex;
@@ -40,14 +72,14 @@
}
.maintenance-popup {
@extend %box;
display: none;
position: absolute;
top: 100%;
padding: 5px;
left: -50%;
right: -50%;
z-index: 8;
background: booru-vars.$background-color;
border: {
left: booru-vars.$media-border;
right: booru-vars.$media-border;
@@ -55,31 +87,13 @@
};
.tags-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
@extend %tags-list;
.tag {
margin: 0;
}
justify-content: center;
}
.tag {
cursor: pointer;
user-select: none;
padding: 0 5px;
gap: 0;
&:hover {
background: booru-vars.$resolved-tag-color;
color: booru-vars.$resolved-tag-background;
}
&.is-missing:not(.is-added),
&.is-present.is-removed {
opacity: 0.5;
}
@extend %toggleable-tag;
}
}
@@ -244,4 +258,64 @@
opacity: 1;
}
}
.tags-panel {
@extend %box;
display: flex;
z-index: 1;
width: 96vw;
max-width: booru-vars.$limited-layout-width;
max-height: 50vh;
position: fixed;
bottom: 0;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
transition: transform .25s ease-in-out;
border: {
top: booru-vars.$media-border;
left: booru-vars.$media-border;
right: booru-vars.$media-border;
}
&__toggle {
position: absolute;
left: -1px;
bottom: calc(100% + 1px);
border: booru-vars.$border;
background: booru-vars.$block-header-light-color;
color: booru-vars.$block-header-link-text-color;
font-weight: 700;
padding: 0 calc(#{booru-vars.$header-spacing} * .5);
line-height: booru-vars.$block-header-sub-height;
cursor: pointer;
}
&__scroller {
overflow-y: auto;
overflow-x: hidden;
}
&.is-collapsed {
transform: translateY(100%);
}
.tags-list {
@extend %tags-list;
&.is-hidden {
display: none;
& + hr {
display: none;
}
}
}
.tag {
@extend %toggleable-tag;
}
}
}
+8
View File
@@ -3,6 +3,7 @@
.block.tag-presets {
.tag {
cursor: pointer;
user-select: none;
&.is-missing {
opacity: 0.5;
@@ -13,4 +14,11 @@
background: booru-vars.$resolved-tag-color;
}
}
.block.block--fixed.block--warning {
margin: {
top: booru-vars.$block-spacing;
bottom: 0;
}
}
}
@@ -0,0 +1,261 @@
import EntitiesController from "$lib/extension/EntitiesController";
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
import StorageHelper from "$lib/browser/StorageHelper";
import { TestedEntity, type TestedSettings } from "$tests/stubs/Entity";
import { randomString } from "$tests/utils";
import { randomInt } from "crypto";
describe('EntitiesController', () => {
let mockedStorage: ChromeStorageArea;
beforeEach(() => {
mockedStorage = new ChromeStorageArea();
EntitiesController.storage = new StorageHelper(mockedStorage);
});
it('should throw when storage is not present', async () => {
EntitiesController.storage = null;
const readPromise = EntitiesController.readAllEntities(
TestedEntity._entityName,
TestedEntity,
);
const deletePromise = EntitiesController.deleteEntity(
TestedEntity._entityName,
randomString(),
);
const updatePromise = EntitiesController.updateEntity(
TestedEntity._entityName,
new TestedEntity(randomString(), {
numberField: randomInt(1000),
stringField: randomString(),
}),
);
const subscribe = () => {
EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, vi.fn());
}
await expect(readPromise).rejects.toThrow(Error);
await expect(deletePromise).rejects.toThrow(Error);
await expect(updatePromise).rejects.toThrow(Error);
expect(subscribe).toThrow(Error);
});
describe('readAllEntities', () => {
it('should return empty array when nothing in the storage yet', async () => {
const entities = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
expect(entities).toHaveLength(0);
});
it('should properly capture different entities from storage', async () => {
const storageWithEntities: Record<string, Record<string, Partial<TestedSettings>>> = {
[TestedEntity._entityName]: {
[randomString()]: {
stringField: randomString(),
numberField: randomInt(-100_000, 100_000),
},
[randomString()]: {
stringField: randomString(),
numberField: randomInt(-100_000, 100_000),
},
}
};
mockedStorage.insertMockedData(storageWithEntities);
const loadedEntities = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
expect(loadedEntities).toHaveLength(2);
for (const entity of loadedEntities) {
const rawStorageEntry = storageWithEntities[TestedEntity._entityName][entity.id];
expect(entity.settings.stringField).toBe(rawStorageEntry.stringField);
expect(entity.settings.numberField).toBe(rawStorageEntry.numberField);
}
});
});
describe('updateEntity', () => {
it('should create a storage structure if it is not created yet', async () => {
expect(mockedStorage.mockedData).toEqual({});
const entity = new TestedEntity(randomString(), {
stringField: randomString(),
numberField: randomInt(100_000),
});
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
expect(mockedStorage.mockedData).toEqual({
[TestedEntity._entityName]: {
[entity.id]: {
stringField: entity.settings.stringField,
numberField: entity.settings.numberField,
},
},
});
});
it('should update entity inside the existing', async () => {
const id = randomString();
const initialStringValue = randomString();
const updatedStringValue = randomString();
mockedStorage.insertMockedData({
[TestedEntity._entityName]: {
[id]: {
stringField: initialStringValue,
numberField: randomInt(100_000),
} as TestedSettings,
}
});
const [entity] = await EntitiesController.readAllEntities(TestedEntity._entityName, TestedEntity);
expect(entity.settings.stringField).toBe(initialStringValue);
entity.settings.stringField = updatedStringValue;
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
const entityInsideStorage = mockedStorage.mockedData[TestedEntity._entityName][id];
expect(entityInsideStorage.stringField).toBe(updatedStringValue);
});
});
describe('deleteEntity', () => {
it('should initialize the storage structure if delete called', async () => {
expect(mockedStorage.mockedData).toEqual({});
await EntitiesController.deleteEntity(TestedEntity._entityName, randomString());
expect(mockedStorage.mockedData).toEqual({
[TestedEntity._entityName]: {},
});
});
it('should delete entity and keep the storage object empty', async () => {
const id = randomString();
const settings: TestedSettings = {
numberField: randomInt(100_000),
stringField: randomString(),
};
mockedStorage.insertMockedData({
[TestedEntity._entityName]: {
[id]: settings,
}
});
// Doesn't touch existing instance if ID is not found in the storage
await EntitiesController.deleteEntity(TestedEntity._entityName, randomString());
expect(mockedStorage.mockedData).toEqual({
[TestedEntity._entityName]: {
[id]: settings,
}
});
await EntitiesController.deleteEntity(TestedEntity._entityName, id);
expect(mockedStorage.mockedData).toEqual({
[TestedEntity._entityName]: {}
});
});
});
describe('subscribeToEntity', () => {
it('should notify about changes and return new entities', async () => {
let receivedEntities: TestedEntity[] | null = null;
const subscriber = vi.fn((entities: TestedEntity[]) => {
receivedEntities = entities;
});
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, subscriber);
expect(subscriber).not.toHaveBeenCalled();
const createdEntity = new TestedEntity(randomString(), {
numberField: randomInt(100),
stringField: randomString(),
});
await EntitiesController.updateEntity(TestedEntity._entityName, createdEntity);
await vi.waitFor(() => {
expect(subscriber).toHaveBeenCalled();
}, {interval: 1, timeout: 10});
const [firstReceivedEntity] = receivedEntities || [];
expect(firstReceivedEntity).toBeInstanceOf(TestedEntity);
expect(firstReceivedEntity).not.toBe(createdEntity);
expect(firstReceivedEntity).toEqual(createdEntity);
});
it('should stop receiving updates once unsubscribed', async () => {
const firstSubscriber = vi.fn();
const unsubscribeFirst = EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, firstSubscriber);
const entity = new TestedEntity(randomString(), {
numberField: randomInt(100_000),
stringField: randomString(),
});
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
await vi.waitFor(() => {
expect(firstSubscriber).toHaveBeenCalledOnce();
}, {interval: 1, timeout: 10});
firstSubscriber.mockReset();
unsubscribeFirst();
const secondSubscriber = vi.fn();
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, secondSubscriber);
entity.settings.stringField = randomString();
await EntitiesController.updateEntity(TestedEntity._entityName, entity);
await vi.waitFor(() => {
expect(secondSubscriber).toHaveBeenCalledOnce();
}, {interval: 1, timeout: 10});
expect(firstSubscriber).not.toHaveBeenCalled();
});
it('should not notify when something else was changed in the storage', async () => {
const rawStorageSubscriber = vi.fn();
const entitiesSubscriber = vi.fn();
void EntitiesController.storage?.subscribe(rawStorageSubscriber);
void EntitiesController.subscribeToEntity(TestedEntity._entityName, TestedEntity, entitiesSubscriber);
EntitiesController.storage?.write('otherStorage', {
someField: randomString(),
});
await EntitiesController.updateEntity(
TestedEntity._entityName,
new TestedEntity(randomString(), {
stringField: randomString(),
numberField: randomInt(100_000),
}),
);
EntitiesController.storage?.write('otherStorage', {
someField: randomString(),
});
await vi.waitFor(() => {
expect(entitiesSubscriber).toHaveBeenCalledOnce();
expect(rawStorageSubscriber).toHaveBeenCalledTimes(3);
}, {timeout: 10, interval: 1});
})
});
});
@@ -0,0 +1,129 @@
import { randomString } from "$tests/utils";
import { randomInt } from "crypto";
import { TestedPreferences } from "$tests/stubs/Preferences";
describe('CachablePreferences', () => {
let preferences: TestedPreferences;
beforeEach(() => {
preferences = new TestedPreferences(randomString(), {
numberField: randomInt(-100_000, 100_000),
stringField: randomString(),
});
});
describe('PreferenceField', () => {
it('should get/set values in preferences with defaults in mind', async () => {
expect(await preferences.numberField.get()).toBe(preferences.defaults.numberField);
expect(await preferences.stringField.get()).toBe(preferences.defaults.stringField);
const randomUpdatedNumber = randomInt(100_000_000);
const randomUpdatedString = randomString();
await preferences.numberField.set(randomUpdatedNumber);
await preferences.stringField.set(randomUpdatedString);
expect(await preferences.numberField.get()).toBe(randomUpdatedNumber);
expect(await preferences.stringField.get()).toBe(randomUpdatedString);
});
});
it('should not store anything unless written into', async () => {
expect(preferences.mockedStorageArea.mockedData).toEqual({});
const randomValue = randomInt(10000000);
await preferences.numberField.set(randomValue);
expect(preferences.mockedStorageArea.mockedData).toEqual({
[preferences.mockedSettingsNamespace]: {
numberField: randomValue,
},
});
});
it('should read from cache on subsequent reads', async () => {
void await preferences.readRaw('numberField', preferences.defaults.numberField);
expect(preferences.mockedStorageArea.get).toHaveBeenCalledOnce();
preferences.mockedStorageArea.get.mockReset();
void await preferences.readRaw('numberField', preferences.defaults.numberField);
expect(preferences.mockedStorageArea.get).not.toHaveBeenCalled();
});
it('should not write if cached value is the same unless forced to', async () => {
const firstValue = randomString();
const secondValue = randomString();
void await preferences.writeRaw('stringField', firstValue);
expect(preferences.mockedStorageArea.set).toHaveBeenCalledOnce();
preferences.mockedStorageArea.set.mockReset();
void await preferences.writeRaw('stringField', firstValue);
expect(preferences.mockedStorageArea.set).not.toHaveBeenCalled();
preferences.mockedStorageArea.set.mockReset();
void await preferences.writeRaw('stringField', secondValue);
expect(preferences.mockedStorageArea.set).toHaveBeenCalledOnce();
preferences.mockedStorageArea.set.mockReset();
void await preferences.writeRaw('stringField', secondValue, true);
expect(preferences.mockedStorageArea.set).toHaveBeenCalledOnce();
});
it('will avoid writing default value if field was accessed previously', async () => {
void await preferences.stringField.get();
void await preferences.stringField.set(preferences.defaults.stringField);
expect(preferences.mockedStorageArea.set).not.toHaveBeenCalled();
expect(preferences.mockedStorageArea.mockedData).toEqual({});
});
it('should notify about changes', async () => {
const subscriber = vi.fn();
preferences.subscribe(subscriber);
const updatedValue = randomString();
await preferences.stringField.set(updatedValue);
expect(subscriber).toHaveBeenCalledWith({
stringField: updatedValue,
});
});
it('should stop sending changes when unsubscribed', async () => {
const subscriber = vi.fn();
const unsubscribe = preferences.subscribe(subscriber);
const updatedValue = randomString();
await preferences.stringField.set(updatedValue);
expect(subscriber).toHaveBeenCalledOnce();
subscriber.mockReset();
unsubscribe();
const secondUpdatedValue = randomString();
await preferences.stringField.set(secondUpdatedValue);
expect(subscriber).not.toHaveBeenCalled();
});
it('should dispose of all subscriptions', async () => {
const subscriber = vi.fn();
preferences.subscribe(subscriber);
await preferences.stringField.set(randomString());
expect(subscriber).toHaveBeenCalledOnce();
subscriber.mockReset();
preferences.dispose();
await preferences.stringField.set(randomString());
expect(subscriber).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,239 @@
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
import StorageHelper from "$lib/browser/StorageHelper";
import { randomString } from "$tests/utils";
import { randomInt } from "crypto";
import EntitiesController from "$lib/extension/EntitiesController";
import { TestedEntity } from "$tests/stubs/Entity";
describe("StorageEntity", () => {
let mockedStorageArea: ChromeStorageArea;
beforeEach(() => {
mockedStorageArea = new ChromeStorageArea();
EntitiesController.storage = new StorageHelper(mockedStorageArea);
});
describe("readAll", () => {
it("should return empty array if no entities stored", async () => {
const entities = await TestedEntity.readAll();
expect(entities).toHaveLength(0);
});
it("should read all entities from storage", async () => {
const entity1 = new TestedEntity(randomString(), {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
});
const entity2 = new TestedEntity(randomString(), {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
});
await entity1.save();
await entity2.save();
const entities = await TestedEntity.readAll();
expect(entities).toHaveLength(2);
expect(entities[0].id).toBe(entity1.id);
expect(entities[0].settings).toEqual(entity1.settings);
expect(entities[1].id).toBe(entity2.id);
expect(entities[1].settings).toEqual(entity2.settings);
});
it("should build entities with correct class", async () => {
const entity = new TestedEntity(randomString(), {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
});
await entity.save();
const [savedEntity] = await TestedEntity.readAll();
expect(savedEntity).toBeInstanceOf(TestedEntity);
});
});
describe("save", () => {
it("should save entity to storage", async () => {
const entity = new TestedEntity(randomString(), {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
});
await entity.save();
expect(mockedStorageArea.mockedData).toEqual({
[TestedEntity._entityName]: {
[entity.id]: entity.settings,
},
});
});
it("should overwrite existing entity with same ID", async () => {
const id = randomString();
const originalSettings = {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
};
const entity1 = new TestedEntity(id, originalSettings);
await entity1.save();
const updatedSettings = {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
};
const entity2 = new TestedEntity(id, updatedSettings);
await entity2.save();
expect(mockedStorageArea.mockedData).toEqual({
[TestedEntity._entityName]: {
[id]: updatedSettings,
},
});
});
});
describe("delete", () => {
it("should delete entity from storage", async () => {
const entity = new TestedEntity(randomString(), {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
});
await entity.save();
expect(mockedStorageArea.mockedData[TestedEntity._entityName]).not.toEqual({});
await entity.delete();
expect(mockedStorageArea.mockedData[TestedEntity._entityName]).toEqual({});
});
it("should not fail if entity does not exist", async () => {
const entity = new TestedEntity(randomString(), {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
});
await expect(entity.delete()).resolves.not.toThrow();
});
});
describe("subscribe", () => {
it("should notify about new entities", async () => {
const subscriber = vi.fn();
void TestedEntity.subscribe(subscriber);
const entity = new TestedEntity(randomString(), {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
});
await entity.save();
// Saving is not notified about immediately.
await vi.waitFor(() => {
expect(subscriber).toHaveBeenCalledOnce();
expect(subscriber).toHaveBeenCalledWith([entity]);
}, {timeout: 10, interval: 1});
});
it("should notify about entity updates", async () => {
const subscriber = vi.fn();
void TestedEntity.subscribe(subscriber);
const entity = new TestedEntity(randomString(), {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
});
await entity.save();
await vi.waitFor(() => {
expect(subscriber).toHaveBeenCalledOnce();
}, {interval: 1, timeout: 10});
subscriber.mockReset();
entity.settings.stringField = randomString();
await entity.save();
await vi.waitFor(() => {
expect(subscriber).toHaveBeenCalledOnce();
expect(subscriber).toHaveBeenCalledWith([entity]);
}, {interval: 1, timeout: 10});
});
it("should notify about entity deletion", async () => {
const subscriber = vi.fn();
void TestedEntity.subscribe(subscriber);
const entity = new TestedEntity(randomString(), {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
});
await entity.save();
await entity.delete();
await vi.waitFor(() => {
expect(subscriber).toHaveBeenCalledTimes(2);
}, {interval: 1, timeout: 10});
expect(subscriber).toHaveBeenCalledWith([entity]);
expect(subscriber).toHaveBeenCalledWith([]);
});
it("should stop notifications after unsubscribe", async () => {
const subscriber = vi.fn();
const unsubscribe = TestedEntity.subscribe(subscriber);
unsubscribe();
const entity = new TestedEntity(randomString(), {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
});
await entity.save();
expect(subscriber).not.toHaveBeenCalled();
});
});
describe("properties", () => {
it("should expose id", () => {
const id = randomString();
const entity = new TestedEntity(id, {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
});
expect(entity.id).toBe(id);
});
it("should expose settings", () => {
const settings = {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
};
const entity = new TestedEntity(randomString(), settings);
expect(entity.settings).toEqual(settings);
});
it("should expose type from _entityName", () => {
const entity = new TestedEntity(randomString(), {
stringField: randomString(),
numberField: randomInt(-100000, 100000),
});
expect(entity.type).toBe(TestedEntity._entityName);
});
});
});
@@ -0,0 +1,111 @@
import {
AndToken,
BoostToken,
GroupEndToken,
GroupStartToken,
NotToken,
OrToken,
QueryLexer,
QuotedTermToken,
TermToken,
Token
} from "$lib/philomena/search/QueryLexer";
describe('QueryLexer', () => {
function parseQuery(query: string): Token[] {
return new QueryLexer(query).parse();
}
function parseQueryTypes(query: string): (typeof Token)[] {
return parseQuery(query)
.map(term => (term.constructor as any) as typeof Token);
}
it('should properly parse different kinds of queries', () => {
expect(parseQueryTypes('safe')).toEqual([TermToken]);
expect(parseQueryTypes('safe^1')).toEqual([TermToken, BoostToken]);
expect(parseQueryTypes('safe, avali')).toEqual([TermToken, AndToken, TermToken]);
expect(parseQueryTypes('!avali')).toEqual([NotToken, TermToken]);
expect(parseQueryTypes('avali || 4 ears')).toEqual([TermToken, OrToken, TermToken]);
expect(parseQueryTypes('avali && !4 ears')).toEqual([TermToken, AndToken, NotToken, TermToken]);
expect(parseQueryTypes('avali AND (NOT 4 ears OR -3 fingers)')).toEqual([
TermToken, AndToken, GroupStartToken, NotToken, TermToken, OrToken, NotToken, TermToken, GroupEndToken,
]);
});
it('should not treat parentheses as groups inside the term', () => {
expect(parseQueryTypes('!(experiment (casualties unknown) || milky (casualties unknown))')).toEqual([
NotToken, GroupStartToken, TermToken, OrToken, TermToken, GroupEndToken,
]);
});
it('should accept any amount of whitespaces between different tokens', () => {
expect(parseQueryTypes('! ( avali , experiment (casualties unknown) ) && safe')).toEqual([
NotToken, GroupStartToken, TermToken, AndToken, TermToken, GroupEndToken, AndToken, TermToken,
]);
});
it('should trim whitespaces inside the terms, even in quoted ones', () => {
const [termWithSpaces] = parseQuery(' avali ');
expect(termWithSpaces.value).toBe('avali');
const [quotedTermWithSpaces] = parseQuery(' " avali " ');
expect(quotedTermWithSpaces instanceof QuotedTermToken && quotedTermWithSpaces.decodedValue || new Error('Wrong token')).toBe('avali');
});
it('should properly differentiate between word-like operators and parts of tags', () => {
expect(parseQueryTypes('safe AND sound')).toEqual([TermToken, AndToken, TermToken]);
expect(parseQueryTypes('NOT safe AND dangerous')).toEqual([NotToken, TermToken, AndToken, TermToken]);
});
it('should only detect word-like operators when spaces are in place', () => {
// Require whitespace between operator and other tokens
expect(parseQueryTypes('NOT safeANDsound')).toEqual([NotToken, TermToken]);
// If none are there, just should treat it as a part of a term
expect(parseQuery('safeAND sound')[0].value).toEqual('safeAND sound');
// All operators should be in all caps, otherwise it's just a term
const [lowercaseOperatorWords] = parseQuery('avali are cute and you know it or else');
expect(lowercaseOperatorWords.value).toBe('avali are cute and you know it or else');
// And if it in caps, but part of some word, then it's just a word
const [wordsInCapsContainingOperators] = parseQuery('THAT POOR KNOT IS PLAIN AS SAND');
expect(wordsInCapsContainingOperators.value).toBe('THAT POOR KNOT IS PLAIN AS SAND');
});
it('should not treat any operators inside the quoted term as actual operators', () => {
const tokens = parseQuery('"this AND that OR these NOT there || () && ^123"');
const [quotedTermToken] = tokens;
expect(tokens).toHaveLength(1);
expect(quotedTermToken instanceof QuotedTermToken && quotedTermToken.decodedValue || null)
.toBe('this AND that OR these NOT there || () && ^123');
});
describe('QuotedTermToken', () => {
it('should decode and encode quotes and backslash', () => {
const encodedQuote = `"term with \\\" inside of it"`;
const decodedQuote = 'term with " inside of it';
expect(QuotedTermToken.decode(encodedQuote)).toBe(decodedQuote);
expect(QuotedTermToken.encode(decodedQuote)).toBe(encodedQuote);
const encodedBackslash = `"term with \\\\ inside of it"`;
const decodedBackslash = 'term with \\ inside of it';
expect(QuotedTermToken.decode(encodedBackslash)).toBe(decodedBackslash);
expect(QuotedTermToken.encode(decodedBackslash)).toBe(encodedBackslash);
});
it('should not care for anything else', () => {
const encodedTerm = '"operators: , && || AND OR NOT ! ^ ? *"';
const decodedTerm = 'operators: , && || AND OR NOT ! ^ ? *';
expect(QuotedTermToken.decode(encodedTerm)).toBe(decodedTerm);
expect(QuotedTermToken.encode(decodedTerm)).toBe(encodedTerm);
});
});
});
+141
View File
@@ -0,0 +1,141 @@
import { URL } from 'url';
import {
buildTagsAndAliasesMap,
resolveTagCategoryFromTagName,
resolveTagNameFromLink,
slugEncodedCharacters
} from '$lib/philomena/tag-utils';
import { randomString } from "$tests/utils";
import { namespaceCategories } from "$config/tags";
const origin = 'https://furbooru.org';
describe('buildTagsAndAliasesMap', () => {
it('should return regular tags if both real and real+alias tags are the same', () => {
const tagsAndAliases = ['avali', 'experiment (casualties unknown)', 'fictional species'];
expect(buildTagsAndAliasesMap(tagsAndAliases, tagsAndAliases)).toMatchInlineSnapshot(`
Map {
"avali" => "avali",
"experiment (casualties unknown)" => "experiment (casualties unknown)",
"fictional species" => "fictional species",
}
`);
});
it('should identify any aliases going after the real tag', () => {
const realTags = ['avali', 'experiment (casualties unknown)', 'fictional species'];
const realAndAliasesTags = ['avali', 'experiment (casualties unknown)', 'experiment (gunsaw)', 'fictional species'];
expect(buildTagsAndAliasesMap(realAndAliasesTags, realTags)).toMatchInlineSnapshot(`
Map {
"avali" => "avali",
"experiment (casualties unknown)" => "experiment (casualties unknown)",
"fictional species" => "fictional species",
"experiment (gunsaw)" => "experiment (casualties unknown)",
}
`);
});
it('should ignore any non-real tags coming before first tag is found', () => {
const outOfOrderTag = randomString();
const realTags = ['avali', 'experiment (casualties unknown)', 'fictional species'];
const realAndAliasesTags = [outOfOrderTag, 'avali', 'experiment (casualties unknown)', 'experiment (gunsaw)', 'fictional species'];
const warn = vi.spyOn(console, 'warn');
expect(buildTagsAndAliasesMap(realAndAliasesTags, realTags)).toMatchInlineSnapshot(`
Map {
"avali" => "avali",
"experiment (casualties unknown)" => "experiment (casualties unknown)",
"fictional species" => "fictional species",
"experiment (gunsaw)" => "experiment (casualties unknown)",
}
`);
expect(warn).toHaveBeenCalledWith(`No real tag found for the alias:`, outOfOrderTag);
});
});
describe('resolveTagNameFromLink', () => {
function resolveFromSearchQuery(encodedQuery: string): string | null {
return resolveTagNameFromLink(new URL(`/search?q=${encodedQuery}`, origin));
}
describe('Parsing from /search/?q=tag links', () => {
it('should resolve a single tag from /search URLs', () => {
expect(resolveFromSearchQuery('safe')).toBe('safe');
});
it('should return null for queries with multiple comma-separated tags', () => {
// Comma acts as a separator in the query, resulting in multiple tokens
expect(resolveFromSearchQuery('safe, suggestive')).toBe(null);
});
it('should return null if query is empty or not a term', () => {
expect(resolveFromSearchQuery('')).toBe(null);
expect(resolveFromSearchQuery('!')).toBe(null);
});
it('should properly treat parentheses in the query with single tag', () => {
// Parentheses are operators in the query language, but when inside the tag name, they should still be properly
// working.
expect(resolveFromSearchQuery('experiment (casualties unknown)')).toBe('experiment (casualties unknown)');
});
it('should properly resolve queries with encoded characters', () => {
expect(resolveFromSearchQuery('pok%C3%A9mon')).toBe('pokémon');
});
it('should unquote quoted term', () => {
expect(resolveFromSearchQuery('"experiment (casualties unknown)"')).toBe('experiment (casualties unknown)')
expect(resolveFromSearchQuery('"single tag, really"')).toBe('single tag, really');
});
})
describe('Parsing from /tags/name links', () => {
function resolveFromTagLink(encodedTagName: string): string | null {
return resolveTagNameFromLink(new URL(`/tags/${encodedTagName}`, origin));
}
it('should resolve a single tag', () => {
expect(resolveFromTagLink('safe')).toBe('safe');
});
it('should only read the tag page even if query is provided', () => {
expect(resolveFromTagLink('grotesque?q=explicit')).toBe('grotesque');
});
it('should properly resolve links with encoded characters', () => {
expect(resolveFromTagLink('pok%C3%A9mon')).toBe('pokémon');
});
it('should decoded slug-encoded characters', () => {
// More common example where tag is.
expect(resolveFromTagLink(`namespace-colon-tag+name`)).toBe('namespace:tag name');
// Testing the whole list of encoded characters.
for (const [encodedCharacter, decodedCharacter] of slugEncodedCharacters.entries()) {
expect(resolveFromTagLink(`test+symbol${encodedCharacter}without+spaces`)).toBe(`test symbol${decodedCharacter}without spaces`);
expect(resolveFromTagLink(`test+symbol+${encodedCharacter}+with+spaces`)).toBe(`test symbol ${decodedCharacter} with spaces`);
}
});
});
it('should return null for unsupported URLs', () => {
expect(resolveTagNameFromLink(new URL('/pages/example', origin))).toBe(null);
});
});
describe('resolveTagCategoryFromTagName', () => {
it('should resolve any known namespace into its known category', () => {
for (const [namespace, category] of namespaceCategories) {
expect(resolveTagCategoryFromTagName(`${namespace}:${randomString()}`)).toBe(category);
}
});
it('should ignore any namespace not listed in config', () => {
expect(resolveTagCategoryFromTagName(`${randomString()}:${randomString()}`)).toBeNull();
});
});
+17
View File
@@ -0,0 +1,17 @@
import StorageEntity from "$lib/extension/base/StorageEntity";
export interface TestedSettings {
stringField: string;
numberField: number;
nested?: {
field: boolean;
};
}
export class TestedEntity extends StorageEntity<TestedSettings> {
static readonly _entityName = "entity";
constructor(id: string, settings: TestedSettings) {
super(id, settings);
}
}
+45
View File
@@ -0,0 +1,45 @@
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
import StorageHelper from "$lib/browser/StorageHelper";
import ConfigurationController from "$lib/extension/ConfigurationController";
export interface TestedFields {
numberField: number;
stringField: string;
}
export class TestedPreferences extends CacheablePreferences<TestedFields> implements WithFields<TestedFields> {
readonly defaults: TestedFields;
readonly mockedSettingsNamespace: string;
readonly mockedStorageArea: ChromeStorageArea;
readonly mockedStorageHelper: StorageHelper;
numberField;
stringField;
constructor(settingsNamespace: string, mockedDefaults: TestedFields) {
const mockedStorageArea = new ChromeStorageArea();
const mockedStorageHelper = new StorageHelper(mockedStorageArea);
const mockedConfigurationController = new ConfigurationController(
settingsNamespace,
mockedStorageHelper,
);
super(settingsNamespace, mockedConfigurationController);
this.mockedSettingsNamespace = settingsNamespace;
this.mockedStorageArea = mockedStorageArea;
this.mockedStorageHelper = mockedStorageHelper;
this.defaults = mockedDefaults;
this.numberField = new PreferenceField(this, {
field: 'numberField',
defaultValue: this.defaults.numberField,
});
this.stringField = new PreferenceField(this, {
field: 'stringField',
defaultValue: this.defaults.stringField,
});
}
}