Compare commits

..
131 Commits
Author SHA1 Message Date
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
koloml f0083169f3 Merge pull request #164 from koloml/release/0.7.0
Release: 0.7.0
2026-03-14 21:58:02 +04:00
koloml a416befcff Bumped version to 0.7.0 2026-03-14 21:57:10 +04:00
koloml d840070a3e Updated dependencies (#166)
* Updated `vitest` and `@vitest/coverage-v8` from 4.0.18 to 4.1.0

* Updated `svelte` from 5.53.3 to 5.53.12

* Updated `@sveltejs/kit` from 2.53.0 to 2.55.0

* Updated `svelte-check` from 4.4.3 to 4.4.5

* Updated `sass` from 1.97.3 to 1.98.0

* Updated `@types/node` from 25.3.0 to 25.5.0
2026-03-14 21:54:23 +04:00
koloml 0bf2a35e9b Added links to Tantabus version of extension 2026-03-14 20:20:35 +04:00
koloml 8889c300a0 Merge pull request #163 from koloml/feature/tag-presets
Added Tag Presets for the image upload & edit pages
2026-03-14 20:10:52 +04:00
koloml 1a542e0fb7 Merge pull request #162 from koloml/feature/different-icons
Updated icons for different extensions versions
2026-03-14 20:07:54 +04:00
koloml b973947070 Fixed presets not refreshing after tag loading/clearing 2026-03-14 20:06:12 +04:00
koloml 7821cebb1b Added missed deletion interface for the presets 2026-03-14 19:37:10 +04:00
koloml 71fb565247 Sort list of presets and their tags more consistently 2026-03-14 19:15:59 +04:00
koloml 0ba81b1509 Separate icons for extensions, support replacing icons on build 2026-03-14 18:59:03 +04:00
koloml f9fb2d66b8 Don't warn about manifest replacements without ENV variable 2026-03-14 18:31:26 +04:00
koloml 2d7db61a76 Reusing details block and tags list components on Profile & Group views 2026-03-12 01:43:14 +04:00
koloml 4a3d7a1bb0 Fixing build warning on new details block 2026-03-12 01:42:59 +04:00
koloml 939b5fec20 Properly refresh the tag category colors after preset changes applied 2026-03-12 01:33:14 +04:00
koloml d7b7aa5b98 Compensating for possible layout shift after toggling tags from preset 2026-03-12 01:22:03 +04:00
koloml 7f41a7e6f0 Fixed tag removal stopping on the first encountered missing tag
Syntax of `-tagname` we rely upon here will stop execution of suggested
tags when first tag which is not present in the editor is encountered.
Just a Philomena's specific quirk, nothing more.
2026-03-12 01:07:37 +04:00
koloml 2a2a488592 Added presets and tag editor handling to the image upload page 2026-03-12 00:57:23 +04:00
koloml bda707b5ac Builder: Support replacing domains inside exclude_matches 2026-03-12 00:53:45 +04:00
koloml 74866949bb Added Tag Presets, popup editor for them, implemented presets image edit 2026-03-12 00:17:45 +04:00
koloml 6c2ef795b3 Merge pull request #161 from koloml/feature/tagging-profiles-code-renaming
Refactoring: Renaming the entity classes, updating the API for accessing preferences values
2026-03-07 17:38:11 +04:00
koloml 58b620ef09 Renaming Philomena and scraping-related classes directory 2026-03-07 17:35:38 +04:00
koloml 9445b1e862 Restructuring and renaming content components and their initialization 2026-03-07 17:22:13 +04:00
koloml 9024883949 Refactoring how preferences classes provide access to fields inside
Instead of constantly implementing these weird methods to read or update
values, there will be fields inside the preferences which contain
methods to read or update them.
2026-03-07 06:41:28 +04:00
koloml dc29c6ca69 Renaming for tagging profiles and preferences classes 2026-02-28 22:49:57 +04:00
koloml 441091142c Added screenshot preview of tag link replacement 2026-02-26 15:22:52 +04:00
koloml 94733c9ff3 Merge pull request #160 from koloml/release/0.6.1
Release: 0.6.1
2026-02-26 04:27:47 +04:00
koloml d11cc2a9c5 Bumped version to 0.6.1 2026-02-26 04:27:01 +04:00
koloml f4e30c60ad Merge pull request #159 from koloml/feature/support-replacing-tag-link-texts
Tag Links: Support replaceting text of links to the decoded tag name
2026-02-26 03:38:08 +04:00
koloml 9031055ec9 Replace the tag link text to resolved tag name when possible 2026-02-26 03:30:10 +04:00
koloml 8194a84ef7 Fix pluses not being decoded from the path 2026-02-26 03:28:48 +04:00
koloml 2829ac022f Merge pull request #157 from koloml/feature/tantabus-namespaces
Tantabus: Adding 3 more namespaces unique to Tantabus for auto-coloring of tags
2026-02-26 02:39:59 +04:00
koloml 5aac85dcaa Merge pull request #158 from koloml/feature/support-tag-links-from-search
Tag Links Replacement: Support links pointintg to `/search?q=`
2026-02-26 02:39:50 +04:00
koloml 9a14a5568d Merge pull request #156 from koloml/bugfix/tantabus-swaps-in-content-scripts
Tantabus: Fixed content scripts not properly receiving proper constants
2026-02-26 02:35:37 +04:00
koloml a2ab0d4e7c Adding 3 more namespaces unique to Tantabus 2026-02-26 02:34:06 +04:00
koloml 5123b57320 Fixed content scripts not properly receiving Tantabus constant 2026-02-26 02:33:15 +04:00
koloml 2bdb789777 Support links to /search?q= when detecting tag links to replace 2026-02-25 20:47:22 +04:00
145 changed files with 5971 additions and 2191 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

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/
+11
View File
@@ -174,6 +174,15 @@ export async function buildScriptsAndStyles(buildOptions) {
}
});
const tantabusSwapPlugin = SwapDefinedVariablesPlugin({
envVariable: 'SITE',
expectedValue: 'tantabus',
define: {
__CURRENT_SITE__: JSON.stringify('tantabus'),
__CURRENT_SITE_NAME__: JSON.stringify('Tantabus'),
}
});
// Building all scripts together with AMD loader in mind
await build({
configFile: false,
@@ -209,6 +218,7 @@ export async function buildScriptsAndStyles(buildOptions) {
?.push(...dependencies);
}),
derpibooruSwapPlugin,
tantabusSwapPlugin,
],
define: defineConstants,
});
@@ -235,6 +245,7 @@ export async function buildScriptsAndStyles(buildOptions) {
wrapScriptIntoIIFE(),
ScssViteReadEnvVariableFunctionPlugin(),
derpibooruSwapPlugin,
tantabusSwapPlugin,
],
define: defineConstants,
});
+33 -11
View File
@@ -96,21 +96,16 @@ class ManifestProcessor {
singleOrMultipleHostnames = [singleOrMultipleHostnames];
}
const matchPatterReplacer = ManifestProcessor.#createHostnameReplacementReduce(singleOrMultipleHostnames);
this.#manifestObject.host_permissions = singleOrMultipleHostnames.map(hostname => `*://*.${hostname}/`);
this.#manifestObject.content_scripts?.forEach(entry => {
entry.matches = entry.matches.reduce((resultMatches, originalMatchPattern) => {
for (const updatedHostname of singleOrMultipleHostnames) {
resultMatches.push(
originalMatchPattern.replace(
/\*:\/\/\*\.[a-z]+\.[a-z]+\//,
`*://*.${updatedHostname}/`
),
);
}
entry.matches = entry.matches.reduce(matchPatterReplacer, []);
return resultMatches;
}, []);
if (entry.exclude_matches) {
entry.exclude_matches = entry.exclude_matches.reduce(matchPatterReplacer, []);
}
})
}
@@ -148,6 +143,32 @@ class ManifestProcessor {
}
);
}
/**
* @param {string|(string[])} singleOrMultipleHostnames
* @return {function(string[], string): string[]}
*/
static #createHostnameReplacementReduce(singleOrMultipleHostnames) {
return (
/**
* @param {string[]} resultMatches
* @param {string} originalMatchPattern
* @return {string[]}
*/
(resultMatches, originalMatchPattern) => {
for (const updatedHostname of singleOrMultipleHostnames) {
resultMatches.push(
originalMatchPattern.replace(
/\*:\/\/\*\.[a-z]+\.[a-z]+\//,
`*://*.${updatedHostname}/`
),
);
}
return resultMatches;
}
);
}
}
/**
@@ -186,6 +207,7 @@ export function loadManifest(filePath) {
/**
* @typedef {Object} ContentScriptsEntry
* @property {string[]} matches
* @property {string[]} exclude_matches
* @property {string[]|undefined} js
* @property {string[]|undefined} css
*/
+45 -1
View File
@@ -3,6 +3,7 @@ import path from "path";
import { buildScriptsAndStyles } from "./lib/content-scripts.js";
import { extractInlineScriptsFromIndex } from "./lib/index-file.js";
import { normalizePath } from "vite";
import fs from "fs";
/**
* Build addition assets required for the extension and pack it into the directory.
@@ -84,12 +85,55 @@ export async function packExtension(settings) {
break;
default:
console.warn('No replacement set up for site: ' + process.env.SITE);
if (process.env.SITE) {
console.warn('No replacement set up for site: ' + process.env.SITE);
}
}
manifest.passVersionFromPackage(path.resolve(settings.rootDir, 'package.json'));
manifest.saveTo(path.resolve(settings.exportDir, 'manifest.json'));
const iconsDirectory = path.resolve(settings.exportDir, 'icons');
switch (process.env.SITE) {
case "derpibooru":
case "tantabus":
const siteIconsDirectory = path.resolve(iconsDirectory, process.env.SITE);
if (!fs.existsSync(siteIconsDirectory)) {
console.warn(`Can't find replacement icons for site ${process.env.SITE}`);
break;
}
console.log(`Found replacement icons for ${process.env.SITE}, swapping them...`);
fs.readdirSync(siteIconsDirectory).forEach(fileName => {
const originalIconPath = path.resolve(settings.exportDir, fileName);
const replacementIconPath = path.resolve(siteIconsDirectory, fileName);
if (!fs.existsSync(originalIconPath)) {
console.warn(`Original icon not found: ${originalIconPath}`)
return;
}
fs.rmSync(originalIconPath);
fs.cpSync(replacementIconPath, originalIconPath);
console.log(`Replaced: ${path.relative(settings.rootDir, replacementIconPath)} → ${path.relative(settings.rootDir, originalIconPath)}`);
});
break;
}
if (fs.existsSync(iconsDirectory)) {
console.log('Cleaning up icon replacements directory');
fs.rmSync(iconsDirectory, {
recursive: true,
force: true,
});
}
extractInlineScriptsFromIndex(path.resolve(settings.exportDir, 'index.html'));
}
+12 -2
View File
@@ -9,16 +9,21 @@ tags to them without opening each individual image.
This extension is available for both Chromium- and Firefox-based browsers. You can find the links to the extension pages
below.
### Furbooru Tagging Assistant
### Furbooru
[![Get the Add-on on Firefox](.github/assets/firefox.png)](https://addons.mozilla.org/en-US/firefox/addon/furbooru-tagging-assistant/)
[![Get the extension on Chrome](.github/assets/chrome.png)](https://chromewebstore.google.com/detail/kpgaphaooaaodgodmnkamhmoedjcnfkj)
### Derpibooru Tagging Assistant
### Derpibooru
[![Get the Add-on on Firefox](.github/assets/firefox.png)](https://addons.mozilla.org/en-US/firefox/addon/derpibooru-tagging-assistant/)
[![Get the extension on Chrome](.github/assets/chrome.png)](https://chromewebstore.google.com/detail/pnmbomcdbfcghgmegklfofncfigdielb)
### Tantabus
[![Get the Add-on on Firefox](.github/assets/firefox.png)](https://addons.mozilla.org/en-US/firefox/addon/tantabus-tagging-assistant/)
[![Get the extension on Chrome](.github/assets/chrome.png)](https://chromewebstore.google.com/detail/jpfkohpgdnpabpjafgagonghknaiecih)
## Features
### Tagging Profiles
@@ -44,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:
+34 -4
View File
@@ -1,7 +1,7 @@
{
"name": "Furbooru Tagging Assistant",
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
"version": "0.6.0.1",
"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"
]
},
{
@@ -50,11 +52,16 @@
"matches": [
"*://*.furbooru.org/images/*"
],
"exclude_matches": [
"*://*.furbooru.org/images/new",
"*://*.furbooru.org/images/new?*"
],
"js": [
"src/content/tags-editor.ts"
],
"css": [
"src/styles/content/tags-editor.scss"
"src/styles/content/tags-editor.scss",
"src/styles/content/tag-presets.scss"
]
},
{
@@ -87,6 +94,29 @@
"css": [
"src/styles/content/posts.scss"
]
},
{
"matches": [
"*://*.furbooru.org/images/new"
],
"js": [
"src/content/upload.ts"
],
"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": {
+756 -454
View File
File diff suppressed because it is too large. Load diff
+16 -13
View File
@@ -1,6 +1,6 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.6.0.1",
"version": "0.8.0",
"private": true,
"type": "module",
"scripts": {
@@ -16,24 +16,27 @@
},
"dependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.53.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.97.3",
"svelte": "^5.53.3"
"sass": "^1.105.1",
"svelte": "^5.57.2"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@types/chrome": "^0.1.37",
"@types/node": "^25.3.0",
"@vitest/coverage-v8": "^4.0.18",
"@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.3",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18"
"jsdom": "^29.1.1",
"svelte-check": "^4.7.6",
"typescript": "^6.0.3",
"vite": "^7.3.7",
"vitest": "^4.1.11"
},
"engines": {
"node": ">=25"
}
}
+4 -2
View File
@@ -1,7 +1,8 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
import MaintenanceProfile from "$entities/MaintenanceProfile";
import type TaggingProfile from "$entities/TaggingProfile";
import type TagGroup from "$entities/TagGroup";
import type TagEditorPreset from "$entities/TagEditorPreset";
declare global {
/**
@@ -37,8 +38,9 @@ declare global {
);
interface EntityNamesMap {
profiles: MaintenanceProfile;
profiles: TaggingProfile;
groups: TagGroup;
presets: TagEditorPreset;
}
interface ImageURIs {
+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

+57
View File
@@ -0,0 +1,57 @@
# Extension Icon
This folder contains original resources used to make an icon for the extension. Since I'm not really an icon designer, I
ended up just composing the icon from sites logos + the shorthand name of the extension with fancy font. Nothing
special.
## Sources
All resources used for composing an icon are stored here as copies just to not lose anything. Original assets are
sourced from the following places:
- [Derpibooru Logo](https://github.com/derpibooru/philomena/blob/40ffb1b75bd0d96db24fa7c84bce36fcb7f2935f/assets/static/favicon.svg)
- [Furbooru Logo](https://github.com/furbooru/philomena/blob/cbfde406de34734403c06952bcaca51db6df1390/assets/static/favicon.svg)
- [Tantabus Logo](https://github.com/tantabus-ai/philomena/blob/285a7666ae4be46ac4da36bbc9ac8fda9e5c0fc3/assets/static/favicon.svg)
- [RoundFeather Font](https://drive.google.com/file/d/18ggNplAZNYtO4eNtMUpv3XpkeOAxSkxm/view?usp=sharing)
- Made by [allorus162](https://bsky.app/profile/allorus162.bsky.social)
- [Original Bluesky post](https://bsky.app/profile/allorus162.bsky.social/post/3mfqntff4j22i)
## Rendering
**Note:** You don't need to do anything to pack current version of icon to the extension. All icons are already pre-rendered and
placed into the `static` directory.
For now, any change to the icons will require manual re-rendering of PNG versions of the logos used when packing
extension for the release. All you need is to open `/src/assets/icon/icon.svg` in software like Inskape, hide the
currently opened logo and toggle the required one and save it into `icon256.png`, `icon128.png`, `icon48.png` and
`icon16.png`.
For the font on the bottom-right to work, you will need to install it from the file
`src/assets/icon/fonts/roundfeather-regular-1.001.ttf` (or you can download and install it from the source link).
You should render them into `/static` directory in the following structure:
- Place Furbooru icons into `/static` directory
- Then add same icons for Derpibooru and Tantabus into `/static/icons/depribooru` and `/static/icons/tantabus`
respectively.
Resulting structure will look like this:
```
static/
icons/
derpibooru/
icon16.png
icon48.png
icon128.png
icon256.png
tantabus/
icon16.png
icon48.png
icon128.png
icon256.png
icon16.png
icon48.png
icon128.png
icon256.png
```
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="132.29mm" height="132.29mm" version="1.1" viewBox="0 0 132.29 132.29" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(1.0427 0 0 1.0427 -.017147 -1.1711)"><path d="m103.79 1.5c2.226 55.884-2.592 66.082-26.774 81.11-10.46 6.495-24.44-7.2571-36.56-5.1501-14.529 2.53-33.119 12.655-39.169 20.603l0.00607-0.0012c56.82 1.164 44.414 29.924 80.7 29.64 21.39-0.17 35.89-18.483 38.62-27.615 10.456-34.942 3.1379-65.073-16.823-98.587zm11.375 57.714c-1.6412 10.692-0.54281 11.478 6.66 16.938-8.649-2.0114-10.977 0.7243-14.702 8.2179 1.5999-8.243 0.4741-11.62-5.8526-15.099 8.3149 0.88078 9.4155 0.05081 13.895-10.057zm-34.884 33.692c-3.7887 11.898-1.8578 13.462 6.4355 18.092-12.032-2.4927-11.44 1.5364-16.965 7.6122 2.9076-9.5873 1.2336-12.084-5.6426-16.122 5.6959 0.20558 11.418 1.8392 16.172-9.5819z" style="fill:#73d6ed"/><path d="m69.863 33.123-42.793 78.941 5.4648 2.9629 42.791-78.943z" style="-inkscape-stroke:none;color:#000000;fill:#73d6ed"/><g style="fill:#73d6ed"><path d="m64.894 48.218 7.18-13.796" style="-inkscape-stroke:none;color:#000000;fill:#73d6ed;stroke-width:6.557"/></g><path d="m89.844 3.2499-14.504 13.697-16.245-10.04 7.31 17.535-16.03 14.152 21.282-2.9352 8.782 19.4 2.8299-22.61 19.26-1.614-17.67-8.8749zm-7.235 13.028-1.5585 8.3965 7.2681 3.9461-8.1864 1.1558-1.6952 9.6819-3.8619-8.8428-9.5827 1.4895 7.3746-6.5192-3.4781-7.6725 7.4099 4.1481z" style="fill:#73d6ed"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="132.29mm" height="132.29mm" version="1.1" viewBox="0 0 132.29 132.29" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.13264 0 0 .13264 -.54013 .21654)"><path d="m396.42 189.81c-51.874-0.0769-99.544 21.731-131.29 68.784 3.6141-17.709 12.731-35.245 20.313-51.85-64.951 23.01-110.98 58.779-153.95 130.96 31.861 78.452 47.007 130.73 32.991 180.63-12.789 45.531-57.874 81.129-120.66 54.031 11.733 79.157 138.9 169.44 265.13 95.148-15.402 34.788-33.651 44.104-58.67 59.609 109.87 18.28 240.46-16.733 327.04-89.364 80.473-67.509 145.76-176.62 150.49-271.1-16.925 5.2436-48.396 10.423-61.314 8.6977-4.9416-13.958-6.5445-47.664-6.6378-65.502-78.741 1.4559-134.21-82.398-225.29-56.079 24.483-19.73 56.853-19.667 84.992-25.658-39.916-24.957-82.805-38.256-123.15-38.315zm46.019 201.23c33.445 0.29408 70.846 13.949 108.14 44.486-71.827-12.193-167.68 9.8684-228.78 100.49-3.4843-86.066 49.678-145.6 120.65-144.98z" style="fill:#9a91d9"/><path d="m70.559 99.836c-11.606 103.12 31.76 196.05 73.743 312.81 47.028 130.79-54.846 190.58-100.97 125.58-46.123-65.005-59.672-280.22 27.226-438.39z" style="fill:#9a91d9"/><path d="m126.15 320.07s-84.457-177.47-13.898-310.03c12.18 95.304 37.741 170.41 85.526 228.25-34.102 31.125-51.147 53.357-71.628 81.784z" style="fill:#9a91d9"/><path d="m54.523 683.55c36.454 189.97 245.77 300.87 372.04 295.07-11.047-9.7005-22.094-18.617-33.141-31.003 95.291 43.85 187.43 17.122 251.23-12.829-16.164-4.6041-32.272-9.3803-47.039-18.174 21.351-4.409 43.671-15.588 59.868-33.141-52.566-1.4772-102.82-10.573-151.86-54.928 57.575-28.86 90.002-66.925 102.7-97.767-13.158 6.0202-27.475 9.3163-40.636 10.507 12.007-23.538 20.064-48.835 23.52-78.043-232.6 178.14-441.6 75.628-536.68 20.312z" style="fill:#9a91d9"/><path d="m611.51 653.89c-3.4653 21.491-9.3328 46.627-17.472 65.294 25.751-12.728 37.33-30.294 47.406-48.456 0 0-10.691 113.32-106.91 158.22 0 0 57.784 50.56 163.62 32.385-12.482 20.32-26.396 37.375-64.404 55.584 57.955 10.976 153.12-24.053 185.6-80.951-28.742 9.1492-48.987 9.8028-69.933 11.156 160.72-63.788 92.562-249.91 248.03-342.1-61.514-30.693-156.71-52.064-253.37 42.763 9.4346-13.824 22.374-34.745 43.832-56.661-72.65 21.206-114.21 112.97-176.4 162.77z" style="fill:#9a91d9"/><path d="m676.96 308.22c-2.2133 13.699-3.9692 34.942 1.8899 51.493 15.962 2.1315 36.687-1.0078 49.243-8.4218-0.26672-23.09-4.5591-41.009-10.829-57.596-12.902 6.3961-22.273 10.895-40.304 14.525z" style="fill:#9a91d9"/></g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="250mm"
height="250mm"
version="1.1"
viewBox="0 0 250 250"
xml:space="preserve"
id="svg10"
sodipodi:docname="favicon.svg"
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
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"><defs
id="defs14" /><sodipodi:namedview
id="namedview12"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.46847003"
inkscape:cx="662.79587"
inkscape:cy="691.61308"
inkscape:window-width="2048"
inkscape:window-height="1403"
inkscape:window-x="1966"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="svg10" /><path
id="circle4"
style="fill:#b189d7;stroke-width:1.0041"
d="M 43.466348 113.64503 A 112.33 112.33 0 0 1 40.650026 88.648915 A 112.33 112.33 0 0 1 40.918168 81.481475 A 81.915001 81.915001 0 0 0 121.44996 148.71379 A 81.915001 81.915001 0 0 0 203.36481 66.799192 A 81.915001 81.915001 0 0 0 121.45008 -15.116154 A 81.915001 81.915001 0 0 0 107.06707 -13.789539 A 112.33 112.33 0 0 1 152.97993 -23.681174 A 112.33 112.33 0 0 1 265.31002 88.648731 A 112.33 112.33 0 0 1 152.98012 200.97882 A 112.33 112.33 0 0 1 43.466348 113.64503 z "
transform="matrix(.25882 .96593 .96593 -.25882 0 0)" /><path
d="m120.78 137.49c-13.186 22.457-39.753 18.697-46.615-10.102-3.3594-14.101 17.903-33.046 13.75-51.609-2.9261-13.079 16.12-43.432 15.115-40.727-5.9218 15.937-6.5312 30.238 1.25 42.264 16.946 26.186 24.595 46.387 16.499 60.175z"
style="fill:#b189d7;stroke-linecap:round;stroke-linejoin:round;stroke-width:2.0644"
id="path8" /></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.
+115
View File
@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="500"
height="500"
version="1.1"
viewBox="0 0 132.29167 132.29167"
xml:space="preserve"
id="svg6"
sodipodi:docname="icon.svg"
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
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"><defs
id="defs6" /><sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1"
inkscape:cx="179"
inkscape:cy="388.49999"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg6"
showguides="false" /><g
id="g19"
transform="matrix(0.58884997,0,0,0.58884997,-7.6052124,-7.356684)"
inkscape:label="tantabus"
style="display:none"><path
id="circle4"
style="fill:#b189d7;stroke-width:1.0041"
d="m 43.466348,113.64503 a 112.33,112.33 0 0 1 -2.816322,-24.996115 112.33,112.33 0 0 1 0.268142,-7.16744 81.915001,81.915001 0 0 0 80.531792,67.232315 81.915001,81.915001 0 0 0 81.91485,-81.914598 81.915001,81.915001 0 0 0 -81.91473,-81.915346 81.915001,81.915001 0 0 0 -14.38301,1.326615 112.33,112.33 0 0 1 45.91286,-9.891635 A 112.33,112.33 0 0 1 265.31002,88.648731 112.33,112.33 0 0 1 152.98012,200.97882 112.33,112.33 0 0 1 43.466348,113.64503 Z"
transform="matrix(0.25882,0.96593,0.96593,-0.25882,0,0)" /><path
d="M 120.78,137.49 C 107.594,159.947 81.027,156.187 74.165,127.388 70.8056,113.287 92.068,94.342 87.915,75.779 84.9889,62.7 104.035,32.347 103.03,35.052 c -5.9218,15.937 -6.5312,30.238 1.25,42.264 16.946,26.186 24.595,46.387 16.499,60.175 z"
style="fill:#b189d7;stroke-width:2.0644;stroke-linecap:round;stroke-linejoin:round"
id="path8" /></g><g
transform="matrix(1.048236,0,0,1.048236,-0.23527111,-1.5720411)"
id="g4"
inkscape:label="derpibooru"
style="display:none"><path
d="m 103.79,1.5 c 2.226,55.884 -2.592,66.082 -26.774,81.11 -10.46,6.495 -24.44,-7.2571 -36.56,-5.1501 -14.529,2.53 -33.119,12.655 -39.169,20.603 l 0.00607,-0.0012 c 56.82,1.164 44.414,29.924 80.7,29.64 21.39,-0.17 35.89,-18.483 38.62,-27.615 10.456,-34.942 3.1379,-65.073 -16.823,-98.587 z m 11.375,57.714 c -1.6412,10.692 -0.54281,11.478 6.66,16.938 -8.649,-2.0114 -10.977,0.7243 -14.702,8.2179 1.5999,-8.243 0.4741,-11.62 -5.8526,-15.099 8.3149,0.88078 9.4155,0.05081 13.895,-10.057 z M 80.281,92.906 c -3.7887,11.898 -1.8578,13.462 6.4355,18.092 -12.032,-2.4927 -11.44,1.5364 -16.965,7.6122 2.9076,-9.5873 1.2336,-12.084 -5.6426,-16.122 5.6959,0.20558 11.418,1.8392 16.172,-9.5819 z"
style="fill:#73d6ed"
id="path1-0" /><path
d="m 69.863,33.123 -42.793,78.941 5.4648,2.9629 42.791,-78.943 z"
style="color:#000000;fill:#73d6ed;-inkscape-stroke:none"
id="path2-9" /><g
style="fill:#73d6ed"
id="g3"><path
d="m 64.894,48.218 7.18,-13.796"
style="color:#000000;fill:#73d6ed;stroke-width:6.557;-inkscape-stroke:none"
id="path3-4" /></g><path
d="m 89.844,3.2499 -14.504,13.697 -16.245,-10.04 7.31,17.535 -16.03,14.152 21.282,-2.9352 8.782,19.4 2.8299,-22.61 19.26,-1.614 -17.67,-8.8749 z m -7.235,13.028 -1.5585,8.3965 7.2681,3.9461 -8.1864,1.1558 -1.6952,9.6819 -3.8619,-8.8428 -9.5827,1.4895 7.3746,-6.5192 -3.4781,-7.6725 7.4099,4.1481 z"
style="fill:#73d6ed"
id="path4-8" /></g><g
transform="matrix(0.13355808,0,0,0.13355808,-0.92543932,0.1095915)"
id="g6"
inkscape:label="furbooru"
style="display:inline"><path
d="m 396.42,189.81 c -51.874,-0.0769 -99.544,21.731 -131.29,68.784 3.6141,-17.709 12.731,-35.245 20.313,-51.85 -64.951,23.01 -110.98,58.779 -153.95,130.96 31.861,78.452 47.007,130.73 32.991,180.63 -12.789,45.531 -57.874,81.129 -120.66,54.031 11.733,79.157 138.9,169.44 265.13,95.148 -15.402,34.788 -33.651,44.104 -58.67,59.609 109.87,18.28 240.46,-16.733 327.04,-89.364 80.473,-67.509 145.76,-176.62 150.49,-271.1 -16.925,5.2436 -48.396,10.423 -61.314,8.6977 -4.9416,-13.958 -6.5445,-47.664 -6.6378,-65.502 -78.741,1.4559 -134.21,-82.398 -225.29,-56.079 24.483,-19.73 56.853,-19.667 84.992,-25.658 -39.916,-24.957 -82.805,-38.256 -123.15,-38.315 z m 46.019,201.23 c 33.445,0.29408 70.846,13.949 108.14,44.486 -71.827,-12.193 -167.68,9.8684 -228.78,100.49 -3.4843,-86.066 49.678,-145.6 120.65,-144.98 z"
style="fill:#9a91d9"
id="path1"
inkscape:label="head" /><path
d="m 70.559,99.836 c -11.606,103.12 31.76,196.05 73.743,312.81 47.028,130.79 -54.846,190.58 -100.97,125.58 C -2.791,473.221 -16.34,258.006 70.558,99.836 Z"
style="fill:#9a91d9"
id="path2"
inkscape:label="rightear" /><path
d="m 126.15,320.07 c 0,0 -84.457,-177.47 -13.898,-310.03 12.18,95.304 37.741,170.41 85.526,228.25 -34.102,31.125 -51.147,53.357 -71.628,81.784 z"
style="fill:#9a91d9"
id="path3"
inkscape:label="leftear" /><path
d="m 54.523,683.55 c 36.454,189.97 245.77,300.87 372.04,295.07 -11.047,-9.7005 -22.094,-18.617 -33.141,-31.003 95.291,43.85 187.43,17.122 251.23,-12.829 -16.164,-4.6041 -32.272,-9.3803 -47.039,-18.174 21.351,-4.409 43.671,-15.588 59.868,-33.141 -52.566,-1.4772 -102.82,-10.573 -151.86,-54.928 57.575,-28.86 90.002,-66.925 102.7,-97.767 -13.158,6.0202 -27.475,9.3163 -40.636,10.507 12.007,-23.538 20.064,-48.835 23.52,-78.043 -232.6,178.14 -441.6,75.628 -536.68,20.312 z"
style="fill:#9a91d9"
id="path4"
inkscape:label="tail" /><path
d="m 611.51,653.89 c -3.4653,21.491 -9.3328,46.627 -17.472,65.294 25.751,-12.728 37.33,-30.294 47.406,-48.456 0,0 -10.691,113.32 -106.91,158.22 0,0 57.784,50.56 163.62,32.385 -12.482,20.32 -26.396,37.375 -64.404,55.584 57.955,10.976 153.12,-24.053 185.6,-80.951 -28.742,9.1492 -48.987,9.8028 -69.933,11.156 160.72,-63.788 92.562,-249.91 248.03,-342.1 -61.514,-30.693 -156.71,-52.064 -253.37,42.763 9.4346,-13.824 22.374,-34.745 43.832,-56.661 -72.65,21.206 -114.21,112.97 -176.4,162.77 z"
style="fill:#9a91d9"
id="path5"
inkscape:label="tailend" /><path
d="m 676.96,308.22 c -2.2133,13.699 -3.9692,34.942 1.8899,51.493 15.962,2.1315 36.687,-1.0078 49.243,-8.4218 -0.26672,-23.09 -4.5591,-41.009 -10.829,-57.596 -12.902,6.3961 -22.273,10.895 -40.304,14.525 z"
style="fill:#9a91d9"
id="path6"
inkscape:label="nose" /></g><g
id="g18"
inkscape:label="badge"
style="display:inline"
transform="matrix(0.9615385,0,0,0.9615385,66.145836,5.0881347)"><rect
style="opacity:1;fill:#1b3c21;fill-opacity:1;stroke:none;stroke-width:1.42664;stroke-linecap:round;stroke-linejoin:round"
id="rect6"
width="68.791664"
height="44.450001"
x="-2.5431316e-06"
y="87.841667"
ry="5.2916665"
inkscape:label="bg" /><text
xml:space="preserve"
style="font-size:34.0036px;line-height:0.85;font-family:sans-serif;text-align:center;text-anchor:middle;fill:#4aa158;fill-opacity:1;stroke-width:0.261214"
x="34.024727"
y="128.18593"
id="text11"
transform="scale(1.0109068,0.98921086)"
inkscape:label="text"><tspan
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:34.0036px;line-height:0.85;font-family:Roundfeather;-inkscape-font-specification:Roundfeather;fill:#4aa158;fill-opacity:1;stroke-width:0.261214"
x="34.024727"
y="128.18593"
id="tspan12"
sodipodi:role="line">PTA</tspan></text></g></svg>

After

Width:  |  Height:  |  Size: 7.8 KiB

+14 -45
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
import type TagGroup from "$entities/TagGroup";
import DetailsBlock from "$components/ui/DetailsBlock.svelte";
import TagsList from "$components/tags/TagsList.svelte";
interface GroupViewProps {
group: TagGroup;
@@ -14,60 +16,27 @@
</script>
<div class="block">
<strong>Group Name:</strong>
<div>{group.settings.name}</div>
</div>
<DetailsBlock title="Group Name">
{group.settings.name}
</DetailsBlock>
{#if sortedTagsList.length}
<div class="block">
<strong>Tags:</strong>
<DetailsBlock title="Tags">
<TagsColorContainer targetCategory={group.settings.category}>
<div class="tags-list">
{#each sortedTagsList as tagName}
<span class="tag">{tagName}</span>
{/each}
</div>
<TagsList tags={sortedTagsList} />
</TagsColorContainer>
</div>
</DetailsBlock>
{/if}
{#if sortedPrefixes.length}
<div class="block">
<strong>Prefixes:</strong>
<DetailsBlock title="Prefixes">
<TagsColorContainer targetCategory={group.settings.category}>
<div class="tags-list">
{#each sortedPrefixes as prefixName}
<span class="tag">{prefixName}*</span>
{/each}
</div>
<TagsList tags={sortedPrefixes} append="*" />
</TagsColorContainer>
</div>
</DetailsBlock>
{/if}
{#if sortedSuffixes.length}
<div class="block">
<strong>Suffixes:</strong>
<DetailsBlock title="Suffixes">
<TagsColorContainer targetCategory={group.settings.category}>
<div class="tags-list">
{#each sortedSuffixes as suffixName}
<span class="tag">*{suffixName}</span>
{/each}
</div>
<TagsList tags={sortedSuffixes} prepend="*" />
</TagsColorContainer>
</div>
</DetailsBlock>
{/if}
<style lang="scss">
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.block + .block {
margin-top: .5em;
strong {
display: block;
margin-bottom: .25em;
}
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type TagEditorPreset from "$entities/TagEditorPreset";
import DetailsBlock from "$components/ui/DetailsBlock.svelte";
import TagsList from "$components/tags/TagsList.svelte";
interface PresetViewProps {
preset: TagEditorPreset;
}
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">
{preset.settings.name}
</DetailsBlock>
<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}
+10 -31
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import type MaintenanceProfile from "$entities/MaintenanceProfile";
import type TaggingProfile from "$entities/TaggingProfile";
import DetailsBlock from "$components/ui/DetailsBlock.svelte";
import TagsList from "$components/tags/TagsList.svelte";
interface ProfileViewProps {
profile: MaintenanceProfile;
profile: TaggingProfile;
}
let { profile }: ProfileViewProps = $props();
@@ -10,32 +12,9 @@
const sortedTagsList = $derived(profile.settings.tags.sort((a, b) => a.localeCompare(b)));
</script>
<div class="block">
<strong>Profile:</strong>
<div>{profile.settings.name}</div>
</div>
<div class="block">
<strong>Tags:</strong>
<div class="tags-list">
{#each sortedTagsList as tagName}
<span class="tag">{tagName}</span>
{/each}
</div>
</div>
<style lang="scss">
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.block + .block {
margin-top: .5em;
strong {
display: block;
margin-bottom: .25em;
}
}
</style>
<DetailsBlock title="Profile">
{profile.settings.name}
</DetailsBlock>
<DetailsBlock title="Tags">
<TagsList tags={sortedTagsList} />
</DetailsBlock>
+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>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
interface TagsListProps {
tags: string[];
prepend?: string;
append?: string;
}
let { tags, prepend, append }: TagsListProps = $props();
</script>
<div class="tags-list">
{#each tags as tagName}
<div class="tag">{prepend || ''}{tagName}{append || ''}</div>
{/each}
</div>
<style lang="scss">
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface DetailsBlockProps {
title?: string;
children?: Snippet;
}
let { title, children }: DetailsBlockProps = $props();
</script>
<div class="block">
{#if title?.length}
<strong>{title}:</strong>
{/if}
<div>
{@render children?.()}
</div>
</div>
<style lang="scss">
.block strong {
display: block;
margin-bottom: .25em;
}
.block + :global(.block) {
margin-top: .5em;
}
</style>
+46 -23
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,17 +29,22 @@ 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'],
['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]
] : [])
]);
/**
@@ -123,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',
];
@@ -1,65 +0,0 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import TagSettings from "$lib/extension/settings/TagSettings";
import { getComponent } from "$content/components/base/component-utils";
import { decodeTagNameFromLink, resolveTagCategoryFromTagName } from "$lib/booru/tag-utils";
export class BlockCommunication extends BaseComponent {
#contentSection: HTMLElement | null = null;
#tagLinks: HTMLAnchorElement[] = [];
#tagLinksReplaced: boolean | null = null;
protected build() {
this.#contentSection = this.container.querySelector('.communication__content');
this.#tagLinks = this.#findAllTagLinks();
}
protected init() {
BlockCommunication.#tagSettings.resolveReplaceLinks().then(this.#onReplaceLinkSettingResolved.bind(this));
BlockCommunication.#tagSettings.subscribe(settings => {
this.#onReplaceLinkSettingResolved(settings.replaceLinks ?? false);
});
}
#onReplaceLinkSettingResolved(haveToReplaceLinks: boolean) {
if (!this.#tagLinks.length || this.#tagLinksReplaced === haveToReplaceLinks) {
return;
}
for (const linkElement of this.#tagLinks) {
linkElement.classList.toggle('tag', haveToReplaceLinks);
// Sometimes tags are being decorated with the code block inside. It should be fine to replace it right away.
if (linkElement.childElementCount === 1 && linkElement.children[0].tagName === 'CODE') {
linkElement.textContent = linkElement.children[0].textContent;
}
if (haveToReplaceLinks) {
const maybeDecodedTagName = decodeTagNameFromLink(linkElement.pathname) ?? '';
linkElement.dataset.tagCategory = resolveTagCategoryFromTagName(maybeDecodedTagName) ?? '';
} else {
linkElement.dataset.tagCategory = '';
}
}
this.#tagLinksReplaced = haveToReplaceLinks;
}
#findAllTagLinks(): HTMLAnchorElement[] {
return Array
.from(this.#contentSection?.querySelectorAll('a') || [])
.filter(link => link.pathname.startsWith('/tags/'))
}
static #tagSettings = new TagSettings();
static findAndInitializeAll() {
for (const container of document.querySelectorAll<HTMLElement>('.block.communication')) {
if (getComponent(container)) {
continue;
}
new BlockCommunication(container).initialize();
}
}
}
-426
View File
@@ -1,426 +0,0 @@
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI";
import { tagsBlacklist } from "$config/tags";
import { emitterAt } from "$content/components/events/comms";
import {
EVENT_ACTIVE_PROFILE_CHANGED,
EVENT_MAINTENANCE_STATE_CHANGED,
EVENT_TAGS_UPDATED
} from "$content/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$content/components/MediaBoxTools";
import { resolveTagCategoryFromTagName } from "$lib/booru/tag-utils";
class BlackListedTagsEncounteredError extends Error {
constructor(tagName: string) {
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
cause: tagName
});
}
}
export class MaintenancePopup extends BaseComponent {
#tagsListElement: HTMLElement = document.createElement('div');
#tagsList: HTMLElement[] = [];
#suggestedInvalidTags: Map<string, HTMLElement> = new Map();
#activeProfile: MaintenanceProfile | null = null;
#mediaBoxTools: MediaBoxTools | null = null;
#tagsToRemove: Set<string> = new Set();
#tagsToAdd: Set<string> = new Set();
#isPlanningToSubmit: boolean = false;
#isSubmitting: boolean = false;
#tagsSubmissionTimer: Timeout | null = null;
#emitter = emitterAt(this);
/**
* @protected
*/
build() {
this.container.innerHTML = '';
this.container.classList.add('maintenance-popup');
this.#tagsListElement.classList.add('tags-list');
this.container.append(
this.#tagsListElement,
);
}
/**
* @protected
*/
init() {
const mediaBoxToolsElement = this.container.closest<HTMLElement>('.media-box-tools');
if (!mediaBoxToolsElement) {
throw new Error('Maintenance popup initialized outside of the media box tools!');
}
const mediaBoxTools = getComponent<MediaBoxTools>(mediaBoxToolsElement);
if (!mediaBoxTools) {
throw new Error('Media box tools component not found!');
}
this.#mediaBoxTools = mediaBoxTools;
MaintenancePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
this.#tagsListElement.addEventListener('click', this.#handleTagClick.bind(this));
const mediaBox = this.#mediaBoxTools.mediaBox;
if (!mediaBox) {
throw new Error('Media box component not found!');
}
mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this));
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
}
#onActiveProfileChanged(activeProfile: MaintenanceProfile | null) {
this.#activeProfile = activeProfile;
this.container.classList.toggle('is-active', activeProfile !== null);
this.#refreshTagsList();
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
}
#refreshTagsList() {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || [];
for (const tagElement of this.#tagsList) {
tagElement.remove();
}
for (const tagElement of this.#suggestedInvalidTags.values()) {
tagElement.remove();
}
this.#tagsList = new Array(activeProfileTagsList.length);
this.#suggestedInvalidTags.clear();
const currentPostTags = this.#mediaBoxTools.mediaBox.tagsAndAliases;
activeProfileTagsList
.sort((a, b) => a.localeCompare(b))
.forEach((tagName, index) => {
const tagElement = MaintenancePopup.#buildTagElement(tagName);
this.#tagsList[index] = tagElement;
this.#tagsListElement.appendChild(tagElement);
const isPresent = currentPostTags?.has(tagName);
tagElement.classList.toggle('is-present', isPresent);
tagElement.classList.toggle('is-missing', !isPresent);
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags?.get(tagName) !== tagName);
// Just to prevent duplication, we need to include this tag to the map of suggested invalid tags
if (tagsBlacklist.includes(tagName)) {
MaintenancePopup.#markTagElementWithCategory(tagElement, 'error');
this.#suggestedInvalidTags.set(tagName, tagElement);
} else {
MaintenancePopup.#markTagElementWithCategory(
tagElement,
resolveTagCategoryFromTagName(tagName) ?? '',
);
}
});
}
/**
* Detect and process clicks made directly to the tags.
*/
#handleTagClick(event: MouseEvent) {
const targetObject = event.target;
if (!targetObject || !(targetObject instanceof HTMLElement)) {
return;
}
let tagElement: HTMLElement | null = targetObject;
if (!tagElement.classList.contains('tag')) {
tagElement = tagElement.closest<HTMLElement>('.tag');
}
if (!tagElement?.dataset.name) {
return;
}
const tagName = tagElement.dataset.name;
if (tagElement.classList.contains('is-present')) {
const isToBeRemoved = tagElement.classList.toggle('is-removed');
if (isToBeRemoved) {
this.#tagsToRemove.add(tagName);
} else {
this.#tagsToRemove.delete(tagName);
}
}
if (tagElement.classList.contains('is-missing')) {
const isToBeAdded = tagElement.classList.toggle('is-added');
if (isToBeAdded) {
this.#tagsToAdd.add(tagName);
} else {
this.#tagsToAdd.delete(tagName);
}
}
if (this.#tagsToAdd.size || this.#tagsToRemove.size) {
// Notify only once, when first planning to submit
if (!this.#isPlanningToSubmit) {
MaintenancePopup.#notifyAboutPendingSubmission(true);
}
this.#isPlanningToSubmit = true;
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting');
}
}
#onMouseEnteredArea() {
if (this.#tagsSubmissionTimer) {
clearTimeout(this.#tagsSubmissionTimer);
}
}
#onMouseLeftArea() {
if (this.#isPlanningToSubmit && !this.#isSubmitting) {
this.#tagsSubmissionTimer = setTimeout(
this.#onSubmissionTimerPassed.bind(this),
MaintenancePopup.#delayBeforeSubmissionMs
);
}
}
async #onSubmissionTimerPassed() {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) {
return;
}
this.#isPlanningToSubmit = false;
this.#isSubmitting = true;
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing');
let maybeTagsAndAliasesAfterUpdate;
const shouldAutoRemove = await MaintenancePopup.#maintenanceSettings.resolveStripBlacklistedTags();
try {
maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
this.#mediaBoxTools.mediaBox.imageId,
tagsList => {
for (let tagName of this.#tagsToRemove) {
tagsList.delete(tagName);
}
for (let tagName of this.#tagsToAdd) {
tagsList.add(tagName);
}
if (shouldAutoRemove) {
for (let tagName of tagsBlacklist) {
tagsList.delete(tagName);
}
} else {
for (let tagName of tagsList) {
if (tagsBlacklist.includes(tagName)) {
throw new BlackListedTagsEncounteredError(tagName);
}
}
}
return tagsList;
}
);
} catch (e) {
if (e instanceof BlackListedTagsEncounteredError) {
this.#revealInvalidTags();
} else {
console.warn('Tags submission failed:', e);
}
MaintenancePopup.#notifyAboutPendingSubmission(false);
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
this.#isSubmitting = false;
return;
}
if (maybeTagsAndAliasesAfterUpdate) {
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
}
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete');
this.#tagsToAdd.clear();
this.#tagsToRemove.clear();
this.#refreshTagsList();
MaintenancePopup.#notifyAboutPendingSubmission(false);
this.#isSubmitting = false;
}
#revealInvalidTags() {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases;
if (!tagsAndAliases) {
return;
}
const firstTagInList = this.#tagsList[0];
for (let tagName of tagsBlacklist) {
if (tagsAndAliases.has(tagName)) {
if (this.#suggestedInvalidTags.has(tagName)) {
continue;
}
const tagElement = MaintenancePopup.#buildTagElement(tagName);
MaintenancePopup.#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');
}
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.
*/
static #maintenanceSettings = new MaintenanceSettings();
/**
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
* at the very start to retrieve the currently active profile.
* @param callback Callback to execute whenever selection of active profile or profile itself has been changed.
* @return Unsubscribe function. Call it to stop watching for changes.
*/
static #watchActiveProfile(callback: (profile: MaintenanceProfile | null) => void): () => void {
let lastActiveProfileId: string | null | undefined = null;
const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => {
if (lastActiveProfileId) {
callback(
profiles.find(profile => profile.id === lastActiveProfileId) || null
);
}
});
const unsubscribeFromMaintenanceSettings = this.#maintenanceSettings.subscribe(settings => {
if (settings.activeProfile === lastActiveProfileId) {
return;
}
lastActiveProfileId = settings.activeProfile;
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(callback);
});
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(profileOrNull => {
if (profileOrNull) {
lastActiveProfileId = profileOrNull.id;
}
callback(profileOrNull);
});
return () => {
unsubscribeFromProfilesChanges();
unsubscribeFromMaintenanceSettings();
}
}
/**
* Notify the frontend about new pending submission started.
* @param isStarted True if started, false if ended.
*/
static #notifyAboutPendingSubmission(isStarted: boolean) {
if (this.#pendingSubmissionCount === null) {
this.#pendingSubmissionCount = 0;
this.#initializeExitPromptHandler();
}
this.#pendingSubmissionCount += isStarted ? 1 : -1;
}
/**
* Subscribe to the global window closing event, show the prompt when there are pending submission.
*/
static #initializeExitPromptHandler() {
window.addEventListener('beforeunload', event => {
if (!this.#pendingSubmissionCount) {
return;
}
event.preventDefault();
event.returnValue = true;
});
}
static #scrapedAPI = new ScrapedAPI();
static #delayBeforeSubmissionMs = 500;
/**
* Amount of pending submissions or NULL if logic was not yet initialized.
*/
static #pendingSubmissionCount: number|null = null;
}
export function createMaintenancePopup() {
const container = document.createElement('div');
new MaintenancePopup(container);
return container;
}
-74
View File
@@ -1,74 +0,0 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { MaintenancePopup } from "$content/components/MaintenancePopup";
import { on } from "$content/components/events/comms";
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/maintenance-popup-events";
import type { MediaBoxWrapper } from "$content/components/MediaBoxWrapper";
import type MaintenanceProfile from "$entities/MaintenanceProfile";
export class MediaBoxTools extends BaseComponent {
#mediaBox: MediaBoxWrapper | null = null;
#maintenancePopup: MaintenancePopup | null = null;
init() {
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
if (!mediaBoxElement) {
throw new Error('Toolbox element initialized outside of the media box!');
}
this.#mediaBox = getComponent(mediaBoxElement);
for (let childElement of this.container.children) {
if (!(childElement instanceof HTMLElement)) {
continue;
}
const component = getComponent(childElement);
if (!component) {
continue;
}
if (!component.isInitialized) {
component.initialize();
}
if (!this.#maintenancePopup && component instanceof MaintenancePopup) {
this.#maintenancePopup = component;
}
}
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
}
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
}
get maintenancePopup(): MaintenancePopup | null {
return this.#maintenancePopup;
}
get mediaBox(): MediaBoxWrapper | null {
return this.#mediaBox;
}
}
/**
* Create a maintenance popup element.
* @param childrenElements List of children elements to append to the component.
* @return The maintenance popup element.
*/
export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement {
const mediaBoxToolsContainer = document.createElement('div');
mediaBoxToolsContainer.classList.add('media-box-tools');
if (childrenElements.length) {
mediaBoxToolsContainer.append(...childrenElements);
}
new MediaBoxTools(mediaBoxToolsContainer);
return mediaBoxToolsContainer;
}
-99
View File
@@ -1,99 +0,0 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
import { on } from "$content/components/events/comms";
import { EVENT_TAGS_UPDATED } from "$content/components/events/maintenance-popup-events";
export class MediaBoxWrapper extends BaseComponent {
#thumbnailContainer: HTMLElement | null = null;
#imageLinkElement: HTMLAnchorElement | null = null;
#tagsAndAliases: Map<string, string> | null = null;
init() {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
const updatedMap = tagsUpdatedEvent.detail;
if (!(updatedMap instanceof Map)) {
throw new TypeError("Tags and aliases should be stored as Map!");
}
this.#tagsAndAliases = updatedMap;
}
#calculateMediaBoxTags() {
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
return buildTagsAndAliasesMap(tagAliases, actualTags);
}
get tagsAndAliases(): Map<string, string> | null {
if (!this.#tagsAndAliases) {
this.#tagsAndAliases = this.#calculateMediaBoxTags();
}
return this.#tagsAndAliases;
}
get imageId(): number {
const imageId = this.container.dataset.imageId;
if (!imageId) {
throw new Error('Missing image ID');
}
return parseInt(imageId);
}
get imageLinks(): App.ImageURIs {
const jsonUris = this.#thumbnailContainer?.dataset.uris;
if (!jsonUris) {
throw new Error('Missing URIs!');
}
return JSON.parse(jsonUris);
}
}
/**
* Wrap the media box element into the special wrapper.
*/
export function initializeMediaBox(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
new MediaBoxWrapper(mediaBoxContainer)
.initialize();
for (let childComponentElement of childComponentElements) {
mediaBoxContainer.appendChild(childComponentElement);
getComponent(childComponentElement)?.initialize();
}
}
export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf<HTMLElement>) {
window.addEventListener('resize', () => {
let lastMediaBox: HTMLElement | null = null;
let lastMediaBoxPosition: number | null = null;
for (const mediaBoxElement of mediaBoxesList) {
const yPosition = mediaBoxElement.getBoundingClientRect().y;
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
lastMediaBox = mediaBoxElement;
lastMediaBoxPosition = yPosition;
}
// Last-ever media box is checked separately
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
lastMediaBox.classList.add('media-box--last');
}
})
}
-150
View File
@@ -1,150 +0,0 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { emit, on, type UnsubscribeFunction } from "$content/components/events/comms";
import { EVENT_FETCH_COMPLETE } from "$content/components/events/booru-events";
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
export class TagsForm extends BaseComponent {
protected init() {
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
const unsubscribe = on(
this.container,
EVENT_FETCH_COMPLETE,
() => this.#waitAndDetectUpdatedForm(unsubscribe),
);
}
#waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void {
const elementContainingTagEditor = this.container
.closest('#image_tags_and_source')
?.parentElement;
if (!elementContainingTagEditor) {
return;
}
const observer = new MutationObserver(() => {
const tagsFormElement = elementContainingTagEditor.querySelector<HTMLElement>('#tags-form');
if (!tagsFormElement || getComponent(tagsFormElement)) {
return;
}
const tagFormComponent = new TagsForm(tagsFormElement);
tagFormComponent.initialize();
const fullTagEditor = tagFormComponent.parentTagEditorElement;
if (fullTagEditor) {
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
} else {
console.info('Tag form is not in the tag editor. Event is not sent.');
}
observer.disconnect();
unsubscribe();
});
observer.observe(elementContainingTagEditor, {
subtree: true,
childList: true,
});
// Make sure to forcibly disconnect everything after a while.
setTimeout(() => {
observer.disconnect();
unsubscribe();
}, 5000);
}
get parentTagEditorElement(): HTMLElement | null {
return this.container.closest<HTMLElement>('.js-tagsauce')
}
/**
* Collect all the tag categories available on the page and color the tags in the editor according to them.
*/
refreshTagColors() {
const tagCategories = this.#gatherTagCategories();
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
for (const tagElement of editableTags) {
// Tag name is stored in the "remove" link and not in the tag itself.
const removeLink = tagElement.querySelector('a');
if (!removeLink) {
continue;
}
const tagName = removeLink.dataset.tagName;
if (!tagName || !tagCategories.has(tagName)) {
continue;
}
const categoryName = tagCategories.get(tagName)!;
tagElement.dataset.tagCategory = categoryName;
tagElement.setAttribute('data-tag-category', categoryName);
}
}
/**
* Collect list of categories from the tags on the page.
* @return
*/
#gatherTagCategories(): Map<string, string> {
const tagCategories: Map<string, string> = new Map();
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
const tagName = tagElement.dataset.tagName;
const tagCategory = tagElement.dataset.tagCategory;
if (!tagName || !tagCategory) {
console.warn('Missing tag name or category!');
continue;
}
tagCategories.set(tagName, tagCategory);
}
return tagCategories;
}
static watchForEditors() {
document.body.addEventListener('click', event => {
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) {
return;
}
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
if (!tagEditorWrapper) {
return;
}
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
if (!refreshTrigger) {
return;
}
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
if (!tagFormElement) {
return;
}
let tagEditor = getComponent(tagFormElement);
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
tagEditor = new TagsForm(tagFormElement);
tagEditor.initialize();
}
(tagEditor as TagsForm).refreshTagColors();
});
}
}
@@ -1,5 +1,15 @@
export const EVENT_FETCH_COMPLETE = 'fetchcomplete';
export const EVENT_RELOAD = 'reload';
/**
* Custom data for the reload event on plain editor textarea. Philomena doesn't send anything on this event.
*/
export interface ReloadCustomOptions {
skipTagColorRefresh?: boolean;
skipTagRefresh?: boolean;
}
export interface BooruEventsMap {
[EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation.
[EVENT_RELOAD]: ReloadCustomOptions|null;
}
+7 -3
View File
@@ -1,16 +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;
& TagDropdownEvents
& PresetBlockEventsMap
& TagsListTogglerEventsMap;
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
export type UnsubscribeFunction = () => void;
@@ -1,4 +1,4 @@
import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
import type { FullscreenViewerSize } from "$lib/extension/preferences/MiscPreferences";
export const EVENT_SIZE_LOADED = 'size-loaded';
@@ -1,13 +0,0 @@
import type MaintenanceProfile from "$entities/MaintenanceProfile";
export const EVENT_ACTIVE_PROFILE_CHANGED = 'active-profile-changed';
export const EVENT_MAINTENANCE_STATE_CHANGED = 'maintenance-state-change';
export const EVENT_TAGS_UPDATED = 'tags-updated';
type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting';
export interface MaintenancePopupEventsMap {
[EVENT_ACTIVE_PROFILE_CHANGED]: MaintenanceProfile | null;
[EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState;
[EVENT_TAGS_UPDATED]: Map<string, string> | null;
}
@@ -0,0 +1,10 @@
export const EVENT_PRESET_TAG_CHANGE_APPLIED = 'preset-tag-changed';
export interface PresetTagChange {
addedTags?: Set<string>;
removedTags?: Set<string>;
}
export interface PresetBlockEventsMap {
[EVENT_PRESET_TAG_CHANGE_APPLIED]: PresetTagChange;
}
@@ -0,0 +1,13 @@
import type TaggingProfile from "$entities/TaggingProfile";
export const EVENT_ACTIVE_PROFILE_CHANGED = 'active-profile-changed';
export const EVENT_PROFILE_POPUP_STATE_CHANGED = 'maintenance-state-change';
export const EVENT_TAGS_UPDATED = 'tags-updated';
export type ProfilePopupState = 'ready' | 'processing' | 'failed' | 'complete' | 'waiting';
export interface TaggingProfilePopupEventsMap {
[EVENT_ACTIVE_PROFILE_CHANGED]: TaggingProfile | null;
[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 MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
import { emit, on } from "$content/components/events/comms";
import MiscPreferences, { type FullscreenViewerSize } from "$lib/extension/preferences/MiscPreferences";
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.#miscSettings
.resolveFullscreenViewerPreviewSize()
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() {
@@ -179,7 +222,7 @@ export class FullscreenViewer extends BaseComponent {
#watchForSizeSelectionChanges() {
let lastActiveSize = this.#sizeSelectorElement.value;
FullscreenViewer.#miscSettings.subscribe(settings => {
FullscreenViewer.#preferences.subscribe(settings => {
const targetSize = settings.fullscreenViewerSize;
if (!targetSize || lastActiveSize === targetSize) {
@@ -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)) {
@@ -202,12 +245,59 @@ export class FullscreenViewer extends BaseComponent {
}
lastActiveSize = targetSize;
void FullscreenViewer.#miscSettings.setFullscreenViewerPreviewSize(targetSize);
void FullscreenViewer.#preferences.fullscreenViewerSize.set(targetSize as FullscreenViewerSize);
});
}
#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,17 +382,57 @@ 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');
}
static #miscSettings = new MiscSettings();
static #preferences = new MiscPreferences();
static #offsetProperty = '--offset';
static #opacityProperty = '--opacity';
static #shownState = 'shown';
static #swipeState = 'swiped';
static #minRequiredDistance = 50;
static #collapsedPopupState = 'is-collapsed';
static #previewSizes: Record<FullscreenViewerSize, string> = {
full: 'Full',
@@ -1,8 +1,8 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import MiscSettings from "$lib/extension/settings/MiscSettings";
import { FullscreenViewer } from "$content/components/FullscreenViewer";
import type { MediaBoxTools } from "$content/components/MediaBoxTools";
import MiscPreferences from "$lib/extension/preferences/MiscPreferences";
import { FullscreenViewer } from "$content/components/extension/FullscreenViewer";
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
export class ImageShowFullscreenButton extends BaseComponent {
#mediaBoxTools: MediaBoxTools | null = null;
@@ -10,8 +10,6 @@ export class ImageShowFullscreenButton extends BaseComponent {
protected build() {
this.container.innerText = '🔍';
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
}
protected init() {
@@ -27,14 +25,14 @@ export class ImageShowFullscreenButton extends BaseComponent {
this.on('click', this.#onButtonClicked.bind(this));
if (ImageShowFullscreenButton.#miscSettings) {
ImageShowFullscreenButton.#miscSettings.resolveFullscreenViewerEnabled()
if (ImageShowFullscreenButton.#preferences) {
ImageShowFullscreenButton.#preferences.fullscreenViewer.get()
.then(isEnabled => {
this.#isFullscreenButtonEnabled = isEnabled;
this.#updateFullscreenButtonVisibility();
})
.then(() => {
ImageShowFullscreenButton.#miscSettings?.subscribe(settings => {
ImageShowFullscreenButton.#preferences?.subscribe(settings => {
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
this.#updateFullscreenButtonVisibility();
})
@@ -47,15 +45,22 @@ 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 {
const element = document.createElement('div');
element.classList.add('media-box-show-fullscreen');
new ImageShowFullscreenButton(element);
return element;
}
static #viewer: FullscreenViewer | null = null;
@@ -76,14 +81,5 @@ export class ImageShowFullscreenButton extends BaseComponent {
return viewer;
}
static #miscSettings: MiscSettings | null = null;
}
export function createImageShowFullscreenButton() {
const element = document.createElement('div');
element.classList.add('media-box-show-fullscreen');
new ImageShowFullscreenButton(element);
return element;
static #preferences = new MiscPreferences();
}
@@ -0,0 +1,74 @@
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/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;
#popup: TaggingProfilePopup | null = null;
init() {
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
if (!mediaBoxElement) {
throw new Error('Toolbox element initialized outside of the media box!');
}
this.#mediaBox = getComponent(mediaBoxElement);
for (let childElement of this.container.children) {
if (!(childElement instanceof HTMLElement)) {
continue;
}
const component = getComponent(childElement);
if (!component) {
continue;
}
if (!component.isInitialized) {
component.initialize();
}
if (!this.#popup && component instanceof TaggingProfilePopup) {
this.#popup = component;
}
}
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
}
#onActiveProfileChanged(profileChangedEvent: CustomEvent<TaggingProfile | null>) {
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
}
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.
* @return The maintenance popup element.
*/
static create(...childrenElements: HTMLElement[]): HTMLElement {
const mediaBoxToolsContainer = document.createElement('div');
mediaBoxToolsContainer.classList.add('media-box-tools');
if (childrenElements.length) {
mediaBoxToolsContainer.append(...childrenElements);
}
new MediaBoxTools(mediaBoxToolsContainer);
return mediaBoxToolsContainer;
}
}
@@ -0,0 +1,102 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import TagEditorPreset from "$entities/TagEditorPreset";
import PresetTableRow from "$content/components/extension/presets/PresetTableRow";
import { createFontAwesomeIcon } from "$lib/dom-utils";
import { sortEntitiesByField } from "$lib/utils";
export default class EditorPresetsBlock extends BaseComponent {
#presetsTable = document.createElement('table');
#presetBlocks: PresetTableRow[] = [];
#tags: Set<string> = new Set();
protected build() {
this.container.classList.add('block', 'hidden', 'tag-presets');
this.container.style.marginTop = 'var(--block-spacing)';
const header = document.createElement('div');
header.classList.add('block__header');
const headerTitle = document.createElement('div');
headerTitle.classList.add('block__header__title')
headerTitle.textContent = ' Presets';
const content = document.createElement('div');
content.classList.add('block__content');
this.#presetsTable.append(
document.createElement('thead'),
document.createElement('tbody'),
);
this.#presetsTable.tHead?.append(
EditorPresetsBlock.#createRowWithTableHeads([
'Name',
'Tags',
'Actions'
]),
);
headerTitle.prepend(createFontAwesomeIcon('layer-group'));
header.append(headerTitle);
content.append(this.#presetsTable);
this.container.append(
header,
content,
);
}
protected init() {
TagEditorPreset.readAll()
.then(this.#refreshPresets.bind(this))
.then(() => TagEditorPreset.subscribe(this.#refreshPresets.bind(this)));
}
toggleVisibility(shouldBeVisible: boolean | undefined = undefined) {
this.container.classList.toggle('hidden', shouldBeVisible);
}
updateTags(tags: Set<string>) {
this.#tags = tags;
for (const presetBlock of this.#presetBlocks) {
presetBlock.updateTags(tags);
}
}
#refreshPresets(presetsList: TagEditorPreset[]) {
if (this.#presetBlocks.length) {
for (const block of this.#presetBlocks) {
block.remove();
}
}
for (const preset of sortEntitiesByField(presetsList, "name")) {
const block = PresetTableRow.create(preset);
this.#presetsTable.tBodies[0]?.append(block.container);
block.initialize();
block.updateTags(this.#tags);
this.#presetBlocks.push(block);
}
}
static create(): EditorPresetsBlock {
return new EditorPresetsBlock(
document.createElement('div')
);
}
static #createRowWithTableHeads(columnNames: string[]): HTMLTableRowElement {
const rowElement = document.createElement('tr');
for (const columnName of columnNames) {
const columnHeadElement = document.createElement('th');
columnHeadElement.textContent = columnName;
rowElement.append(columnHeadElement);
}
return rowElement;
}
}
@@ -0,0 +1,212 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import type TagEditorPreset from "$entities/TagEditorPreset";
import { emit } from "$content/components/events/comms";
import { EVENT_PRESET_TAG_CHANGE_APPLIED } from "$content/components/events/preset-block-events";
import { createFontAwesomeIcon } from "$lib/dom-utils";
export default class PresetTableRow extends BaseComponent {
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[] = [];
constructor(container: HTMLElement, preset: TagEditorPreset) {
super(container);
this.#preset = preset;
}
protected build() {
this.#tagsList = this.#preset.settings.tags
.toSorted((a, b) => a.localeCompare(b))
.map(tagName => {
const tagElement = document.createElement('span');
tagElement.classList.add('tag');
tagElement.textContent = tagName;
tagElement.dataset.tagName = tagName;
return tagElement;
});
const nameCell = document.createElement('td');
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');
tagsListContainer.append(...this.#tagsList);
tagsCell.append(tagsListContainer);
const actionsCell = document.createElement('td');
const actionsContainer = document.createElement('div');
actionsContainer.classList.add('flex', 'flex--gap-small');
this.#applyAllButton.classList.add('button', 'button--state-success', 'button--bold');
this.#applyAllButton.append(createFontAwesomeIcon('circle-plus'));
this.#applyAllButton.title = 'Add all tags from this preset into the editor';
this.#removeAllButton.classList.add('button', 'button--state-danger', 'button--bold');
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,
);
actionsCell.append(actionsContainer);
this.container.append(
nameCell,
tagsCell,
actionsCell,
);
this.#alternateColorDummy.style.display = 'none';
}
protected init() {
for (const tagElement of this.#tagsList) {
tagElement.addEventListener('click', this.#onTagClicked.bind(this));
}
this.#applyAllButton.addEventListener('click', this.#onApplyAllClicked.bind(this));
this.#removeAllButton.addEventListener('click', this.#onRemoveAllClicked.bind(this));
}
#onTagClicked(event: Event) {
const targetElement = event.currentTarget;
if (!(targetElement instanceof HTMLElement)) {
return;
}
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])
});
}
#onApplyAllClicked(event: Event) {
event.preventDefault();
event.stopPropagation();
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
addedTags: new Set(this.#preset.settings.tags),
});
}
#onRemoveAllClicked(event: Event) {
event.preventDefault();
event.stopPropagation();
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
removedTags: new Set(this.#preset.settings.tags),
});
}
#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) {
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() {
this.container.remove();
}
static create(preset: TagEditorPreset) {
return new this(document.createElement('tr'), preset);
}
static #tagMissingClassName = 'is-missing';
static #presetWarningClassName = 'has-warning';
}
@@ -0,0 +1,309 @@
import TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences";
import TaggingProfile from "$entities/TaggingProfile";
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, on } from "$content/components/events/comms";
import {
EVENT_ACTIVE_PROFILE_CHANGED,
EVENT_PROFILE_POPUP_STATE_CHANGED,
EVENT_TAGS_UPDATED
} from "$content/components/events/tagging-profile-popup-events";
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
import TagsListToggler, { BlackListedTagsEncounteredError } from "$content/components/extension/profiles/TagsListToggler";
import { EVENT_TAG_TOGGLED } from "$content/components/events/tags-list-toggler-events";
export class TaggingProfilePopup extends BaseComponent {
#tagsListToggler = TagsListToggler.create();
#activeProfile: TaggingProfile | null = null;
#mediaBoxTools: MediaBoxTools | null = null;
#isSubmissionLocked: boolean = false;
#isPlanningToSubmit: boolean = false;
#isSubmitting: boolean = false;
#tagsSubmissionTimer: Timeout | null = null;
#emitter = emitterAt(this);
/**
* @protected
*/
build() {
this.container.innerHTML = '';
this.container.classList.add('maintenance-popup');
this.#tagsListToggler.initialize();
this.container.append(this.#tagsListToggler.container);
}
/**
* @protected
*/
init() {
const mediaBoxToolsElement = this.container.closest<HTMLElement>('.media-box-tools');
if (!mediaBoxToolsElement) {
throw new Error('Maintenance popup initialized outside of the media box tools!');
}
const mediaBoxTools = getComponent<MediaBoxTools>(mediaBoxToolsElement);
if (!mediaBoxTools) {
throw new Error('Media box tools component not found!');
}
this.#mediaBoxTools = mediaBoxTools;
TaggingProfilePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
on(this.#tagsListToggler, EVENT_TAG_TOGGLED, this.#onTagToggled.bind(this));
const mediaBox = this.#mediaBoxTools.mediaBox;
if (!mediaBox) {
throw new Error('Media box component not found!');
}
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.#refreshTagsToggler();
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
}
#refreshTagsToggler() {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || [];
const currentPostTags = new Set(this.#mediaBoxTools.mediaBox.tagsAndAliases?.keys() || []);
this.#tagsListToggler.replaceTags(activeProfileTagsList, currentPostTags);
}
/**
* Detect and process clicks made directly to the tags.
*/
#onTagToggled() {
const hasPendingChanges = this.#tagsListToggler.hasChanges;
if (hasPendingChanges) {
// Notify only once, when first planning to submit
if (!this.#isPlanningToSubmit) {
TaggingProfilePopup.#notifyAboutPendingSubmission(true);
}
this.#isPlanningToSubmit = true;
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);
}
}
}
#cancelQueuedSubmission() {
if (this.#tagsSubmissionTimer) {
clearTimeout(this.#tagsSubmissionTimer);
}
}
#maybeEnqueueSubmission() {
if (this.#isPlanningToSubmit && !this.#isSubmitting && !this.#isSubmissionLocked) {
this.#tagsSubmissionTimer = setTimeout(
this.#onSubmissionTimerPassed.bind(this),
TaggingProfilePopup.#delayBeforeSubmissionMs
);
}
}
async #onSubmissionTimerPassed() {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox || this.#isSubmissionLocked) {
return;
}
this.#isPlanningToSubmit = false;
this.#isSubmitting = true;
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'processing');
let maybeTagsAndAliasesAfterUpdate;
const shouldAutoRemove = await TaggingProfilePopup.#preferences.stripBlacklistedTags.get();
try {
maybeTagsAndAliasesAfterUpdate = await TaggingProfilePopup.#scrapedAPI.updateImageTags(
this.#mediaBoxTools.mediaBox.imageId,
this.#tagsListToggler.createTagsUpdater(shouldAutoRemove),
);
} catch (e) {
if (e instanceof BlackListedTagsEncounteredError) {
this.#revealInvalidTags();
} else {
console.warn('Tags submission failed:', e);
}
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'failed');
this.#isSubmitting = false;
return;
}
if (maybeTagsAndAliasesAfterUpdate) {
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
}
this.#emitter.emit(EVENT_PROFILE_POPUP_STATE_CHANGED, 'complete');
this.#tagsListToggler.clearChangeSet();
this.#refreshTagsToggler();
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
this.#isSubmitting = false;
}
#revealInvalidTags() {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
const activeTags = new Set(this.#mediaBoxTools.mediaBox.tagsAndAliases?.keys() || []);
if (!activeTags.size) {
return;
}
this.#tagsListToggler.prependTags(tagsBlacklist, activeTags);
}
static create(): HTMLElement {
const container = document.createElement('div');
new this(container);
return container;
}
/**
* Controller with maintenance settings.
*/
static #preferences = new TaggingProfilesPreferences();
/**
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
* at the very start to retrieve the currently active profile.
* @param callback Callback to execute whenever selection of active profile or profile itself has been changed.
* @return Unsubscribe function. Call it to stop watching for changes.
*/
static #watchActiveProfile(callback: (profile: TaggingProfile | null) => void): () => void {
let lastActiveProfileId: string | null | undefined = null;
const unsubscribeFromProfilesChanges = TaggingProfile.subscribe(profiles => {
if (lastActiveProfileId) {
callback(
profiles.find(profile => profile.id === lastActiveProfileId) || null
);
}
});
const unsubscribeFromPreferences = this.#preferences.subscribe(settings => {
if (settings.activeProfile === lastActiveProfileId) {
return;
}
lastActiveProfileId = settings.activeProfile;
this.#preferences.activeProfile.asObject()
.then(callback);
});
this.#preferences.activeProfile.asObject()
.then(profileOrNull => {
if (profileOrNull) {
lastActiveProfileId = profileOrNull.id;
}
callback(profileOrNull);
});
return () => {
unsubscribeFromProfilesChanges();
unsubscribeFromPreferences();
}
}
/**
* Notify the frontend about new pending submission started.
* @param isStarted True if started, false if ended.
*/
static #notifyAboutPendingSubmission(isStarted: boolean) {
if (this.#pendingSubmissionCount === null) {
this.#pendingSubmissionCount = 0;
this.#initializeExitPromptHandler();
}
this.#pendingSubmissionCount += isStarted ? 1 : -1;
}
/**
* Subscribe to the global window closing event, show the prompt when there are pending submission.
*/
static #initializeExitPromptHandler() {
window.addEventListener('beforeunload', event => {
if (!this.#pendingSubmissionCount) {
return;
}
event.preventDefault();
event.returnValue = true;
});
}
static #scrapedAPI = new ScrapedAPI();
static #delayBeforeSubmissionMs = 500;
/**
* Amount of pending submissions or NULL if logic was not yet initialized.
*/
static #pendingSubmissionCount: number | null = null;
}
@@ -1,10 +1,13 @@
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 type { MediaBoxTools } from "$content/components/MediaBoxTools";
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 MaintenanceStatusIcon extends BaseComponent {
export class TaggingProfileStatusIcon extends BaseComponent {
#mediaBoxTools: MediaBoxTools | null = null;
build() {
@@ -22,10 +25,10 @@ export class MaintenanceStatusIcon 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":
@@ -52,13 +55,13 @@ export class MaintenanceStatusIcon extends BaseComponent {
this.container.innerText = '❓';
}
}
}
export function createMaintenanceStatusIcon() {
const element = document.createElement('div');
element.classList.add('maintenance-status-icon');
static create(): HTMLElement {
const element = document.createElement('div');
element.classList.add('maintenance-status-icon');
new MaintenanceStatusIcon(element);
new TaggingProfileStatusIcon(element);
return element;
return element;
}
}
@@ -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,133 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import TagsPreferences from "$lib/extension/preferences/TagsPreferences";
import { getComponent } from "$content/components/base/component-utils";
import { resolveTagNameFromLink, resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils";
export class BlockCommunication extends BaseComponent {
#contentSection: HTMLElement | null = null;
#tagLinks: HTMLAnchorElement[] = [];
#tagLinksReplaced: boolean | null = null;
#linkTextReplaced: boolean | null = null;
protected build() {
this.#contentSection = this.container.querySelector('.communication__content');
this.#tagLinks = this.#findAllTagLinks();
}
protected init() {
Promise.all([
BlockCommunication.#preferences.replaceLinks.get(),
BlockCommunication.#preferences.replaceLinkText.get(),
]).then(([replaceLinks, replaceLinkText]) => {
this.#onReplaceLinkSettingResolved(
replaceLinks,
replaceLinkText
);
});
BlockCommunication.#preferences.subscribe(settings => {
this.#onReplaceLinkSettingResolved(
settings.replaceLinks ?? false,
settings.replaceLinkText ?? true
);
});
}
#onReplaceLinkSettingResolved(haveToReplaceLinks: boolean, shouldReplaceLinkText: boolean) {
if (
!this.#tagLinks.length
|| this.#tagLinksReplaced === haveToReplaceLinks
&& this.#linkTextReplaced === shouldReplaceLinkText
) {
return;
}
for (const linkElement of this.#tagLinks) {
linkElement.classList.toggle('tag', haveToReplaceLinks);
// Sometimes tags are being decorated with the code block inside. It should be fine to replace it right away.
if (linkElement.childElementCount === 1 && linkElement.children[0].tagName === 'CODE') {
linkElement.textContent = linkElement.children[0].textContent;
}
/**
* Resolved tag name. It should be stored for the text replacement.
*/
let tagName: string | undefined;
if (haveToReplaceLinks) {
tagName = resolveTagNameFromLink(new URL(linkElement.href)) ?? '';
linkElement.dataset.tagCategory = resolveTagCategoryFromTagName(tagName) ?? '';
} else {
linkElement.dataset.tagCategory = '';
}
this.#toggleTagLinkText(
linkElement,
haveToReplaceLinks && shouldReplaceLinkText,
tagName,
);
}
this.#tagLinksReplaced = haveToReplaceLinks;
this.#linkTextReplaced = shouldReplaceLinkText;
}
/**
* Swap the link text with the tag name or restore it back to original content. This function will only perform
* replacement on links without any additional tags inside. This will ensure link won't break original content.
* @param linkElement Element to swap the text on.
* @param shouldSwapToTagName Should we swap the text to tag name or retore it back from memory.
* @param tagName Tag name to swap the text to. If not provided, text will be swapped back.
* @private
*/
#toggleTagLinkText(linkElement: HTMLElement, shouldSwapToTagName: boolean, tagName?: string) {
if (linkElement.childElementCount) {
return;
}
// Make sure we save the original text to memory.
if (!BlockCommunication.#originalTagLinkTexts.has(linkElement)) {
BlockCommunication.#originalTagLinkTexts.set(linkElement, linkElement.textContent);
}
if (shouldSwapToTagName && tagName) {
linkElement.textContent = tagName;
} else {
linkElement.textContent = BlockCommunication.#originalTagLinkTexts.get(linkElement) ?? linkElement.textContent;
}
}
#findAllTagLinks(): HTMLAnchorElement[] {
return Array
.from(this.#contentSection?.querySelectorAll('a') || [])
.filter(
link =>
// Support links pointing to the tag page.
link.pathname.startsWith('/tags/')
// Also capture link which point to the search results with single tag.
|| link.pathname.startsWith('/search')
&& link.search.includes('q=')
);
}
static #preferences = new TagsPreferences();
/**
* Map of links to their original texts. These texts need to be stored here to make them restorable. Keys is a link
* element and value is a text.
* @private
*/
static #originalTagLinkTexts: WeakMap<HTMLElement, string> = new WeakMap();
static findAndInitializeAll() {
for (const container of document.querySelectorAll<HTMLElement>('.block.communication')) {
if (getComponent(container)) {
continue;
}
new BlockCommunication(container).initialize();
}
}
}
@@ -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 || [];
}
}
@@ -0,0 +1,125 @@
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/tagging-profile-popup-events";
import ImageContainer from "$content/components/philomena/ImageContainer";
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
export class MediaBox extends BaseComponent {
#imageContainer: ImageContainer | null = null;
#tagsAndAliases: Map<string, string> | null = null;
#mediaBoxTools: MediaBoxTools | null = null;
init() {
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));
}
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
const updatedMap = tagsUpdatedEvent.detail;
if (!(updatedMap instanceof Map)) {
throw new TypeError("Tags and aliases should be stored as Map!");
}
this.#tagsAndAliases = updatedMap;
}
#calculateMediaBoxTags() {
const tagAliases = 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();
}
return this.#tagsAndAliases;
}
get imageId(): number {
const imageId = this.container.dataset.imageId;
if (!imageId) {
throw new Error('Missing image ID');
}
return parseInt(imageId);
}
get imageLinks(): App.ImageURIs {
const sourceUrls = this.#imageContainer?.extractImageLinks();
if (!sourceUrls) {
throw new Error('Missing image container!');
}
return sourceUrls;
}
get tools(): MediaBoxTools | null {
return this.#mediaBoxTools;
}
/**
* Wrap the media box element into the special wrapper.
*/
static initialize(mediaBoxContainer: HTMLElement, mediaBoxToolsElement: HTMLElement) {
const mediaBox = new MediaBox(mediaBoxContainer);
mediaBox.initialize();
mediaBoxContainer.appendChild(mediaBoxToolsElement);
const mediaBoxToolsComponent = getComponent<MediaBoxTools>(mediaBoxToolsElement);
mediaBoxToolsComponent?.initialize();
mediaBox.#mediaBoxTools = mediaBoxToolsComponent;
}
static findElements(): NodeListOf<HTMLElement> {
return document.querySelectorAll('.media-box');
}
static initializePositionCalculation(mediaBoxesList: NodeListOf<HTMLElement>) {
window.addEventListener('resize', () => {
let lastMediaBox: HTMLElement | null = null;
let lastMediaBoxPosition: number | null = null;
for (const mediaBoxElement of mediaBoxesList) {
const yPosition = mediaBoxElement.getBoundingClientRect().y;
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
lastMediaBox = mediaBoxElement;
lastMediaBoxPosition = yPosition;
}
// Last-ever media box is checked separately
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
lastMediaBox.classList.add('media-box--last');
}
})
}
}
@@ -1,6 +1,6 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
import TaggingProfile from "$entities/TaggingProfile";
import TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences";
import { getComponent } from "$content/components/base/component-utils";
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
import { on } from "$content/components/events/comms";
@@ -8,9 +8,7 @@ import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-
import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdown-events";
import type TagGroup from "$entities/TagGroup";
const categoriesResolver = new CustomCategoriesResolver();
export class TagDropdownWrapper extends BaseComponent {
export class TagDropdown extends BaseComponent {
/**
* Container with dropdown elements to insert options into.
*/
@@ -29,7 +27,7 @@ export class TagDropdownWrapper extends BaseComponent {
/**
* Local clone of the currently active profile used for updating the list of tags.
*/
#activeProfile: MaintenanceProfile | null = null;
#activeProfile: TaggingProfile | null = null;
/**
* Is cursor currently entered the dropdown.
@@ -46,7 +44,7 @@ export class TagDropdownWrapper extends BaseComponent {
this.on('mouseenter', this.#onDropdownEntered.bind(this));
this.on('mouseleave', this.#onDropdownLeft.bind(this));
TagDropdownWrapper.#watchActiveProfile(activeProfileOrNull => {
TagDropdown.#watchActiveProfile(activeProfileOrNull => {
this.#activeProfile = activeProfileOrNull;
if (this.#isEntered) {
@@ -122,7 +120,7 @@ export class TagDropdownWrapper extends BaseComponent {
#updateButtons() {
if (!this.#activeProfile) {
this.#addToNewButton ??= TagDropdownWrapper.#createDropdownLink(
this.#addToNewButton ??= TagDropdown.#createDropdownLink(
'Add to new tagging profile',
this.#onAddToNewClicked.bind(this)
);
@@ -135,7 +133,7 @@ export class TagDropdownWrapper extends BaseComponent {
}
if (this.#activeProfile) {
this.#toggleOnExistingButton ??= TagDropdownWrapper.#createDropdownLink(
this.#toggleOnExistingButton ??= TagDropdown.#createDropdownLink(
'Add to existing tagging profile',
this.#onToggleInExistingClicked.bind(this)
);
@@ -172,14 +170,14 @@ export class TagDropdownWrapper extends BaseComponent {
throw new Error('Missing tag name to create the profile!');
}
const profile = new MaintenanceProfile(crypto.randomUUID(), {
const profile = new TaggingProfile(crypto.randomUUID(), {
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
tags: [this.tagName],
temporary: true,
});
await profile.save();
await TagDropdownWrapper.#maintenanceSettings.setActiveProfileId(profile.id);
await TagDropdown.#preferences.activeProfile.set(profile.id);
}
async #onToggleInExistingClicked() {
@@ -205,25 +203,25 @@ export class TagDropdownWrapper extends BaseComponent {
await this.#activeProfile.save();
}
static #maintenanceSettings = new MaintenanceSettings();
static #preferences = new TaggingProfilesPreferences();
/**
* Watch for changes to active profile.
* @param onActiveProfileChange Callback to call when profile was
* changed.
*/
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile | null) => void) {
static #watchActiveProfile(onActiveProfileChange: (profile: TaggingProfile | null) => void) {
let lastActiveProfile: string | null = null;
this.#maintenanceSettings.subscribe((settings) => {
this.#preferences.subscribe((settings) => {
lastActiveProfile = settings.activeProfile ?? null;
this.#maintenanceSettings
.resolveActiveProfileAsObject()
this.#preferences
.activeProfile.asObject()
.then(onActiveProfileChange);
});
MaintenanceProfile.subscribe(profiles => {
TaggingProfile.subscribe(profiles => {
const activeProfile = profiles
.find(profile => profile.id === lastActiveProfile);
@@ -231,8 +229,8 @@ export class TagDropdownWrapper extends BaseComponent {
);
});
this.#maintenanceSettings
.resolveActiveProfileAsObject()
this.#preferences
.activeProfile.asObject()
.then(activeProfile => {
lastActiveProfile = activeProfile?.id ?? null;
onActiveProfileChange(activeProfile);
@@ -263,58 +261,65 @@ export class TagDropdownWrapper extends BaseComponent {
return dropdownLink;
}
}
export function wrapTagDropdown(element: HTMLElement) {
// Skip initialization when tag component is already wrapped
if (getComponent(element)) {
return;
static #categoriesResolver = new CustomCategoriesResolver();
static #processedElements: WeakSet<HTMLElement> = new WeakSet();
static #findAll(parentNode: ParentNode = document): NodeListOf<HTMLElement> {
return parentNode.querySelectorAll('.tag.dropdown');
}
const tagDropdown = new TagDropdownWrapper(element);
tagDropdown.initialize();
static #initialize(element: HTMLElement) {
// Skip initialization when tag component is already wrapped
if (getComponent(element)) {
return;
}
categoriesResolver.addElement(tagDropdown);
}
const tagDropdown = new TagDropdown(element);
tagDropdown.initialize();
const processedElementsSet = new WeakSet<HTMLElement>();
export function watchTagDropdownsInTagsEditor() {
// We only need to watch for new editor elements if there is a tag editor present on the page
if (!document.querySelector('#image_tags_and_source')) {
return;
this.#categoriesResolver.addElement(tagDropdown);
}
document.body.addEventListener('mouseover', event => {
const targetElement = event.target;
static findAllAndInitialize(parentNode: ParentNode = document) {
for (const element of this.#findAll(parentNode)) {
this.#initialize(element);
}
}
if (!(targetElement instanceof HTMLElement)) {
static watch() {
// We only need to watch for new editor elements if there is a tag editor present on the page
if (!document.querySelector('#image_tags_and_source')) {
return;
}
if (processedElementsSet.has(targetElement)) {
return;
}
document.body.addEventListener('mouseover', event => {
const targetElement = event.target;
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
if (!(targetElement instanceof HTMLElement)) {
return;
}
if (!closestTagEditor || processedElementsSet.has(closestTagEditor)) {
processedElementsSet.add(targetElement);
return;
}
if (this.#processedElements.has(targetElement)) {
return;
}
processedElementsSet.add(targetElement);
processedElementsSet.add(closestTagEditor);
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
for (const tagDropdownElement of closestTagEditor.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
});
if (!closestTagEditor || this.#processedElements.has(closestTagEditor)) {
this.#processedElements.add(targetElement);
return;
}
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
for (const tagDropdownElement of event.detail.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
});
this.#processedElements.add(targetElement);
this.#processedElements.add(closestTagEditor);
this.findAllAndInitialize(closestTagEditor);
});
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
this.findAllAndInitialize(event.detail);
});
}
}
@@ -0,0 +1,310 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { emit, on, type UnsubscribeFunction } from "$content/components/events/comms";
import { EVENT_FETCH_COMPLETE, EVENT_RELOAD, type ReloadCustomOptions } from "$content/components/events/booru-events";
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
import EditorPresetsBlock from "$content/components/extension/presets/EditorPresetsBlock";
import { EVENT_PRESET_TAG_CHANGE_APPLIED, type PresetTagChange } from "$content/components/events/preset-block-events";
export class TagsForm extends BaseComponent {
#togglePresetsButton: HTMLButtonElement = document.createElement('button');
#presetsList = EditorPresetsBlock.create();
#plainEditorTextarea: HTMLTextAreaElement | null = null;
#fancyEditorInput: HTMLInputElement | null = null;
#tagsSet: Set<string> = new Set();
protected build() {
this.#togglePresetsButton.classList.add(
'button',
'button--state-primary',
'button--bold',
'button--separate-left',
);
this.#togglePresetsButton.textContent = 'Presets';
this.container
.querySelector(':is(.fancy-tag-edit, .fancy-tag-upload) ~ button:last-of-type')
?.after(this.#togglePresetsButton, this.#presetsList.container);
this.#plainEditorTextarea = this.container.querySelector('textarea.tagsinput');
this.#fancyEditorInput = this.container.querySelector('.js-taginput-fancy input');
}
protected init() {
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
const unsubscribe = on(
this.container,
EVENT_FETCH_COMPLETE,
() => this.#waitAndDetectUpdatedForm(unsubscribe),
);
this.#togglePresetsButton.addEventListener('click', this.#togglePresetsList.bind(this));
this.#presetsList.initialize();
this.#plainEditorTextarea?.addEventListener('input', this.#refreshTagsListForPresets.bind(this));
this.#fancyEditorInput?.addEventListener('keydown', this.#refreshTagsListForPresets.bind(this));
this.#refreshTagsListForPresets();
on(this.#presetsList, EVENT_PRESET_TAG_CHANGE_APPLIED, this.#onTagChangeRequested.bind(this));
if (this.#plainEditorTextarea) {
// When reloaded, we should catch and refresh the colors. Extension reuses this event to force site to update
// list of tags in the fancy tag editor.
on(this.#plainEditorTextarea, EVENT_RELOAD, this.#onPlainEditorReloadRequested.bind(this));
}
}
#waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void {
const elementContainingTagEditor = this.container
.closest('#image_tags_and_source')
?.parentElement;
if (!elementContainingTagEditor) {
return;
}
const observer = new MutationObserver(() => {
const tagsFormElement = elementContainingTagEditor.querySelector<HTMLElement>('#tags-form');
if (!tagsFormElement || getComponent(tagsFormElement)) {
return;
}
const tagFormComponent = new TagsForm(tagsFormElement);
tagFormComponent.initialize();
const fullTagEditor = tagFormComponent.parentTagEditorElement;
if (fullTagEditor) {
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
} else {
console.info('Tag form is not in the tag editor. Event is not sent.');
}
observer.disconnect();
unsubscribe();
});
observer.observe(elementContainingTagEditor, {
subtree: true,
childList: true,
});
// Make sure to forcibly disconnect everything after a while.
setTimeout(() => {
observer.disconnect();
unsubscribe();
}, 5000);
}
get parentTagEditorElement(): HTMLElement | null {
return this.container.closest<HTMLElement>('.js-tagsauce')
}
/**
* Collect all the tag categories available on the page and color the tags in the editor according to them.
*/
refreshTagColors() {
const tagCategories = this.#gatherTagCategories();
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
for (const tagElement of editableTags) {
// Tag name is stored in the "remove" link and not in the tag itself.
const removeLink = tagElement.querySelector('a');
if (!removeLink) {
continue;
}
const tagName = removeLink.dataset.tagName;
if (!tagName || !tagCategories.has(tagName)) {
continue;
}
const categoryName = tagCategories.get(tagName)!;
tagElement.dataset.tagCategory = categoryName;
tagElement.setAttribute('data-tag-category', categoryName);
}
}
/**
* Collect list of categories from the tags on the page.
* @return
*/
#gatherTagCategories(): Map<string, string> {
const tagCategories: Map<string, string> = new Map();
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
const tagName = tagElement.dataset.tagName;
const tagCategory = tagElement.dataset.tagCategory;
if (!tagName || !tagCategory) {
console.warn('Missing tag name or category!');
continue;
}
tagCategories.set(tagName, tagCategory);
}
return tagCategories;
}
#togglePresetsList(event: Event) {
event.stopPropagation();
event.preventDefault();
this.#presetsList.toggleVisibility();
this.#refreshTagsListForPresets();
}
#refreshTagsListForPresets() {
this.#tagsSet = new Set(
this.#plainEditorTextarea?.value
.split(',')
.map(tagName => tagName.trim())
);
this.#presetsList.updateTags(this.#tagsSet);
}
#onTagChangeRequested(event: CustomEvent<PresetTagChange>) {
const targetElement = event.target instanceof HTMLElement ? event.target : null;
const {addedTags = null, removedTags = null} = event.detail;
let tagChangeList: string[] = [];
if (addedTags) {
tagChangeList.push(...addedTags);
}
if (removedTags) {
tagChangeList.push(
...Array.from(removedTags)
.filter(tagName => this.#tagsSet.has(tagName))
.map(tagName => `-${tagName}`)
);
}
this.#executeAndCompensateForLayoutShift(
() => this.#applyTagChangesWithFancyTagEditor(tagChangeList.join(',')),
[this.#presetsList.container, targetElement],
);
}
#executeAndCompensateForLayoutShift(executeOperation: () => void, elements: (HTMLElement | null)[]) {
const offsetsListBefore = TagsForm.#gatherOffsetsFromElements(elements);
executeOperation();
const offsetsListAfter = TagsForm.#gatherOffsetsFromElements(elements);
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 {
if (!this.#fancyEditorInput || !this.#plainEditorTextarea) {
return;
}
const originalValue = this.#fancyEditorInput.value;
// We have to tell plain text editor to also refresh the list of tags in the fancy editor, just in case user
// made changes to it in plain mode.
emit(this.#plainEditorTextarea, EVENT_RELOAD, {
// Sending that we don't need to refresh the color on this event, since we will do that ourselves later, after
// changes are applied.
skipTagColorRefresh: true,
skipTagRefresh: true,
});
this.#fancyEditorInput.value = tagsListWithChanges;
this.#fancyEditorInput.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Comma',
}));
this.#fancyEditorInput.value = originalValue;
this.refreshTagColors();
}
#onPlainEditorReloadRequested(event: CustomEvent<ReloadCustomOptions | null>) {
if (!event.detail?.skipTagColorRefresh) {
this.refreshTagColors();
}
if (!event.detail?.skipTagRefresh) {
this.#refreshTagsListForPresets();
}
}
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;
if (!(targetElement instanceof HTMLElement)) {
return;
}
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
if (!tagEditorWrapper) {
return;
}
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
if (!refreshTrigger) {
return;
}
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
if (!tagFormElement) {
return;
}
let tagEditor = getComponent(tagFormElement);
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
tagEditor = new TagsForm(tagFormElement);
tagEditor.initialize();
}
(tagEditor as TagsForm).refreshTagColors();
});
}
static initializeUploadEditor() {
const uploadEditorContainer = document.querySelector<HTMLElement>('.field:has(.fancy-tag-upload)');
if (!uploadEditorContainer) {
return;
}
new TagsForm(uploadEditorContainer).initialize();
}
}
@@ -1,11 +1,11 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import type TagGroup from "$entities/TagGroup";
import type { TagDropdownWrapper } from "$content/components/TagDropdownWrapper";
import type { TagDropdown } from "$content/components/philomena/TagDropdown";
import { on } from "$content/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
import { getComponent } from "$content/components/base/component-utils";
import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdown-events";
import TagSettings from "$lib/extension/settings/TagSettings";
import TagsPreferences from "$lib/extension/preferences/TagsPreferences";
export class TagsListBlock extends BaseComponent {
#tagsListButtonsContainer: HTMLElement | null = null;
@@ -14,14 +14,14 @@ export class TagsListBlock extends BaseComponent {
#toggleGroupingButton = document.createElement('a');
#toggleGroupingButtonIcon = document.createElement('i');
#tagSettings = new TagSettings();
#preferences = new TagsPreferences();
#shouldDisplaySeparation = false;
#separatedGroups = new Map<string, TagGroup>();
#separatedHeaders = new Map<string, HTMLElement>();
#groupsCount = new Map<string, number>();
#lastTagGroup = new WeakMap<TagDropdownWrapper, TagGroup | null>;
#lastTagGroup = new WeakMap<TagDropdown, TagGroup | null>;
#isReorderingPlanned = false;
@@ -44,8 +44,8 @@ export class TagsListBlock extends BaseComponent {
}
init() {
this.#tagSettings.resolveGroupSeparation().then(this.#onTagSeparationChange.bind(this));
this.#tagSettings.subscribe(settings => {
this.#preferences.groupSeparation.get().then(this.#onTagSeparationChange.bind(this));
this.#preferences.subscribe(settings => {
this.#onTagSeparationChange(Boolean(settings.groupSeparation))
});
@@ -80,7 +80,7 @@ export class TagsListBlock extends BaseComponent {
return;
}
const tagDropdown = getComponent<TagDropdownWrapper>(maybeDropdownElement);
const tagDropdown = getComponent<TagDropdown>(maybeDropdownElement);
if (!tagDropdown) {
return;
@@ -103,7 +103,7 @@ export class TagsListBlock extends BaseComponent {
#onToggleGroupingClicked(event: Event) {
event.preventDefault();
void this.#tagSettings.setGroupSeparation(!this.#shouldDisplaySeparation);
void this.#preferences.groupSeparation.set(!this.#shouldDisplaySeparation);
}
#handleTagGroupChanges(tagGroup: TagGroup) {
@@ -146,7 +146,7 @@ export class TagsListBlock extends BaseComponent {
heading.innerText = group.settings.name;
}
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdownWrapper) {
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdown) {
const previousGroupId = this.#lastTagGroup.get(tagComponent)?.id;
const currentGroupId = resolvedGroup?.id;
const isDifferentId = currentGroupId !== previousGroupId;
@@ -217,28 +217,28 @@ export class TagsListBlock extends BaseComponent {
static #iconGroupingDisabled = 'fa-folder';
static #iconGroupingEnabled = 'fa-folder-tree';
}
export function initializeAllTagsLists() {
for (let element of document.querySelectorAll<HTMLElement>('#image_tags_and_source')) {
if (getComponent(element)) {
return;
static initializeAll() {
for (let element of document.querySelectorAll<HTMLElement>('#image_tags_and_source')) {
if (getComponent(element)) {
return;
}
new TagsListBlock(element)
.initialize();
}
}
new TagsListBlock(element)
.initialize();
static watchUpdatedLists() {
on(document, EVENT_FORM_EDITOR_UPDATED, event => {
const tagsListElement = event.detail.closest<HTMLElement>('#image_tags_and_source');
if (!tagsListElement || getComponent(tagsListElement)) {
return;
}
new TagsListBlock(tagsListElement)
.initialize();
})
}
}
export function watchForUpdatedTagLists() {
on(document, EVENT_FORM_EDITOR_UPDATED, event => {
const tagsListElement = event.detail.closest<HTMLElement>('#image_tags_and_source');
if (!tagsListElement || getComponent(tagsListElement)) {
return;
}
new TagsListBlock(tagsListElement)
.initialize();
});
}
@@ -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();
}
}
}
@@ -1,5 +1,5 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { ImageListInfo } from "$content/components/listing/ImageListInfo";
import { ImageListInfo } from "$content/components/philomena/listing/ImageListInfo";
export class ImageListContainer extends BaseComponent {
#info: ImageListInfo | null = null;
@@ -12,8 +12,12 @@ export class ImageListContainer extends BaseComponent {
this.#info.initialize();
}
}
}
export function initializeImageListContainer(element: HTMLElement) {
new ImageListContainer(element).initialize();
static findAndInitialize() {
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
if (imageListContainer) {
new ImageListContainer(imageListContainer).initialize();
}
}
}
+3
View File
@@ -0,0 +1,3 @@
import GridDupeReportsList from "$content/components/philomena/dupe/GridDupeReportsList";
GridDupeReportsList.findAndInitialize();
+14 -20
View File
@@ -1,21 +1,18 @@
import { createMaintenancePopup } from "$content/components/MaintenancePopup";
import { createMediaBoxTools } from "$content/components/MediaBoxTools";
import { calculateMediaBoxesPositions, initializeMediaBox } from "$content/components/MediaBoxWrapper";
import { createMaintenanceStatusIcon } from "$content/components/MaintenanceStatusIcon";
import { createImageShowFullscreenButton } from "$content/components/ImageShowFullscreenButton";
import { initializeImageListContainer } from "$content/components/listing/ImageListContainer";
import { TaggingProfilePopup } from "$content/components/extension/profiles/TaggingProfilePopup";
import { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
import { MediaBox } from "$content/components/philomena/MediaBox";
import { TaggingProfileStatusIcon } from "$content/components/extension/profiles/TaggingProfileStatusIcon";
import { ImageShowFullscreenButton } from "$content/components/extension/ImageShowFullscreenButton";
import { ImageListContainer } from "$content/components/philomena/listing/ImageListContainer";
const mediaBoxes = document.querySelectorAll<HTMLElement>('.media-box');
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
const mediaBoxes = MediaBox.findElements();
mediaBoxes.forEach(mediaBoxElement => {
initializeMediaBox(mediaBoxElement, [
createMediaBoxTools(
createMaintenancePopup(),
createMaintenanceStatusIcon(),
createImageShowFullscreenButton(),
)
]);
MediaBox.initialize(mediaBoxElement, MediaBoxTools.create(
TaggingProfilePopup.create(),
TaggingProfileStatusIcon.create(),
ImageShowFullscreenButton.create(),
));
// Attempt to fix misplacement of media boxes
requestAnimationFrame(() => {
@@ -23,8 +20,5 @@ mediaBoxes.forEach(mediaBoxElement => {
})
});
calculateMediaBoxesPositions(mediaBoxes);
if (imageListContainer) {
initializeImageListContainer(imageListContainer);
}
MediaBox.initializePositionCalculation(mediaBoxes);
ImageListContainer.findAndInitialize();
+1 -1
View File
@@ -1,3 +1,3 @@
import { BlockCommunication } from "$content/components/BlockCommunication";
import { BlockCommunication } from "$content/components/philomena/BlockCommunication";
BlockCommunication.findAndInitializeAll();
+4 -4
View File
@@ -1,6 +1,6 @@
import { TagsForm } from "$content/components/TagsForm";
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$content/components/TagsListBlock";
import { TagsForm } from "$content/components/philomena/TagsForm";
import { TagsListBlock } from "$content/components/philomena/TagsListBlock";
initializeAllTagsLists();
watchForUpdatedTagLists();
TagsListBlock.initializeAll();
TagsListBlock.watchUpdatedLists();
TagsForm.watchForEditors();
+3 -6
View File
@@ -1,7 +1,4 @@
import { watchTagDropdownsInTagsEditor, wrapTagDropdown } from "$content/components/TagDropdownWrapper";
import { TagDropdown } from "$content/components/philomena/TagDropdown";
for (let tagDropdownElement of document.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
watchTagDropdownsInTagsEditor();
TagDropdown.findAllAndInitialize();
TagDropdown.watch();
+3
View File
@@ -0,0 +1,3 @@
import { TagsForm } from "$content/components/philomena/TagsForm";
TagsForm.initializeUploadEditor();
+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');
})();
+12
View File
@@ -0,0 +1,12 @@
/**
* 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, subtype: string = 'solid'): HTMLElement {
const iconElement = document.createElement('i');
iconElement.classList.add(`fa-${subtype}`, `fa-${iconSlug}`);
return iconElement;
}
+7 -3
View File
@@ -2,8 +2,9 @@ import type StorageEntity from "$lib/extension/base/StorageEntity";
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
import type { ImportableElementsList, ImportableEntityObject } from "$lib/extension/transporting/importables";
import EntitiesTransporter, { type SameSiteStatus } from "$lib/extension/EntitiesTransporter";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import TaggingProfile from "$entities/TaggingProfile";
import TagGroup from "$entities/TagGroup";
import TagEditorPreset from "$entities/TagEditorPreset";
type TransportersMapping = {
[EntityName in keyof App.EntityNamesMap]: EntitiesTransporter<App.EntityNamesMap[EntityName]>;
@@ -73,10 +74,12 @@ export default class BulkEntitiesTransporter {
elements: entities
.map(entity => {
switch (true) {
case entity instanceof MaintenanceProfile:
case entity instanceof TaggingProfile:
return BulkEntitiesTransporter.#transporters.profiles.exportToObject(entity);
case entity instanceof TagGroup:
return BulkEntitiesTransporter.#transporters.groups.exportToObject(entity);
case entity instanceof TagEditorPreset:
return BulkEntitiesTransporter.#transporters.presets.exportToObject(entity);
}
return null;
@@ -99,8 +102,9 @@ export default class BulkEntitiesTransporter {
}
static #transporters: TransportersMapping = {
profiles: new EntitiesTransporter(MaintenanceProfile),
profiles: new EntitiesTransporter(TaggingProfile),
groups: new EntitiesTransporter(TagGroup),
presets: new EntitiesTransporter(TagEditorPreset),
}
/**
+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);
@@ -1,4 +1,4 @@
import type { TagDropdownWrapper } from "$content/components/TagDropdownWrapper";
import type { TagDropdown } from "$content/components/philomena/TagDropdown";
import TagGroup from "$entities/TagGroup";
import { escapeRegExp } from "$lib/utils";
import { emit } from "$content/components/events/comms";
@@ -7,7 +7,7 @@ import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdow
export default class CustomCategoriesResolver {
#exactGroupMatches = new Map<string, TagGroup>();
#regExpGroupMatches = new Map<RegExp, TagGroup>();
#tagDropdowns: TagDropdownWrapper[] = [];
#tagDropdowns: TagDropdown[] = [];
#nextQueuedUpdate: Timeout | null = null;
constructor() {
@@ -15,7 +15,7 @@ export default class CustomCategoriesResolver {
TagGroup.readAll().then(this.#onTagGroupsReceived.bind(this));
}
public addElement(tagDropdown: TagDropdownWrapper): void {
public addElement(tagDropdown: TagDropdown): void {
this.#tagDropdowns.push(tagDropdown);
if (!this.#exactGroupMatches.size && !this.#regExpGroupMatches.size) {
@@ -49,7 +49,7 @@ export default class CustomCategoriesResolver {
* @return {boolean} Will return false when tag is processed and true when it is not found.
* @private
*/
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdown): boolean {
const tagName = tagDropdown.tagName!;
if (!this.#exactGroupMatches.has(tagName)) {
@@ -65,7 +65,7 @@ export default class CustomCategoriesResolver {
return false;
}
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
#matchCustomCategoryByRegExp(tagDropdown: TagDropdown) {
const tagName = tagDropdown.tagName!;
for (const targetRegularExpression of this.#regExpGroupMatches.keys()) {
@@ -117,7 +117,7 @@ export default class CustomCategoriesResolver {
this.#queueUpdatingTags();
}
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
static #resetToOriginalCategory(tagDropdown: TagDropdown): void {
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
+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);
}
}
@@ -0,0 +1,184 @@
import ConfigurationController from "$lib/extension/ConfigurationController";
/**
* Initialization options for the preference field helper class.
*/
type PreferenceFieldOptions<FieldKey, ValueType> = {
/**
* Field name which will be read or updated.
*/
field: FieldKey;
/**
* Default value for this field.
*/
defaultValue: ValueType;
}
/**
* Helper class for a field. Contains all information needed to read or set the values into the preferences while
* retaining proper types for the values.
*/
export class PreferenceField<
/**
* Mapping of keys to fields. Usually this is the same type used for defining the structure of the storage itself.
* Is automatically captured when preferences class instance is passed into the constructor.
*/
Fields extends Record<string, any> = Record<string, any>,
/**
* Field key for resolving which value will be resolved from getter or which value type should be passed into the
* setter method.
*/
Key extends keyof Fields = keyof Fields
> {
/**
* Instance of the preferences class to read/update values on.
* @private
*/
readonly #preferences: CacheablePreferences<Fields>;
/**
* Key of a field we want to read or write with the helper class.
* @private
*/
readonly #fieldKey: Key;
/**
* Stored default value for a field.
* @private
*/
readonly #defaultValue: Fields[Key];
/**
* @param preferencesInstance Instance of preferences to work with.
* @param options Initialization options for this field.
*/
constructor(preferencesInstance: CacheablePreferences<Fields>, options: PreferenceFieldOptions<Key, Fields[Key]>) {
this.#preferences = preferencesInstance;
this.#fieldKey = options.field;
this.#defaultValue = options.defaultValue;
}
/**
* Read the field value from the preferences.
*/
get() {
return this.#preferences.readRaw(this.#fieldKey, this.#defaultValue);
}
/**
* Update the preference field with provided value.
* @param value Value to update the field with.
*/
set(value: Fields[Key]) {
return this.#preferences.writeRaw(this.#fieldKey, value);
}
get key(): Key {
return this.#fieldKey;
}
}
/**
* Helper type for preference classes to enforce having field objects inside the preferences instance. It should be
* applied on child classes of {@link CacheablePreferences}.
*/
export type WithFields<FieldsType extends Record<string, any>> = {
readonly [FieldKey in keyof FieldsType]: PreferenceField<FieldsType, FieldKey>;
}
/**
* Base class for any preferences instances. It contains methods for reading or updating any arbitrary values inside
* extension storage. It also tries to save the value resolved from the storage into special internal cache after the
* first call.
*
* Should be usually paired with implementation of {@link WithFields} helper type as interface for much more usable
* API.
*/
export default abstract class CacheablePreferences<Fields> {
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, controller: ConfigurationController = new ConfigurationController(settingsNamespace)) {
this.#controller = controller;
this.#disposables.push(
this.#controller.subscribeToChanges(settings => {
for (const key of Object.keys(settings)) {
this.#cachedValues.set(
key as keyof Fields,
settings[key]
);
}
})
);
}
/**
* Read the value from the preferences by the field. This function doesn't handle default values, so you generally
* should avoid using this method and accessing the special fields instead.
* @param settingName Name of the field to read.
* @param defaultValue Default value to return if value is not set.
* @return Value of the field or default value if it is not set.
*/
public async readRaw<Key extends keyof Fields>(settingName: Key, defaultValue: Fields[Key]): Promise<Fields[Key]> {
if (this.#cachedValues.has(settingName)) {
return this.#cachedValues.get(settingName);
}
const settingValue = await this.#controller.readSetting(settingName as string, defaultValue);
this.#cachedValues.set(settingName, settingValue);
return settingValue;
}
/**
* Write the value into specific field of the storage. You should generally avoid calling this function directly and
* instead rely on special field helpers inside your preferences class.
* @param settingName Name of the setting to write.
* @param value Value to pass.
* @param force Ignore the cache and force the update.
* @protected
*/
async writeRaw<Key extends keyof Fields>(settingName: Key, value: Fields[Key], force: boolean = false): Promise<void> {
if (
!force
&& this.#cachedValues.has(settingName)
&& this.#cachedValues.get(settingName) === value
) {
return;
}
return this.#controller.writeSetting(
settingName as string,
value
);
}
/**
* Subscribe to the changes made to the storage.
* @param callback Callback which will receive list of settings on every update. This function will not be called
* on initialization.
* @return Unsubscribe function to call in order to disable the watching.
*/
subscribe(callback: (settings: Partial<Fields>) => void): () => void {
const unsubscribeCallback = this.#controller.subscribeToChanges(callback as (fields: Record<string, any>) => void);
this.#disposables.push(unsubscribeCallback);
return unsubscribeCallback;
}
/**
* Completely disable all subscriptions.
*/
dispose() {
for (let disposeCallback of this.#disposables) {
disposeCallback();
}
}
}
@@ -1,81 +0,0 @@
import ConfigurationController from "$lib/extension/ConfigurationController";
export default class CacheableSettings<Fields> {
#controller: ConfigurationController;
#cachedValues: Map<keyof Fields, any> = new Map();
#disposables: Function[] = [];
constructor(settingsNamespace: string) {
this.#controller = new ConfigurationController(settingsNamespace);
this.#disposables.push(
this.#controller.subscribeToChanges(settings => {
for (const key of Object.keys(settings)) {
this.#cachedValues.set(
key as keyof Fields,
settings[key]
);
}
})
);
}
/**
* @template SettingType
* @param {string} settingName
* @param {SettingType} defaultValue
* @return {Promise<SettingType>}
* @protected
*/
protected async _resolveSetting<Key extends keyof Fields>(settingName: Key, defaultValue: Fields[Key]): Promise<Fields[Key]> {
if (this.#cachedValues.has(settingName)) {
return this.#cachedValues.get(settingName);
}
const settingValue = await this.#controller.readSetting(settingName as string, defaultValue);
this.#cachedValues.set(settingName, settingValue);
return settingValue;
}
/**
* @param settingName Name of the setting to write.
* @param value Value to pass.
* @param force Ignore the cache and force the update.
* @protected
*/
async _writeSetting<Key extends keyof Fields>(settingName: Key, value: Fields[Key], force: boolean = false): Promise<void> {
if (
!force
&& this.#cachedValues.has(settingName)
&& this.#cachedValues.get(settingName) === value
) {
return;
}
return this.#controller.writeSetting(
settingName as string,
value
);
}
/**
* Subscribe to the changes made to the storage.
* @param {function(Object): void} callback Callback which will receive list of settings.
* @return {function(): void} Unsubscribe function.
*/
subscribe(callback: (settings: Partial<Fields>) => void): () => void {
const unsubscribeCallback = this.#controller.subscribeToChanges(callback as (fields: Record<string, any>) => void);
this.#disposables.push(unsubscribeCallback);
return unsubscribeCallback;
}
dispose() {
for (let disposeCallback of this.#disposables) {
disposeCallback();
}
}
}
@@ -0,0 +1,23 @@
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> {
constructor(id: string, settings: Partial<TagEditorPresetSettings>) {
super(id, {
name: settings.name || '',
tags: settings.tags || [],
exclusive: settings.exclusive ?? false,
conditional: settings.conditional || false,
requiredTags: settings.requiredTags || [],
});
}
public static readonly _entityName = 'presets';
}
@@ -1,20 +1,20 @@
import StorageEntity from "$lib/extension/base/StorageEntity";
export interface MaintenanceProfileSettings {
export interface TaggingProfileSettings {
name: string;
tags: string[];
temporary: boolean;
}
/**
* Class representing the maintenance profile entity.
* Class representing the tagging profile entity.
*/
export default class MaintenanceProfile extends StorageEntity<MaintenanceProfileSettings> {
export default class TaggingProfile extends StorageEntity<TaggingProfileSettings> {
/**
* @param id ID of the entity.
* @param settings Maintenance profile settings object.
*/
constructor(id: string, settings: Partial<MaintenanceProfileSettings>) {
constructor(id: string, settings: Partial<TaggingProfileSettings>) {
super(id, {
name: settings.name || '',
tags: settings.tags || [],
@@ -0,0 +1,33 @@
import CacheablePreferences, {
PreferenceField,
type WithFields
} from "$lib/extension/base/CacheablePreferences";
export type FullscreenViewerSize = keyof App.ImageURIs;
interface MiscPreferencesFields {
fullscreenViewer: boolean;
fullscreenViewerSize: FullscreenViewerSize;
fullscreenViewerPopupShown: boolean;
}
export default class MiscPreferences extends CacheablePreferences<MiscPreferencesFields> implements WithFields<MiscPreferencesFields> {
constructor() {
super("misc");
}
readonly fullscreenViewer = new PreferenceField(this, {
field: "fullscreenViewer",
defaultValue: true,
});
readonly fullscreenViewerSize = new PreferenceField(this, {
field: "fullscreenViewerSize",
defaultValue: "large",
});
readonly fullscreenViewerPopupShown = new PreferenceField(this, {
field: "fullscreenViewerPopupShown",
defaultValue: true,
});
}
@@ -0,0 +1,40 @@
import TaggingProfile from "$entities/TaggingProfile";
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
interface TaggingProfilePreferencesFields {
activeProfile: string | null;
stripBlacklistedTags: boolean;
}
class ActiveProfilePreference extends PreferenceField<TaggingProfilePreferencesFields, "activeProfile"> {
constructor(preferencesInstance: CacheablePreferences<TaggingProfilePreferencesFields>) {
super(preferencesInstance, {
field: "activeProfile",
defaultValue: null,
});
}
async asObject(): Promise<TaggingProfile | null> {
const activeProfileId = await this.get();
if (!activeProfileId) {
return null;
}
return (await TaggingProfile.readAll())
.find(profile => profile.id === activeProfileId) || null;
}
}
export default class TaggingProfilesPreferences extends CacheablePreferences<TaggingProfilePreferencesFields> implements WithFields<TaggingProfilePreferencesFields> {
constructor() {
super("maintenance");
}
readonly activeProfile = new ActiveProfilePreference(this);
readonly stripBlacklistedTags = new PreferenceField(this, {
field: "stripBlacklistedTags",
defaultValue: false,
});
}
@@ -0,0 +1,28 @@
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
interface TagsPreferencesFields {
groupSeparation: boolean;
replaceLinks: boolean;
replaceLinkText: boolean;
}
export default class TagsPreferences extends CacheablePreferences<TagsPreferencesFields> implements WithFields<TagsPreferencesFields> {
constructor() {
super("tag");
}
readonly groupSeparation = new PreferenceField(this, {
field: "groupSeparation",
defaultValue: true,
});
readonly replaceLinks = new PreferenceField(this, {
field: "replaceLinks",
defaultValue: false,
});
readonly replaceLinkText = new PreferenceField(this, {
field: "replaceLinkText",
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,
});
}
@@ -1,48 +0,0 @@
import MaintenanceProfile from "$entities/MaintenanceProfile";
import CacheableSettings from "$lib/extension/base/CacheableSettings";
interface MaintenanceSettingsFields {
activeProfile: string | null;
stripBlacklistedTags: boolean;
}
export default class MaintenanceSettings extends CacheableSettings<MaintenanceSettingsFields> {
constructor() {
super("maintenance");
}
/**
* Set the active maintenance profile.
*/
async resolveActiveProfileId() {
return this._resolveSetting("activeProfile", null);
}
/**
* Get the active maintenance profile if it is set.
*/
async resolveActiveProfileAsObject(): Promise<MaintenanceProfile | null> {
const resolvedProfileId = await this.resolveActiveProfileId();
return (await MaintenanceProfile.readAll())
.find(profile => profile.id === resolvedProfileId) || null;
}
async resolveStripBlacklistedTags() {
return this._resolveSetting('stripBlacklistedTags', false);
}
/**
* Set the active maintenance profile.
*
* @param profileId ID of the profile to set as active. If `null`, the active profile will be considered
* unset.
*/
async setActiveProfileId(profileId: string | null): Promise<void> {
await this._writeSetting("activeProfile", profileId);
}
async setStripBlacklistedTags(isEnabled: boolean) {
await this._writeSetting('stripBlacklistedTags', isEnabled);
}
}
@@ -1,30 +0,0 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
export type FullscreenViewerSize = keyof App.ImageURIs;
interface MiscSettingsFields {
fullscreenViewer: boolean;
fullscreenViewerSize: FullscreenViewerSize;
}
export default class MiscSettings extends CacheableSettings<MiscSettingsFields> {
constructor() {
super("misc");
}
async resolveFullscreenViewerEnabled() {
return this._resolveSetting("fullscreenViewer", true);
}
async resolveFullscreenViewerPreviewSize() {
return this._resolveSetting('fullscreenViewerSize', 'large');
}
async setFullscreenViewerEnabled(isEnabled: boolean) {
return this._writeSetting("fullscreenViewer", isEnabled);
}
async setFullscreenViewerPreviewSize(size: FullscreenViewerSize | string) {
return this._writeSetting('fullscreenViewerSize', size as FullscreenViewerSize);
}
}
-28
View File
@@ -1,28 +0,0 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
interface TagSettingsFields {
groupSeparation: boolean;
replaceLinks: boolean;
}
export default class TagSettings extends CacheableSettings<TagSettingsFields> {
constructor() {
super("tag");
}
async resolveGroupSeparation() {
return this._resolveSetting("groupSeparation", true);
}
async resolveReplaceLinks() {
return this._resolveSetting("replaceLinks", false);
}
async setGroupSeparation(value: boolean) {
return this._writeSetting("groupSeparation", Boolean(value));
}
async setReplaceLinks(value: boolean) {
return this._writeSetting("replaceLinks", Boolean(value));
}
}
@@ -33,6 +33,19 @@ const entitiesExporters: ExportersMap = {
category: entity.settings.category,
separate: entity.settings.separate,
}
},
presets: entity => {
return {
$type: "presets",
$site: __CURRENT_SITE__,
v: 1,
id: entity.id,
name: entity.settings.name,
tags: entity.settings.tags,
exclusive: entity.settings.exclusive,
conditional: entity.settings.conditional,
requiredTags: entity.settings.requiredTags,
}
}
};
+25 -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';
}
/**
@@ -64,6 +72,22 @@ const entitiesValidators: EntitiesValidationMap = {
throw new Error('Invalid group format detected!');
}
},
presets: importedObject => {
if (!importedObject.v || importedObject.v > 1) {
throw new Error('Unsupported preset version!');
}
if (
!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!');
}
}
};
/**
@@ -1,6 +1,6 @@
import PostParser from "$lib/booru/scraped/parsing/PostParser";
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);
@@ -1,5 +1,5 @@
import PageParser from "$lib/booru/scraped/parsing/PageParser";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
import PageParser from "$lib/philomena/scraping/parsing/PageParser";
import { buildTagsAndAliasesMap } from "$lib/philomena/tag-utils";
export default class PostParser extends PageParser {
#tagEditorForm: HTMLFormElement | null = null;
@@ -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;
}
@@ -1,4 +1,5 @@
import { namespaceCategories } from "$config/tags";
import { QueryLexer, QuotedTermToken, TermToken } from "$lib/philomena/search/QueryLexer";
/**
* Build the map containing both real tags and their aliases.
@@ -41,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-', '\\'],
@@ -51,16 +52,48 @@ const slugEncodedCharacters: Map<string, string> = new Map([
]);
/**
* Decode the tag name from its link path.
* Try to parse the tag name from the search query URL. It uses the same tokenizer as the booru. It only returns the
* tag name if query contains only one single tag without any additional conditions.
*
* @param tagLink Full or partial link to the tag.
* @param searchLink Link with search query.
*
* @return Tag name or NULL if function is failed to recognize the link as tag-related link.
* @return Tag name or NULL if query contains more than 1 tag or doesn't have any tags at all.
*/
export function decodeTagNameFromLink(tagLink: string): string | null {
function parseTagNameFromSearchQuery(searchLink: URL): string | null {
const lexer = new QueryLexer(searchLink.searchParams.get('q') || '');
const parsedQuery = lexer.parse();
if (parsedQuery.length !== 1) {
return null;
}
const [token] = parsedQuery;
switch (true) {
case token instanceof TermToken:
return token.value;
case token instanceof QuotedTermToken:
return token.decodedValue;
}
return null;
}
/**
* Decode the tag name from the following link.
*
* @param tagLink Search link or link to the tag to parse the tag name from.
*
* @return Tag name or NULL if function is failed to parse the name of the tag.
*/
export function resolveTagNameFromLink(tagLink: URL): string | null {
if (tagLink.pathname.startsWith('/search') && tagLink.searchParams.has('q')) {
return parseTagNameFromSearchQuery(tagLink);
}
tagLinkRegExp.lastIndex = 0;
const result = tagLinkRegExp.exec(tagLink);
const result = tagLinkRegExp.exec(tagLink.pathname);
const encodedTagName = result?.groups?.encodedTagName;
if (!encodedTagName) {
@@ -68,8 +101,8 @@ export function decodeTagNameFromLink(tagLink: string): string | null {
}
return decodeURIComponent(encodedTagName)
.replaceAll(/-[a-z]+-/gi, match => slugEncodedCharacters.get(match) ?? match)
.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);
}
}
+14
View File
@@ -1,3 +1,6 @@
import type StorageEntity from "$lib/extension/base/StorageEntity";
import type TagGroup from "$entities/TagGroup";
/**
* Traverse and find the object using the key path.
* @param targetObject Target object to traverse into.
@@ -39,3 +42,14 @@ export function escapeRegExp(value: string): string {
unsafeRegExpCharacters.lastIndex = 0;
return value.replace(unsafeRegExpCharacters, "\\$&");
}
type OnlyStringFields<Fields extends Record<string, any>> = {
[FieldKey in keyof Fields as Fields[FieldKey] extends string ? FieldKey : never]: string;
};
export function sortEntitiesByField<Fields extends Object>(entities: StorageEntity<Fields>[], fieldName: keyof OnlyStringFields<Fields>) {
return entities.toSorted(
(a, b) => (a.settings[fieldName] as string)
.localeCompare(b.settings[fieldName] as string)
);
}
+8 -7
View File
@@ -1,31 +1,32 @@
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { activeProfileStore, maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import { activeTaggingProfile, taggingProfiles } from "$stores/entities/tagging-profiles";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import TaggingProfile from "$entities/TaggingProfile";
import { popupTitle } from "$stores/popup";
$popupTitle = null;
let activeProfile = $derived<MaintenanceProfile | null>(
$maintenanceProfiles.find(profile => profile.id === $activeProfileStore) || null
let activeProfile = $derived<TaggingProfile | null>(
$taggingProfiles.find(profile => profile.id === $activeTaggingProfile) || null
);
function turnOffActiveProfile() {
$activeProfileStore = null;
$activeTaggingProfile = null;
}
</script>
<Menu>
{#if activeProfile}
<MenuCheckboxItem checked oninput={turnOffActiveProfile} href="/features/maintenance/{activeProfile.id}">
<MenuCheckboxItem checked oninput={turnOffActiveProfile} href="/features/profiles/{activeProfile.id}">
Active Profile: {activeProfile.settings.name}
</MenuCheckboxItem>
<hr>
{/if}
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
<MenuItem href="/features/profiles">Tagging Profiles</MenuItem>
<MenuItem href="/features/groups">Tag Groups</MenuItem>
<MenuItem href="/features/presets">Tag Presets</MenuItem>
<hr>
<MenuItem href="/transporting">Import/Export</MenuItem>
<MenuItem href="/preferences">Preferences</MenuItem>
+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>
+1 -1
View File
@@ -6,5 +6,5 @@
<Menu>
<MenuItem href="/">Back</MenuItem>
<hr>
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
<MenuItem href="/features/profiles">Tagging Profiles</MenuItem>
</Menu>
@@ -1,46 +0,0 @@
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import MenuRadioItem from "$components/ui/menu/MenuRadioItem.svelte";
import { activeProfileStore, maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import { popupTitle } from "$stores/popup";
$popupTitle = 'Tagging Profiles';
let profiles = $derived<MaintenanceProfile[]>(
$maintenanceProfiles.sort((a, b) => a.settings.name.localeCompare(b.settings.name))
);
function resetActiveProfile() {
$activeProfileStore = null;
}
function enableSelectedProfile(event: Event) {
const target = event.target;
if (target instanceof HTMLInputElement && target.checked) {
activeProfileStore.set(target.value);
}
}
</script>
<Menu>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/maintenance/new/edit" icon="plus">Create New</MenuItem>
{#if profiles.length}
<hr>
{/if}
{#each profiles as profile}
<MenuRadioItem href="/features/maintenance/{profile.id}"
name="active-profile"
value={profile.id}
checked={$activeProfileStore === profile.id}
oninput={enableSelectedProfile}>
{profile.settings.name}
</MenuRadioItem>
{/each}
<hr>
<MenuItem href="#" onclick={resetActiveProfile}>Reset Active Profile</MenuItem>
<MenuItem href="/features/maintenance/import">Import Profile</MenuItem>
</Menu>
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
import { sortEntitiesByField } from "$lib/utils";
let presets = $derived(sortEntitiesByField($tagEditorPresets, "name"))
</script>
<Menu>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/presets/new/edit" icon="plus">Create New</MenuItem>
{#if presets.length}
<hr>
{#each presets as preset}
<MenuItem href="/features/presets/{preset.id}">{preset.settings.name}</MenuItem>
{/each}
{/if}
</Menu>
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more