Compare commits

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

* Updated `svelte` from 5.56.3 to 5.57.2

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

* Updated `svelte-check` from 4.6.0 to 4.7.6

* Updated `sass` from 1.101.0 to 1.105.1

* Updated `vite` from 7.3.5 to 7.3.7

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

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

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

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

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

* Updated `svelte` from 5.50.0 to 5.53.3

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

* Move `devDependencies` to the bottom

* Updated `jsdom` from 28.0.0 to 28.1.0

* Updated `svelte-check` from 4.3.6 to 4.4.3

* Updated `@types/chrome` from 0.1.36 to 0.1.37

* Updated `@types/node` from 25.2.2 to 25.3.0
2026-02-23 20:05:27 +04:00
koloml 7ffee170c3 Merge pull request #149 from koloml/feature/tantabus-support
Added Tantabus version of extension
2026-02-23 19:03:19 +04:00
koloml db34b361b3 Merge pull request #152 from koloml/feature/color-tags-in-tagging-popup
Tagging Popup: Automatically color tags by their tag namespaces
2026-02-23 18:45:44 +04:00
koloml bf81b7111f Tagging Popup: Automatically color tags by their tag namespaces 2026-02-23 18:38:07 +04:00
koloml dc79959b8f Merge pull request #150 from koloml/feature/decorated-tag-links-in-forum
Added option to decorate tag links in forum posts
2026-02-23 18:25:46 +04:00
koloml dfdab180ee Added option to decorate tag links in forum posts 2026-02-23 17:51:27 +04:00
koloml b768f9072c Updating README to include info on Tantabus as well 2026-02-22 20:08:42 +04:00
koloml 72a731aaff Updating CI to build for Tantabus on release as well 2026-02-22 20:07:57 +04:00
koloml d181509d6f Preparing extension for Tantabus 2026-02-22 01:49:31 +04:00
koloml 03b0763db4 Merge pull request #146 from koloml/release/0.5.4
Release: 0.5.4
2026-02-09 11:29:08 +04:00
koloml a7e0aefe6b Merge pull request #148 from koloml/feature/centralized-component-for-errors-and-warnings
Popup: Combining warnings and error messages into single component
2026-02-09 11:27:43 +04:00
koloml 687c12a8f4 Putting errors and warnings into separate component 2026-02-09 11:24:01 +04:00
koloml 9b7ba4a6e2 Bumped version to 0.5.4 2026-02-09 11:05:42 +04:00
koloml 8d7b151911 Merge pull request #147 from koloml/bugfix/update-tags-on-last-group-deleted
Fixed custom categories not refreshing on tags once last group is deleted
2026-02-09 11:01:50 +04:00
koloml fccd79292d Fixed tags list didn't update itself once last group was deleted 2026-02-09 10:59:40 +04:00
koloml 8041f2d2a1 Merge pull request #145 from koloml/chore/dependencies
Updating dependencies
2026-02-09 10:56:00 +04:00
koloml 3fac472ae0 Merge pull request #144 from koloml/bugfix/bulk-import
Fixed bulk import only adding one entry from the list
2026-02-09 10:55:50 +04:00
koloml 44aca3120c Updated @types/node from 25.0.3 to 25.2.2 2026-02-09 10:50:01 +04:00
koloml 3aee3defba Updated @types/chrome from 0.1.32 to 0.1.36 2026-02-09 10:49:33 +04:00
koloml b7a9dc2a2b Updated jsdom from 27.4.0 to 28.0.0 2026-02-09 10:48:56 +04:00
koloml 242dfc5972 Updated cheerio from 1.1.2 to 1.2.0 2026-02-09 10:48:01 +04:00
koloml b6840996b6 Updated sass from 1.97.2 to 1.97.3 2026-02-09 10:47:22 +04:00
koloml 4c5b796f1d Updated @sveltejs/vite-plugin-svelte from 6.2.3 to 6.2.4 2026-02-09 10:46:35 +04:00
koloml 7f2e06a1b1 Updated vitest and @vitest/coverage-v8 from 4.0.16 to 4.0.18 2026-02-09 10:45:40 +04:00
koloml 31a33131cd Updated svelte-check from 4.3.5 to 4.3.6 2026-02-09 10:44:56 +04:00
koloml 7063459622 Updated @sveltejs/kit from 2.49.4 to 2.50.2 2026-02-09 10:44:03 +04:00
koloml 5a82b8751d Updated svelte from 5.46.1 to 5.50.0 2026-02-09 10:43:25 +04:00
koloml 9318bd51fa Fixed bulk import only saving last entry 2026-02-09 10:41:10 +04:00
koloml ab625d0181 Merge pull request #141 from koloml/release/0.5.3
Release: 0.5.3
2026-01-09 08:51:45 +04:00
koloml c59d8f55f0 Bumped version to 0.5.3 2026-01-09 08:50:05 +04:00
koloml 8dfc5f49f9 Merge pull request #143 from koloml/feature/code-reorganization
Slight change in code organization for content script components
2026-01-09 08:48:59 +04:00
koloml 2ecd37512f Moving all content_scripts-related components under $content directory
Having $lib/component with just $component was a bit confusing,
especially since $lib is also used in Svelte components all over the
place. This move will hopefully make it less confusing for me.
2026-01-09 07:06:58 +04:00
koloml c8ff80d445 Move list of tag categories into the tags config script 2026-01-09 06:55:51 +04:00
koloml 38cbd725d9 Merge pull request #142 from koloml/bugfix/profile-view-tags-list
Profile View: Fixed tags list not being properly reactive in the extension popup
2026-01-09 06:44:23 +04:00
koloml 26f09c7c46 Fixed tags list for tagging profiles not updating reactively in popup 2026-01-09 06:41:28 +04:00
koloml 64be6a6e15 Bumping dependencies (#140)
* Updated `vite` from 7.1.6 to 7.3.1

* Updated `@sveltejs/vite-plugin-svelte` from 6.2.0 to 6.2.3

* Updated `@sveltejs/kit` from 2.42.2 to 2.49.4

* Updated `@sveltejs/adapter-static` from 3.0.9 to 3.0.10

* Updated `svelte` from 5.39.4 to 5.46.1

* Updated `svelte-check` from 4.3.1 to 4.3.5

* Updated `typescript` from 5.9.2 to 5.9.3

* Updated `sass` from 1.93.0 to 1.97.2

* Updated `jsdom` from 27.0.0 to 27.4.0

* Updated `cross-env` from 10.0.0 to 10.1.0

* Updated `@types/node` from 22.18.6 to 25.0.3

* Updated `@types/chrome` from 0.0.326 to 0.1.32

* Updated `vitest` and `@vitest/coverage-v8` from 3.2.4 to 4.0.16

* Updated `@fortawesome/fontawesome-free` from 6.7.2 to 7.1.0
2026-01-09 06:35:52 +04:00
koloml cb22b2deab Merge pull request #139 from koloml/feature/display-dedicated-popup-titles
Popup: Display different tab titles for different routes
2026-01-09 06:35:25 +04:00
koloml 5c5e0812dc Provide names for all popup routes using new store 2026-01-09 05:56:33 +04:00
koloml 70129d7a0e Added the store for dynamically changing the popup titles 2026-01-09 05:27:48 +04:00
koloml 5fd6dee999 Added constant with the full name of the plugin 2026-01-09 05:27:11 +04:00
koloml ec41ba5030 Furbooru: Added screenshots for tag groups feature 2025-11-04 19:26:47 -05:00
koloml 55624285e1 Merge pull request #138 from koloml/feature/release-build-pipeline
Added GitHub action for building the project in CI
2025-10-02 13:41:29 +04:00
koloml b97255ccd6 Release CI action
Builds the extension for both sites and uploads them as artifacts. This
will make it possible to just release the project and then grab the
ready-to-be-deployed archive for publishing.

This change was made using Cursor. Just a small test run to check how
useful it is for my workflows.
2025-10-02 13:40:02 +04:00
koloml ef76560bfb Merge pull request #136 from koloml/release/0.5.2
Release: 0.5.2
2025-09-27 22:24:38 +04:00
koloml faa909a0db Properly rearranging non-dev dependencies 2025-09-27 22:22:40 +04:00
koloml 3955e3191e Bumped version to 0.5.2 2025-09-27 22:15:38 +04:00
koloml 17dab5854c Merge pull request #137 from koloml/feature/reduce-gap-in-tag-category-titles
Furbooru: Applying styling changes previously used only for Derpibooru
2025-09-24 13:13:22 +04:00
koloml a20632e58e Furbooru: Updated tags appearance in media box popups 2025-09-22 22:50:48 +04:00
koloml 5f4a1a6c00 Furbooru: Use margin for tag category titles used for Derpibooru 2025-09-22 22:49:33 +04:00
koloml 48fc58f042 Merge pull request #135 from koloml/feature/updated-tag-dropdown
Making dropdown link icon active for both boorus
2025-09-22 05:09:45 +04:00
koloml 8356956b2e Making dropdown link icon active for both boorus
Furbooru was updated to the latest version of Philomena a few days ago.
Now it uses icons in tag dropdown just like on Derpibooru.
2025-09-22 05:05:52 +04:00
koloml 3833cada1e Bumping dependencies (#134)
* Updated `vite` from 6.3.5 to 7.1.2

* Updated `@sveltejs/kit` and `@sveltejs/vite-plugin-svelte`

These are updated together, since they're interconnected with Vite

* Updated `svelte` from 5.33.14 to 5.38.1

* Updated `@sveltejs/adapter-static` from 3.0.8 to 3.0.9

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

* Updated `svelte-check` from 4.2.1 to 4.3.1

* Updated `typescript` from 5.8.3 to 5.9.2

* Updated `sass` from 1.89.1 to 1.90.0

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

* Updated `cheerio` from 1.0.0 to 1.1.2

* Updated `@sveltejs/kit` from 2.31.1 to 2.42.2

* Updated `vite` from 7.1.2 to 7.1.6

* Updated `@sveltejs/vite-plugin-svelte` from 6.1.2 to 6.2.0

* Updated `svelte` from 5.38.1 to 5.39.4

* Updated `sass` from 1.90.0 to 1.93.0

* Updated `@types/node` from 22.17.2 to 22.18.6

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

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

- Scripts are now built in 2 steps instead of building every script and
style one at a time;
- Scripts are built as AMD modules;
- Dependencies are automatically injected into resulting manifest.json
file.
2025-04-06 15:12:12 +04:00
koloml bdbe49b419 Installed amd-lite 1.0.1 2025-04-06 15:08:14 +04:00
koloml ed779a8481 Merge remote-tracking branch 'origin/master' into feature/bulk-import-and-export
# Conflicts:
#	src/lib/extension/transporting/exporters.ts
2025-04-04 18:25:27 +04:00
koloml 9d7f5c0f38 Initial implementation of bulk transporter with import methods 2025-02-20 20:36:34 +04:00
koloml f67a321a66 Extracted method to import directly from object 2025-02-20 20:32:18 +04:00
koloml 5dc41700b8 Introducing lists of importable entities, renaming some interfaces 2025-02-19 03:42:20 +04:00
koloml c93c3c7bd5 Swapped validator arguments to meet the order on exporter function, fixing types 2025-02-19 03:19:01 +04:00
koloml 459b1fa779 Marking entity name as key of the mapping for type safety 2025-02-19 03:17:52 +04:00
koloml 62dc38b35a Fixed exporters not saving categories for groups and temporary flags for profiles 2025-02-19 03:08:54 +04:00
koloml 07373e17d5 Updated exporters to use importable types for more type safety 2025-02-19 03:07:52 +04:00
koloml 371bce133e Moved base types used in validators to separate module 2025-02-19 03:07:03 +04:00
204 changed files with 8536 additions and 3910 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

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: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 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
+63
View File
@@ -0,0 +1,63 @@
name: Build Extensions
on:
push:
branches: [ master ]
jobs:
build-extensions:
runs-on: ubuntu-latest
strategy:
matrix:
site: [furbooru, derpibooru, tantabus]
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '25'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build extension for ${{ matrix.site }}
run: |
if [ "${{ matrix.site }}" = "furbooru" ]; then
npm run build
else
npm run build:${{ matrix.site }}
fi
- name: Create extension zip
run: |
cd build
zip -r "../${{ matrix.site }}-tagging-assistant-extension.zip" .
- name: Upload extension artifact
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.site }}-tagging-assistant-extension
path: ${{ matrix.site }}-tagging-assistant-extension.zip
retention-days: 30
create-release-artifacts:
runs-on: ubuntu-latest
needs: build-extensions
steps:
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
- name: Create combined artifact
uses: actions/upload-artifact@v7
with:
name: all-extensions
path: artifacts/
retention-days: 90
+172 -32
View File
@@ -1,7 +1,9 @@
import {build} from "vite";
import {createHash} from "crypto";
import { build } from "vite";
import { createHash } from "crypto";
import path from "path";
import fs from "fs";
import { SwapDefinedVariablesPlugin } from "../plugins/swap-defined-variables.js";
import { ScssViteReadEnvVariableFunctionPlugin } from "../plugins/scss-read-env-variable-function.js";
/**
* Create the result base file name for the file.
@@ -49,6 +51,7 @@ function wrapScriptIntoIIFE() {
function makeAliases(rootDir) {
return {
"$config": path.resolve(rootDir, 'src/config'),
"$content": path.resolve(rootDir, 'src/content'),
"$lib": path.resolve(rootDir, 'src/lib'),
"$entities": path.resolve(rootDir, 'src/lib/extension/entities'),
"$styles": path.resolve(rootDir, 'src/styles'),
@@ -56,69 +59,198 @@ function makeAliases(rootDir) {
}
/**
* Build the selected script separately.
* @param {AssetBuildOptions} buildOptions Building options for the script.
* @return {Promise<string>} Result file path.
* @param {import('rollup').OutputChunk} chunk
* @param {import('rollup').OutputBundle} bundle
* @param {Set<import('rollup').OutputChunk>} processedChunks
* @return string[]
*/
export async function buildScript(buildOptions) {
const outputBaseName = createOutputBaseName(buildOptions.input);
function collectChunkDependencies(chunk, bundle, processedChunks = new Set()) {
if (processedChunks.has(chunk) || !chunk.imports) {
return [];
}
processedChunks.add(chunk);
return chunk.imports.concat(
chunk.imports
.map(importedChunkName => {
const module = bundle[importedChunkName];
if (module.type === 'chunk') {
return collectChunkDependencies(module, bundle, processedChunks);
}
return [];
})
.flat()
);
}
/**
* @param {(fileName: string, dependencies: string[]) => void} onDependencyResolvedCallback
* @returns {import('vite').Plugin}
*/
function collectDependenciesForManifestBuilding(onDependencyResolvedCallback) {
return {
name: 'extract-dependencies-for-content-scripts',
enforce: "post",
/**
* @param {any} options
* @param {import('rollup').OutputBundle} bundle
*/
writeBundle(options, bundle) {
Object.keys(bundle).forEach(fileName => {
const chunk = bundle[fileName];
if (chunk.type !== "chunk" || !chunk.facadeModuleId) {
return;
}
const dependencies = Array.from(
new Set(
collectChunkDependencies(chunk, bundle)
)
);
onDependencyResolvedCallback(fileName, dependencies);
});
}
}
}
/**
* Second revision of the building logic for the content scripts. This method tries to address duplication of
* dependencies generated with the previous method, where every single content script was built separately.
* @param {BatchBuildOptions} buildOptions
* @returns {Promise<Map<string, string[]>>}
*/
export async function buildScriptsAndStyles(buildOptions) {
/** @type {Map<string, string[]>} */
const pathsReplacement = new Map();
/** @type {Map<string, string[]>} */
const pathsReplacementByOutputPath = new Map();
const amdScriptsInput = {};
const libsAndStylesInput = {};
for (const inputPath of buildOptions.inputs) {
let outputExtension = path.extname(inputPath);
if (outputExtension === '.scss') {
outputExtension = '.css';
}
if (outputExtension === '.ts') {
outputExtension = '.js';
}
const outputPath = createOutputBaseName(inputPath);
const replacementsArray = [`${outputPath}${outputExtension}`];
pathsReplacement.set(inputPath, replacementsArray);
if (outputExtension === '.css' || inputPath.includes('/deps/')) {
libsAndStylesInput[outputPath] = inputPath;
continue;
}
pathsReplacementByOutputPath.set(outputPath + '.js', replacementsArray);
amdScriptsInput[outputPath] = inputPath;
}
const aliasesSettings = makeAliases(buildOptions.rootDir);
const defineConstants = {
__CURRENT_SITE__: JSON.stringify('furbooru'),
__CURRENT_SITE_NAME__: JSON.stringify('Furbooru'),
};
const derpibooruSwapPlugin = SwapDefinedVariablesPlugin({
envVariable: 'SITE',
expectedValue: 'derpibooru',
define: {
__CURRENT_SITE__: JSON.stringify('derpibooru'),
__CURRENT_SITE_NAME__: JSON.stringify('Derpibooru'),
}
});
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,
publicDir: false,
build: {
rollupOptions: {
input: {
[outputBaseName]: buildOptions.input
},
input: amdScriptsInput,
output: {
dir: buildOptions.outputDir,
entryFileNames: '[name].js'
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name]-[hash].js',
// ManifestV3 doesn't allow to use modern ES modules syntax, so we build all content scripts as AMD modules.
format: "amd",
inlineDynamicImports: false,
amd: {
// amd-lite requires names even for the entry-point scripts, so we should make sure to add those.
autoId: true,
},
// All these modules are not intended to be used outside of extension anyway
minifyInternalExports: true,
}
},
emptyOutDir: false,
},
resolve: {
alias: makeAliases(buildOptions.rootDir)
alias: aliasesSettings,
},
plugins: [
wrapScriptIntoIIFE()
]
wrapScriptIntoIIFE(),
collectDependenciesForManifestBuilding((fileName, dependencies) => {
pathsReplacementByOutputPath
.get(fileName)
?.push(...dependencies);
}),
derpibooruSwapPlugin,
tantabusSwapPlugin,
],
define: defineConstants,
});
return path.resolve(buildOptions.outputDir, `${outputBaseName}.js`);
}
/**
* Build the selected stylesheet.
* @param {AssetBuildOptions} buildOptions Build options for the stylesheet.
* @return {Promise<string>} Result file path.
*/
export async function buildStyle(buildOptions) {
const outputBaseName = createOutputBaseName(buildOptions.input);
// Build styles separately because AMD converts styles to JS files.
await build({
configFile: false,
publicDir: false,
build: {
rollupOptions: {
input: {
[outputBaseName]: buildOptions.input
},
input: libsAndStylesInput,
output: {
dir: buildOptions.outputDir,
entryFileNames: '[name].js',
assetFileNames: '[name].[ext]',
}
},
emptyOutDir: false,
emptyOutDir: false
},
resolve: {
alias: makeAliases(buildOptions.rootDir)
}
alias: aliasesSettings,
},
plugins: [
wrapScriptIntoIIFE(),
ScssViteReadEnvVariableFunctionPlugin(),
derpibooruSwapPlugin,
tantabusSwapPlugin,
],
define: defineConstants,
});
return path.resolve(buildOptions.outputDir, `${outputBaseName}.css`);
return pathsReplacement;
}
/**
@@ -127,3 +259,11 @@ export async function buildStyle(buildOptions) {
* @property {string} outputDir Destination folder for the script.
* @property {string} rootDir Root directory of the repository.
*/
/**
* @typedef {Object} BatchBuildOptions
* @property {Set<string>} inputs Set of all scripts and styles to build.
* @property {string} outputDir Destination folder for the assets.
* @property {string} rootDir Root directory of the repository.
* @property {(fileName: string, dependencies: string[]) => void} onDependenciesResolved Callback for dependencies.
*/
+116 -1
View File
@@ -17,6 +17,38 @@ class ManifestProcessor {
this.#manifestObject = parsedManifest;
}
/**
* Collect all the content scripts & stylesheets for single build action.
*
* @returns {Set<string>}
*/
collectContentScripts() {
const contentScripts = this.#manifestObject.content_scripts;
if (!contentScripts) {
console.info('No content scripts to collect.');
return new Set();
}
const entryPoints = new Set();
for (let entry of contentScripts) {
if (entry.js) {
for (let jsPath of entry.js) {
entryPoints.add(jsPath);
}
}
if (entry.css) {
for (let cssPath of entry.css) {
entryPoints.add(cssPath);
}
}
}
return entryPoints;
}
/**
* Map over every content script defined in the manifest. If no content scripts defined, no calls will be made to the
* callback.
@@ -54,6 +86,48 @@ class ManifestProcessor {
}
}
/**
* Find all patterns in content scripts and host permissions and replace the hostname to the different one.
*
* @param {string|string[]} singleOrMultipleHostnames One or multiple hostnames to replace the original hostname with.
*/
replaceHostTo(singleOrMultipleHostnames) {
if (typeof singleOrMultipleHostnames === 'string') {
singleOrMultipleHostnames = [singleOrMultipleHostnames];
}
const matchPatterReplacer = ManifestProcessor.#createHostnameReplacementReduce(singleOrMultipleHostnames);
this.#manifestObject.host_permissions = singleOrMultipleHostnames.map(hostname => `*://*.${hostname}/`);
this.#manifestObject.content_scripts?.forEach(entry => {
entry.matches = entry.matches.reduce(matchPatterReplacer, []);
if (entry.exclude_matches) {
entry.exclude_matches = entry.exclude_matches.reduce(matchPatterReplacer, []);
}
})
}
/**
* Set different identifier for Gecko-based browsers (Firefox).
*
* @param {string} id ID of the extension to use.
*/
setGeckoIdentifier(id) {
this.#manifestObject.browser_specific_settings.gecko.id = id;
}
/**
* Set the different extension name.
*
* @param {string} booruName
*/
replaceBooruNameWith(booruName) {
this.#manifestObject.name = this.#manifestObject.name.replaceAll('Furbooru', booruName);
this.#manifestObject.description = this.#manifestObject.description.replaceAll('Furbooru', booruName);
}
/**
* Save the current state of the manifest into the selected file.
*
@@ -69,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;
}
);
}
}
/**
@@ -86,13 +186,28 @@ export function loadManifest(filePath) {
/**
* @typedef {Object} Manifest
* @property {string} name
* @property {string} description
* @property {string} version
* @property {BrowserSpecificSettings} browser_specific_settings
* @property {string[]} host_permissions
* @property {ContentScriptsEntry[]|undefined} content_scripts
*/
/**
* @typedef {Object} BrowserSpecificSettings
* @property {GeckoSettings} gecko
*/
/**
* @typedef {Object} GeckoSettings
* @property {string} id
*/
/**
* @typedef {Object} ContentScriptsEntry
* @property {string[]} mathces
* @property {string[]} matches
* @property {string[]} exclude_matches
* @property {string[]|undefined} js
* @property {string[]|undefined} css
*/
+119 -33
View File
@@ -1,8 +1,9 @@
import {loadManifest} from "./lib/manifest.js";
import { loadManifest } from "./lib/manifest.js";
import path from "path";
import {buildScript, buildStyle} from "./lib/content-scripts.js";
import {normalizePath} from "vite";
import {extractInlineScriptsFromIndex} from "./lib/index-file.js";
import { buildScriptsAndStyles } from "./lib/content-scripts.js";
import { extractInlineScriptsFromIndex } from "./lib/index-file.js";
import { normalizePath } from "vite";
import fs from "fs";
/**
* Build addition assets required for the extension and pack it into the directory.
@@ -11,49 +12,134 @@ import {extractInlineScriptsFromIndex} from "./lib/index-file.js";
export async function packExtension(settings) {
const manifest = loadManifest(path.resolve(settings.rootDir, 'manifest.json'));
// Since we CAN'T really build all scripts and stylesheets in a single build entry, we will run build for every single
// one of them in a row. This way, no chunks will be generated. Thanks, ManifestV3!
await manifest.mapContentScripts(async (entry) => {
if (entry.js) {
for (let scriptIndex = 0; scriptIndex < entry.js.length; scriptIndex++) {
const builtScriptFilePath = await buildScript({
input: path.resolve(settings.rootDir, entry.js[scriptIndex]),
outputDir: settings.contentScriptsDir,
rootDir: settings.rootDir,
});
const replacementMapping = await buildScriptsAndStyles({
inputs: manifest.collectContentScripts(),
outputDir: settings.contentScriptsDir,
rootDir: settings.rootDir,
});
entry.js[scriptIndex] = normalizePath(
path.relative(
settings.exportDir,
builtScriptFilePath
await manifest.mapContentScripts(async entry => {
if (entry.js) {
entry.js = entry.js
.map(jsSourcePath => {
if (!replacementMapping.has(jsSourcePath)) {
return [];
}
return replacementMapping.get(jsSourcePath);
})
.flat(1)
.map(pathName => {
return normalizePath(
path.relative(
settings.exportDir,
path.join(
settings.contentScriptsDir,
pathName
)
)
)
);
}
});
}
if (entry.css) {
for (let styleIndex = 0; styleIndex < entry.css.length; styleIndex++) {
const builtStylesheetFilePath = await buildStyle({
input: path.resolve(settings.rootDir, entry.css[styleIndex]),
outputDir: settings.contentScriptsDir,
rootDir: settings.rootDir
});
entry.css = entry.css
.map(jsSourcePath => {
if (!replacementMapping.has(jsSourcePath)) {
return [];
}
entry.css[styleIndex] = normalizePath(
path.relative(
settings.exportDir,
builtStylesheetFilePath
return replacementMapping.get(jsSourcePath);
})
.flat(1)
.map(pathName => {
return normalizePath(
path.relative(
settings.exportDir,
path.join(
settings.contentScriptsDir,
pathName
)
)
)
);
}
})
}
return entry;
});
})
switch (process.env.SITE) {
case 'localhost':
manifest.replaceHostTo('localhost');
manifest.replaceBooruNameWith('Philomena');
manifest.setGeckoIdentifier('philomena-tagging-assistant@thecore.city');
break;
case 'derpibooru':
manifest.replaceHostTo([
'derpibooru.org',
'trixiebooru.org'
]);
manifest.replaceBooruNameWith('Derpibooru');
manifest.setGeckoIdentifier('derpibooru-tagging-assistant@thecore.city');
break;
case 'tantabus':
manifest.replaceHostTo('tantabus.ai');
manifest.replaceBooruNameWith('Tantabus');
manifest.setGeckoIdentifier('tantabus-tagging-assistant@thecore.city');
break;
default:
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'));
}
@@ -0,0 +1,46 @@
import { SassString, Value } from "sass";
/**
* @return {import('vite').Plugin}
*/
export function ScssViteReadEnvVariableFunctionPlugin() {
return {
name: 'koloml:scss-read-env-variable-function',
apply: 'build',
enforce: 'post',
configResolved: config => {
config.css.preprocessorOptions ??= {};
config.css.preprocessorOptions.scss ??= {};
config.css.preprocessorOptions.scss.functions ??= {};
/**
* @param {Value[]} args
* @return {SassString}
*/
config.css.preprocessorOptions.scss.functions['vite-read-env-variable($constant-name)'] = (args) => {
const constName = args[0].assertString('constant-name').text;
if (config.define && config.define.hasOwnProperty(constName)) {
let returnedValue = config.define[constName];
try {
returnedValue = JSON.parse(returnedValue);
} catch {
returnedValue = null;
}
if (typeof returnedValue !== 'string') {
console.warn(`Attempting to read the constant with non-string type: ${constName}`);
return new SassString('');
}
return new SassString(returnedValue);
}
console.warn(`Constant does not exist: ${constName}`);
return new SassString('');
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
/**
* @param {SwapDefinedVariablesSettings} settings
* @return {import('vite').Plugin}
*/
export function SwapDefinedVariablesPlugin(settings) {
return {
name: 'koloml:swap-defined-variables',
enforce: 'post',
configResolved: (config) => {
if (
config.define
&& process.env.hasOwnProperty(settings.envVariable)
&& process.env[settings.envVariable] === settings.expectedValue
) {
for (const [key, value] of Object.entries(settings.define)) {
config.define[key] = value;
}
}
}
}
}
/**
* @typedef {Object} SwapDefinedVariablesSettings
* @property {string} envVariable
* @property {string} expectedValue
* @property {Record<string, string>} define
*/
+64 -6
View File
@@ -1,10 +1,58 @@
# Furbooru Tagging Assistant
# Philomena Tagging Assistant
This is a browser extension written for the [Furbooru](https://furbooru.org), [Derpibooru](https://derpibooru.org) and
[Tantabus](https://tantabus.ai) image-boards. It gives you the ability to manually go over the list of images and apply
tags to them without opening each individual image.
## Installation
This extension is available for both Chromium- and Firefox-based browsers. You can find the links to the extension pages
below.
### Furbooru
[![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)
This is a browser extension written for the [Furbooru](https://furbooru.org) image-board. It gives you the ability to
tag the images more easily and quickly.
### 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
Select a set of tags and add/remove them from images without opening them. Just hover over image, click on tags and
you're done!
![Tagging Profiles Showcase](.github/assets/profiles-showcase.png)
### Custom Tag Groups
Customize the list of tags with your own custom tag groups. Apply custom colors to different groups or even separate
them from each other with group titles.
![Tag Groups Showcase](.github/assets/groups-showcase.png)
### Fullscreen Viewer
Open up the specific image or video in fullscreen mode by clicking 🔍 icon in the bottom left corner of the image. This
feature is opt-in and should be enabled in the settings first.
![Fullscreen Viewer Icon](.github/assets/fullscreen-viewer-icon.png)
![Fullscreen Viewer Showcase](.github/assets/fullscreen-viewer-showcase.png)
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
@@ -19,11 +67,21 @@ npm install --save-dev
```
Second, you need to run the `build` command. It will first build the popup using SvelteKit and then build all the
content scripts/stylesheets and copy the manifest afterward. Simply run:
content scripts/stylesheets and copy the manifest afterward.
Extension can currently be built for multiple different imageboards using one of the following commands:
```shell
# Furbooru:
npm run build
# Derpibooru:
npm run build:derpibooru
# Tantabus:
npm run build:tantabus
```
When building is complete, resulting files can be found in the `/build` directory. These files can be either used
directly in Chrome (via loading the extension as unpacked extension) or manually compressed into `*.zip` file.
When build is complete, extension files can be found in the `/build` directory. These files can be either used
directly in Chrome (via loading the extension as unpacked extension) or manually compressed into `*.zip` file and loaded
into Firefox.
+63 -15
View File
@@ -1,11 +1,17 @@
{
"name": "Furbooru Tagging Assistant",
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
"version": "0.4.5",
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
"version": "0.8.0",
"browser_specific_settings": {
"gecko": {
"id": "furbooru-tagging-assistant@thecore.city"
}
"id": "furbooru-tagging-assistant@thecore.city",
"data_collection_permissions": {
"required": [
"none"
]
}
},
"gecko_android": {}
},
"icons": {
"16": "icon16.png",
@@ -18,6 +24,15 @@
"*://*.furbooru.org/"
],
"content_scripts": [
{
"matches": [
"*://*.furbooru.org/*"
],
"js": [
"src/content/deps/amd.ts",
"src/content/user-details.ts"
]
},
{
"matches": [
"*://*.furbooru.org/",
@@ -33,23 +48,20 @@
"src/styles/content/listing.scss"
]
},
{
"matches": [
"*://*.furbooru.org/*"
],
"js": [
"src/content/header.ts"
],
"css": [
"src/styles/content/header.scss"
]
},
{
"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/tag-presets.scss"
]
},
{
@@ -69,6 +81,42 @@
"js": [
"src/content/tags.ts"
]
},
{
"matches": [
"*://*.furbooru.org/posts",
"*://*.furbooru.org/posts?*",
"*://*.furbooru.org/forums/*/topics/*"
],
"js": [
"src/content/posts.ts"
],
"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": {
+1337 -1408
View File
File diff suppressed because it is too large. Load diff
+28 -20
View File
@@ -1,9 +1,13 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.4.5",
"version": "0.8.0",
"private": true,
"type": "module",
"scripts": {
"build": "npm run build:popup && npm run build:extension",
"build:derpibooru": "cross-env SITE=derpibooru npm run build",
"build:tantabus": "cross-env SITE=tantabus npm run build",
"build:local": "cross-env SITE=localhost npm run build",
"build:popup": "vite build",
"build:extension": "node build-extension.js",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@@ -11,25 +15,29 @@
"test": "vitest run --coverage",
"test:watch": "vitest watch --coverage"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.21.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.326",
"@types/node": "^22.15.29",
"@vitest/coverage-v8": "^3.2.0",
"cheerio": "^1.0.0",
"jsdom": "^26.1.0",
"sass": "^1.89.1",
"svelte": "^5.33.14",
"svelte-check": "^4.2.1",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.2.0"
},
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"lz-string": "^1.5.0"
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.70.3",
"@fortawesome/fontawesome-free": "^7.3.1",
"amd-lite": "^1.0.1",
"lz-string": "^1.5.0",
"sass": "^1.105.1",
"svelte": "^5.57.2"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@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": "^29.1.1",
"svelte-check": "^4.7.6",
"typescript": "^6.0.3",
"vite": "^7.3.7",
"vitest": "^4.1.11"
},
"engines": {
"node": ">=25"
}
}
+14 -2
View File
@@ -1,9 +1,20 @@
// 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 {
/**
* Identifier of the current site this extension is built for.
*/
const __CURRENT_SITE__: string;
/**
* Name of the site.
*/
const __CURRENT_SITE_NAME__: string;
// Helper type to not deal with differences between the setTimeout of @types/node and usual web browser's type.
type Timeout = ReturnType<typeof setTimeout>;
@@ -27,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}
+11 -32
View File
@@ -1,41 +1,20 @@
<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();
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));
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>
+5 -1
View File
@@ -1,5 +1,9 @@
<script lang="ts">
import { PLUGIN_NAME } from "$lib/constants";
</script>
<header>
<a href="/">Furbooru Tagging Assistant</a>
<a href="/">{PLUGIN_NAME}</a>
</header>
<style lang="scss">
@@ -19,50 +19,60 @@
.tag-color-container:is(:global(.tag-color-container--rating)) :global(.tag) {
background-color: colors.$tag-rating-background;
color: colors.$tag-rating-text;
border-color: colors.$tag-rating-border;
}
.tag-color-container:is(:global(.tag-color-container--spoiler)) :global(.tag) {
background-color: colors.$tag-spoiler-background;
color: colors.$tag-spoiler-text;
border-color: colors.$tag-spoiler-border;
}
.tag-color-container:is(:global(.tag-color-container--origin)) :global(.tag) {
background-color: colors.$tag-origin-background;
color: colors.$tag-origin-text;
border-color: colors.$tag-origin-border;
}
.tag-color-container:is(:global(.tag-color-container--oc)) :global(.tag) {
background-color: colors.$tag-oc-background;
color: colors.$tag-oc-text;
border-color: colors.$tag-oc-border;
}
.tag-color-container:is(:global(.tag-color-container--error)) :global(.tag) {
background-color: colors.$tag-error-background;
color: colors.$tag-error-text;
border-color: colors.$tag-error-border;
}
.tag-color-container:is(:global(.tag-color-container--character)) :global(.tag) {
background-color: colors.$tag-character-background;
color: colors.$tag-character-text;
border-color: colors.$tag-character-border;
}
.tag-color-container:is(:global(.tag-color-container--content-official)) :global(.tag) {
background-color: colors.$tag-content-official-background;
color: colors.$tag-content-official-text;
border-color: colors.$tag-content-official-border;
}
.tag-color-container:is(:global(.tag-color-container--content-fanmade)) :global(.tag) {
background-color: colors.$tag-content-fanmade-background;
color: colors.$tag-content-fanmade-text;
border-color: colors.$tag-content-fanmade-border;
}
.tag-color-container:is(:global(.tag-color-container--species)) :global(.tag) {
background-color: colors.$tag-species-background;
color: colors.$tag-species-text;
border-color: colors.$tag-species-border;
}
.tag-color-container:is(:global(.tag-color-container--body-type)) :global(.tag) {
background-color: colors.$tag-body-type-background;
color: colors.$tag-body-type-text;
border-color: colors.$tag-body-type-border;
}
</style>
+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>
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
interface MessageProps {
children?: import('svelte').Snippet;
level: 'warning' | 'error';
}
let { children, level }: MessageProps = $props();
</script>
<p class="{level}">
{@render children?.()}
</p>
<style lang="scss">
@use '$styles/colors';
p {
padding: 5px 24px;
margin: {
left: -24px;
right: -24px;
}
}
.warning {
background: colors.$warning-background;
}
.error {
background: colors.$error-background;
}
</style>
@@ -1,6 +1,6 @@
<script lang="ts">
import SelectField from "$components/ui/forms/SelectField.svelte";
import { categories } from "$lib/booru/tag-categories";
import { categories } from "$config/tags";
interface TagCategorySelectFieldProps {
value?: string;
+56 -2
View File
@@ -9,10 +9,19 @@
value?: string;
href?: string;
children?: Snippet;
/**
* Click event received by the checkbox input element.
*/
onclick?: MouseEventHandler<HTMLInputElement>;
oninput?: FormEventHandler<HTMLInputElement>;
/**
* Click event received by the menu item instead of the checkbox element.
*/
onitemclick?: MouseEventHandler<HTMLElement>;
}
let checkboxElement: HTMLInputElement;
let {
checked = $bindable(),
name = undefined,
@@ -21,16 +30,61 @@
children,
onclick,
oninput,
onitemclick,
}: MenuCheckboxItemProps = $props();
/**
* Prevent clicks from getting sent to the menu link if user clicked directly on the checkbox.
* @param originalEvent
*/
function stopPropagationAndPassCallback(originalEvent: MouseEvent) {
originalEvent.stopPropagation();
onclick?.(originalEvent as MouseEvent & { currentTarget: HTMLInputElement });
}
/**
* Check and try to toggle checkbox if href was not provided for the menu item.
*/
function maybeToggleCheckboxOnOuterLinkClicked(event: MouseEvent) {
// Call the event handler if present.
if (onitemclick) {
onitemclick(event as MouseEvent & {currentTarget: HTMLElement});
// If it was prevented, then don't attempt to run checkbox toggling workaround.
if (event.defaultPrevented) {
return;
}
}
// When menu link does not contain any link, we should just treat clicks on it as toggle action on checkbox.
if (!href) {
checked = !checked;
// Since we've toggled it using the `checked` property and input does not trigger `onclick` when we do something
// programmatically, we should create valid event and send it back to the parent component so it will handle
// whatever it wants.
if (oninput) {
// Uhh, not sure if this is how it should be done, but we need `currentTarget` to point on the checkbox. Without
// dispatching the event, we can't fill it normally. Also, input element does not return us untrusted input
// events automatically. Probably should make the util function later in case I'd need something similar.
checkboxElement.addEventListener('input', (inputEvent: Event) => {
oninput(inputEvent as Event & { currentTarget: HTMLInputElement });
}, { once: true })
checkboxElement.dispatchEvent(new InputEvent('input'));
}
}
}
</script>
<MenuLink {href}>
<input bind:checked={checked} {name} onclick={stopPropagationAndPassCallback} {oninput} type="checkbox" {value}>
<MenuLink {href} onclick={maybeToggleCheckboxOnOuterLinkClicked}>
<input bind:this={checkboxElement}
bind:checked={checked}
{name}
onclick={stopPropagationAndPassCallback}
{oninput}
type="checkbox"
{value}>
{@render children?.()}
</MenuLink>
+83 -1
View File
@@ -1,4 +1,57 @@
export const tagsBlacklist: string[] = [
/**
* 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[] = Object.values(CategoriesEnum);
/**
* Mapping of namespaces to their respective categories. These namespaces are automatically assigned to them, so we can
* automatically assume categories of tags which start with them. Mapping is extracted from Philomena directly.
*
* This mapping may differ between boorus.
*
* @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', 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]
] : [])
]);
/**
* List of tags which marked by the site as blacklisted. These tags are blocked from being added by the tag editor and
* should usually just be removed automatically.
*/
export const tagsBlacklist: string[] = (__CURRENT_SITE__ === 'furbooru' ? [
"anthro art",
"anthro artist",
"anthro cute",
@@ -63,4 +116,33 @@ export const tagsBlacklist: string[] = [
"tagme",
"upvotes galore",
"wall of faves"
] : [
"tagme",
"tag me",
"not tagged",
"no tag",
"notag",
"notags",
"upvotes galore",
"downvotes galore",
"wall of faves",
"drama in the comments",
"drama in comments",
"tag needed",
"paywall",
"cringeworthy",
"solo oc",
"tag your shit"
]);
/**
* 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,4 +1,4 @@
import { bindComponent } from "$lib/components/base/component-utils";
import { bindComponent } from "$content/components/base/component-utils";
type ComponentEventListener<EventName extends keyof HTMLElementEventMap> =
(this: HTMLElement, event: HTMLElementEventMap[EventName]) => void;
@@ -1,4 +1,4 @@
import type { BaseComponent } from "$lib/components/base/BaseComponent";
import type { BaseComponent } from "$content/components/base/BaseComponent";
const instanceSymbol = Symbol.for('instance');
@@ -0,0 +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;
}
@@ -1,16 +1,20 @@
import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenance-popup-events";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events";
import type { BooruEventsMap } from "$lib/components/events/booru-events";
import type { TagsFormEventsMap } from "$lib/components/events/tags-form-events";
import type { TagDropdownEvents } from "$lib/components/events/tag-dropdown-events";
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';
@@ -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 "$lib/components/base/BaseComponent";
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
import { emit, on } from "$lib/components/events/comms";
import { EVENT_SIZE_LOADED } from "$lib/components/events/fullscreen-viewer-events";
import { BaseComponent } from "$content/components/base/BaseComponent";
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 "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import MiscSettings from "$lib/extension/settings/MiscSettings";
import { FullscreenViewer } from "$lib/components/FullscreenViewer";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
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 "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { on } from "$lib/components/events/comms";
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { on } from "$content/components/events/comms";
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,16 +1,14 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
import { getComponent } from "$lib/components/base/component-utils";
import { BaseComponent } from "$content/components/base/BaseComponent";
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 "$lib/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
import { on } from "$content/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
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)
);
@@ -148,7 +146,12 @@ export class TagDropdownWrapper extends BaseComponent {
profileSpecificButtonText = `Remove from profile "${profileName}"`;
}
this.#toggleOnExistingButton.innerText = profileSpecificButtonText;
if (this.#toggleOnExistingButton.lastChild instanceof Text) {
this.#toggleOnExistingButton.lastChild.textContent = ` ${profileSpecificButtonText}`;
} else {
// Just in case last child is missing, then update the text on the full element.
this.#toggleOnExistingButton.textContent = profileSpecificButtonText;
}
if (!this.#toggleOnExistingButton.isConnected) {
this.#dropdownContainer?.append(this.#toggleOnExistingButton);
@@ -167,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() {
@@ -200,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);
@@ -226,8 +229,8 @@ export class TagDropdownWrapper extends BaseComponent {
);
});
this.#maintenanceSettings
.resolveActiveProfileAsObject()
this.#preferences
.activeProfile.asObject()
.then(activeProfile => {
lastActiveProfile = activeProfile?.id ?? null;
onActiveProfileChange(activeProfile);
@@ -243,9 +246,14 @@ export class TagDropdownWrapper extends BaseComponent {
static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement {
const dropdownLink = document.createElement('a');
dropdownLink.href = '#';
dropdownLink.innerText = text;
dropdownLink.className = 'tag__dropdown__link';
const dropdownLinkIcon = document.createElement('i');
dropdownLinkIcon.classList.add('fa', 'fa-tags');
dropdownLink.textContent = ` ${text}`;
dropdownLink.insertAdjacentElement('afterbegin', dropdownLinkIcon);
dropdownLink.addEventListener('click', event => {
event.preventDefault();
onClickHandler(event);
@@ -253,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 "$lib/components/base/BaseComponent";
import { BaseComponent } from "$content/components/base/BaseComponent";
import type TagGroup from "$entities/TagGroup";
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
import { on } from "$lib/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
import { getComponent } from "$lib/components/base/component-utils";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
import TagSettings from "$lib/extension/settings/TagSettings";
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 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) {
@@ -134,6 +134,7 @@ export class TagsListBlock extends BaseComponent {
heading.style.display = 'none';
heading.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(group.id)}, 0)`;
heading.style.flexBasis = '100%';
heading.classList.add('tag-category-headline');
// We're inserting heading to the top just to make sure that heading is always in front of the tags related to
// this category.
@@ -145,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;
@@ -216,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();
}
}
}
@@ -0,0 +1,23 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { ImageListInfo } from "$content/components/philomena/listing/ImageListInfo";
export class ImageListContainer extends BaseComponent {
#info: ImageListInfo | null = null;
protected build() {
const imageListInfoContainer = this.container.querySelector<HTMLElement>('.js-imagelist-info');
if (imageListInfoContainer) {
this.#info = new ImageListInfo(imageListInfoContainer);
this.#info.initialize();
}
}
static findAndInitialize() {
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
if (imageListContainer) {
new ImageListContainer(imageListContainer).initialize();
}
}
}
@@ -1,4 +1,4 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { BaseComponent } from "$content/components/base/BaseComponent";
export class ImageListInfo extends BaseComponent {
#tagElement: HTMLElement | null = null;
+53
View File
@@ -0,0 +1,53 @@
import { amdLite } from "amd-lite";
const originalDefine = amdLite.define;
/**
* Set of already defined modules. Used for deduplication.
*/
const definedModules = new Set<string>();
/**
* Throttle timer to make sure only one attempt at loading modules will run for a batch of loaded scripts.
*/
let throttledAutoRunTimer: NodeJS.Timeout | number | undefined;
/**
* Schedule the automatic resolving of all waiting modules on the next available frame.
*/
function scheduleModulesAutoRun() {
clearTimeout(throttledAutoRunTimer);
throttledAutoRunTimer = setTimeout(() => {
amdLite.resolveDependencies(Object.keys(amdLite.waitingModules));
});
}
amdLite.define = (name, dependencies, originalCallback) => {
// Chrome doesn't run the same content script multiple times, while Firefox does. Since each content script and their
// chunks are intended to be run only once, we should just ignore any attempts of running the same module more than
// once. Names of the modules are assumed to be unique.
if (definedModules.has(name)) {
return;
}
definedModules.add(name);
originalDefine(name, dependencies, function () {
const callbackResult = originalCallback(...arguments);
// Workaround for the entry script not returning anything causing AMD-Lite to send warning about modules not
// being loaded/not existing.
return typeof callbackResult !== 'undefined' ? callbackResult : {};
});
// Schedule the auto run on the next available frame. Firefox and Chromium have a lot of differences in how they
// decide to execute content scripts. For example, Firefox might decide to skip a frame before attempting to load
// different groups of them. Chromium on the other hand doesn't have that issue, but it doesn't allow us to, for
// example, schedule a microtask to run the modules.
scheduleModulesAutoRun();
}
amdLite.init({
publicScope: window
});
+3
View File
@@ -0,0 +1,3 @@
import GridDupeReportsList from "$content/components/philomena/dupe/GridDupeReportsList";
GridDupeReportsList.findAndInitialize();
-7
View File
@@ -1,7 +0,0 @@
import { initializeSiteHeader } from "$lib/components/SiteHeaderWrapper";
const siteHeader = document.querySelector<HTMLElement>('.header');
if (siteHeader) {
initializeSiteHeader(siteHeader);
}
+14 -20
View File
@@ -1,21 +1,18 @@
import { createMaintenancePopup } from "$lib/components/MaintenancePopup";
import { createMediaBoxTools } from "$lib/components/MediaBoxTools";
import { calculateMediaBoxesPositions, initializeMediaBox } from "$lib/components/MediaBoxWrapper";
import { createMaintenanceStatusIcon } from "$lib/components/MaintenanceStatusIcon";
import { createImageShowFullscreenButton } from "$lib/components/ImageShowFullscreenButton";
import { initializeImageListContainer } from "$lib/components/listing/ImageListContainer";
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();
+3
View File
@@ -0,0 +1,3 @@
import { BlockCommunication } from "$content/components/philomena/BlockCommunication";
BlockCommunication.findAndInitializeAll();
+4 -4
View File
@@ -1,6 +1,6 @@
import { TagsForm } from "$lib/components/TagsForm";
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$lib/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 "$lib/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
@@ -1,12 +0,0 @@
export const categories = [
'rating',
'spoiler',
'origin',
'oc',
'error',
'character',
'content-official',
'content-fanmade',
'species',
'body-type',
];
-33
View File
@@ -1,33 +0,0 @@
/**
* Build the map containing both real tags and their aliases.
*
* @param realAndAliasedTags List combining aliases and tag names.
* @param realTags List of actual tag names, excluding aliases.
*
* @return Map where key is a tag or alias and value is an actual tag name.
*/
export function buildTagsAndAliasesMap(realAndAliasedTags: string[], realTags: string[]): Map<string, string> {
const tagsAndAliasesMap: Map<string, string> = new Map();
for (const tagName of realTags) {
tagsAndAliasesMap.set(tagName, tagName);
}
let realTagName: string | null = null;
for (const tagNameOrAlias of realAndAliasedTags) {
if (tagsAndAliasesMap.has(tagNameOrAlias)) {
realTagName = tagNameOrAlias;
continue;
}
if (!realTagName) {
console.warn('No real tag found for the alias:', tagNameOrAlias);
continue;
}
tagsAndAliasesMap.set(tagNameOrAlias, realTagName);
}
return tagsAndAliasesMap;
}
-419
View File
@@ -1,419 +0,0 @@
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI";
import { tagsBlacklist } from "$config/tags";
import { emitterAt } from "$lib/components/events/comms";
import {
EVENT_ACTIVE_PROFILE_CHANGED,
EVENT_MAINTENANCE_STATE_CHANGED,
EVENT_TAGS_UPDATED
} from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
class BlackListedTagsEncounteredError extends Error {
constructor(tagName: string) {
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
cause: tagName
});
}
}
export class MaintenancePopup extends BaseComponent {
#tagsListElement: HTMLElement = document.createElement('div');
#tagsList: HTMLElement[] = [];
#suggestedInvalidTags: Map<string, HTMLElement> = new Map();
#activeProfile: MaintenanceProfile | null = null;
#mediaBoxTools: MediaBoxTools | null = null;
#tagsToRemove: Set<string> = new Set();
#tagsToAdd: Set<string> = new Set();
#isPlanningToSubmit: boolean = false;
#isSubmitting: boolean = false;
#tagsSubmissionTimer: Timeout | null = null;
#emitter = emitterAt(this);
/**
* @protected
*/
build() {
this.container.innerHTML = '';
this.container.classList.add('maintenance-popup');
this.#tagsListElement.classList.add('tags-list');
this.container.append(
this.#tagsListElement,
);
}
/**
* @protected
*/
init() {
const mediaBoxToolsElement = this.container.closest<HTMLElement>('.media-box-tools');
if (!mediaBoxToolsElement) {
throw new Error('Maintenance popup initialized outside of the media box tools!');
}
const mediaBoxTools = getComponent<MediaBoxTools>(mediaBoxToolsElement);
if (!mediaBoxTools) {
throw new Error('Media box tools component not found!');
}
this.#mediaBoxTools = mediaBoxTools;
MaintenancePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
this.#tagsListElement.addEventListener('click', this.#handleTagClick.bind(this));
const mediaBox = this.#mediaBoxTools.mediaBox;
if (!mediaBox) {
throw new Error('Media box component not found!');
}
mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this));
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
}
#onActiveProfileChanged(activeProfile: MaintenanceProfile | null) {
this.#activeProfile = activeProfile;
this.container.classList.toggle('is-active', activeProfile !== null);
this.#refreshTagsList();
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
}
#refreshTagsList() {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || [];
for (const tagElement of this.#tagsList) {
tagElement.remove();
}
for (const tagElement of this.#suggestedInvalidTags.values()) {
tagElement.remove();
}
this.#tagsList = new Array(activeProfileTagsList.length);
this.#suggestedInvalidTags.clear();
const currentPostTags = this.#mediaBoxTools.mediaBox.tagsAndAliases;
activeProfileTagsList
.sort((a, b) => a.localeCompare(b))
.forEach((tagName, index) => {
const tagElement = MaintenancePopup.#buildTagElement(tagName);
this.#tagsList[index] = tagElement;
this.#tagsListElement.appendChild(tagElement);
const isPresent = currentPostTags?.has(tagName);
tagElement.classList.toggle('is-present', isPresent);
tagElement.classList.toggle('is-missing', !isPresent);
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags?.get(tagName) !== tagName);
// Just to prevent duplication, we need to include this tag to the map of suggested invalid tags
if (tagsBlacklist.includes(tagName)) {
MaintenancePopup.#markTagAsInvalid(tagElement);
this.#suggestedInvalidTags.set(tagName, tagElement);
}
});
}
/**
* Detect and process clicks made directly to the tags.
*/
#handleTagClick(event: MouseEvent) {
const targetObject = event.target;
if (!targetObject || !(targetObject instanceof HTMLElement)) {
return;
}
let tagElement: HTMLElement | null = targetObject;
if (!tagElement.classList.contains('tag')) {
tagElement = tagElement.closest<HTMLElement>('.tag');
}
if (!tagElement?.dataset.name) {
return;
}
const tagName = tagElement.dataset.name;
if (tagElement.classList.contains('is-present')) {
const isToBeRemoved = tagElement.classList.toggle('is-removed');
if (isToBeRemoved) {
this.#tagsToRemove.add(tagName);
} else {
this.#tagsToRemove.delete(tagName);
}
}
if (tagElement.classList.contains('is-missing')) {
const isToBeAdded = tagElement.classList.toggle('is-added');
if (isToBeAdded) {
this.#tagsToAdd.add(tagName);
} else {
this.#tagsToAdd.delete(tagName);
}
}
if (this.#tagsToAdd.size || this.#tagsToRemove.size) {
// Notify only once, when first planning to submit
if (!this.#isPlanningToSubmit) {
MaintenancePopup.#notifyAboutPendingSubmission(true);
}
this.#isPlanningToSubmit = true;
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting');
}
}
#onMouseEnteredArea() {
if (this.#tagsSubmissionTimer) {
clearTimeout(this.#tagsSubmissionTimer);
}
}
#onMouseLeftArea() {
if (this.#isPlanningToSubmit && !this.#isSubmitting) {
this.#tagsSubmissionTimer = setTimeout(
this.#onSubmissionTimerPassed.bind(this),
MaintenancePopup.#delayBeforeSubmissionMs
);
}
}
async #onSubmissionTimerPassed() {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) {
return;
}
this.#isPlanningToSubmit = false;
this.#isSubmitting = true;
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing');
let maybeTagsAndAliasesAfterUpdate;
const shouldAutoRemove = await MaintenancePopup.#maintenanceSettings.resolveStripBlacklistedTags();
try {
maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
this.#mediaBoxTools.mediaBox.imageId,
tagsList => {
for (let tagName of this.#tagsToRemove) {
tagsList.delete(tagName);
}
for (let tagName of this.#tagsToAdd) {
tagsList.add(tagName);
}
if (shouldAutoRemove) {
for (let tagName of tagsBlacklist) {
tagsList.delete(tagName);
}
} else {
for (let tagName of tagsList) {
if (tagsBlacklist.includes(tagName)) {
throw new BlackListedTagsEncounteredError(tagName);
}
}
}
return tagsList;
}
);
} catch (e) {
if (e instanceof BlackListedTagsEncounteredError) {
this.#revealInvalidTags();
} else {
console.warn('Tags submission failed:', e);
}
MaintenancePopup.#notifyAboutPendingSubmission(false);
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
this.#isSubmitting = false;
return;
}
if (maybeTagsAndAliasesAfterUpdate) {
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
}
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete');
this.#tagsToAdd.clear();
this.#tagsToRemove.clear();
this.#refreshTagsList();
MaintenancePopup.#notifyAboutPendingSubmission(false);
this.#isSubmitting = false;
}
#revealInvalidTags() {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases;
if (!tagsAndAliases) {
return;
}
const firstTagInList = this.#tagsList[0];
for (let tagName of tagsBlacklist) {
if (tagsAndAliases.has(tagName)) {
if (this.#suggestedInvalidTags.has(tagName)) {
continue;
}
const tagElement = MaintenancePopup.#buildTagElement(tagName);
MaintenancePopup.#markTagAsInvalid(tagElement);
tagElement.classList.add('is-present');
this.#suggestedInvalidTags.set(tagName, tagElement);
if (firstTagInList && firstTagInList.isConnected) {
this.#tagsListElement.insertBefore(tagElement, firstTagInList);
} else {
this.#tagsListElement.appendChild(tagElement);
}
}
}
}
get isActive() {
return this.container.classList.contains('is-active');
}
static #buildTagElement(tagName: string): HTMLElement {
const tagElement = document.createElement('span');
tagElement.classList.add('tag');
tagElement.innerText = tagName;
tagElement.dataset.name = tagName;
return tagElement;
}
/**
* Marks the tag with red color.
* @param tagElement Element to mark.
*/
static #markTagAsInvalid(tagElement: HTMLElement) {
tagElement.dataset.tagCategory = 'error';
tagElement.setAttribute('data-tag-category', 'error');
}
/**
* Controller with maintenance settings.
*/
static #maintenanceSettings = new MaintenanceSettings();
/**
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
* at the very start to retrieve the currently active profile.
* @param callback Callback to execute whenever selection of active profile or profile itself has been changed.
* @return Unsubscribe function. Call it to stop watching for changes.
*/
static #watchActiveProfile(callback: (profile: MaintenanceProfile | null) => void): () => void {
let lastActiveProfileId: string | null | undefined = null;
const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => {
if (lastActiveProfileId) {
callback(
profiles.find(profile => profile.id === lastActiveProfileId) || null
);
}
});
const unsubscribeFromMaintenanceSettings = this.#maintenanceSettings.subscribe(settings => {
if (settings.activeProfile === lastActiveProfileId) {
return;
}
lastActiveProfileId = settings.activeProfile;
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(callback);
});
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(profileOrNull => {
if (profileOrNull) {
lastActiveProfileId = profileOrNull.id;
}
callback(profileOrNull);
});
return () => {
unsubscribeFromProfilesChanges();
unsubscribeFromMaintenanceSettings();
}
}
/**
* Notify the frontend about new pending submission started.
* @param isStarted True if started, false if ended.
*/
static #notifyAboutPendingSubmission(isStarted: boolean) {
if (this.#pendingSubmissionCount === null) {
this.#pendingSubmissionCount = 0;
this.#initializeExitPromptHandler();
}
this.#pendingSubmissionCount += isStarted ? 1 : -1;
}
/**
* Subscribe to the global window closing event, show the prompt when there are pending submission.
*/
static #initializeExitPromptHandler() {
window.addEventListener('beforeunload', event => {
if (!this.#pendingSubmissionCount) {
return;
}
event.preventDefault();
event.returnValue = true;
});
}
static #scrapedAPI = new ScrapedAPI();
static #delayBeforeSubmissionMs = 500;
/**
* Amount of pending submissions or NULL if logic was not yet initialized.
*/
static #pendingSubmissionCount: number|null = null;
}
export function createMaintenancePopup() {
const container = document.createElement('div');
new MaintenancePopup(container);
return container;
}
-74
View File
@@ -1,74 +0,0 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { MaintenancePopup } from "$lib/components/MaintenancePopup";
import { on } from "$lib/components/events/comms";
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxWrapper } from "$lib/components/MediaBoxWrapper";
import type MaintenanceProfile from "$entities/MaintenanceProfile";
export class MediaBoxTools extends BaseComponent {
#mediaBox: MediaBoxWrapper | null = null;
#maintenancePopup: MaintenancePopup | null = null;
init() {
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
if (!mediaBoxElement) {
throw new Error('Toolbox element initialized outside of the media box!');
}
this.#mediaBox = getComponent(mediaBoxElement);
for (let childElement of this.container.children) {
if (!(childElement instanceof HTMLElement)) {
continue;
}
const component = getComponent(childElement);
if (!component) {
continue;
}
if (!component.isInitialized) {
component.initialize();
}
if (!this.#maintenancePopup && component instanceof MaintenancePopup) {
this.#maintenancePopup = component;
}
}
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
}
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
}
get maintenancePopup(): MaintenancePopup | null {
return this.#maintenancePopup;
}
get mediaBox(): MediaBoxWrapper | null {
return this.#mediaBox;
}
}
/**
* Create a maintenance popup element.
* @param childrenElements List of children elements to append to the component.
* @return The maintenance popup element.
*/
export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement {
const mediaBoxToolsContainer = document.createElement('div');
mediaBoxToolsContainer.classList.add('media-box-tools');
if (childrenElements.length) {
mediaBoxToolsContainer.append(...childrenElements);
}
new MediaBoxTools(mediaBoxToolsContainer);
return mediaBoxToolsContainer;
}
-99
View File
@@ -1,99 +0,0 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
import { on } from "$lib/components/events/comms";
import { EVENT_TAGS_UPDATED } from "$lib/components/events/maintenance-popup-events";
export class MediaBoxWrapper extends BaseComponent {
#thumbnailContainer: HTMLElement | null = null;
#imageLinkElement: HTMLAnchorElement | null = null;
#tagsAndAliases: Map<string, string> | null = null;
init() {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
const updatedMap = tagsUpdatedEvent.detail;
if (!(updatedMap instanceof Map)) {
throw new TypeError("Tags and aliases should be stored as Map!");
}
this.#tagsAndAliases = updatedMap;
}
#calculateMediaBoxTags() {
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
return buildTagsAndAliasesMap(tagAliases, actualTags);
}
get tagsAndAliases(): Map<string, string> | null {
if (!this.#tagsAndAliases) {
this.#tagsAndAliases = this.#calculateMediaBoxTags();
}
return this.#tagsAndAliases;
}
get imageId(): number {
const imageId = this.container.dataset.imageId;
if (!imageId) {
throw new Error('Missing image ID');
}
return parseInt(imageId);
}
get imageLinks(): App.ImageURIs {
const jsonUris = this.#thumbnailContainer?.dataset.uris;
if (!jsonUris) {
throw new Error('Missing URIs!');
}
return JSON.parse(jsonUris);
}
}
/**
* Wrap the media box element into the special wrapper.
*/
export function initializeMediaBox(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
new MediaBoxWrapper(mediaBoxContainer)
.initialize();
for (let childComponentElement of childComponentElements) {
mediaBoxContainer.appendChild(childComponentElement);
getComponent(childComponentElement)?.initialize();
}
}
export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf<HTMLElement>) {
window.addEventListener('resize', () => {
let lastMediaBox: HTMLElement | null = null;
let lastMediaBoxPosition: number | null = null;
for (const mediaBoxElement of mediaBoxesList) {
const yPosition = mediaBoxElement.getBoundingClientRect().y;
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
lastMediaBox = mediaBoxElement;
lastMediaBoxPosition = yPosition;
}
// Last-ever media box is checked separately
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
lastMediaBox.classList.add('media-box--last');
}
})
}
-429
View File
@@ -1,429 +0,0 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { QueryLexer, QuotedTermToken, TermToken, Token } from "$lib/booru/search/QueryLexer";
import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings";
export class SearchWrapper extends BaseComponent {
#searchField: HTMLInputElement | null = null;
#lastParsedSearchValue: string | null = null;
#cachedParsedQuery: Token[] = [];
#searchSettings: SearchSettings = new SearchSettings();
#arePropertiesSuggestionsEnabled: boolean = false;
#propertiesSuggestionsPosition: SuggestionsPosition = "start";
#cachedAutocompleteContainer: HTMLElement | null = null;
#lastTermToken: TermToken | QuotedTermToken | null = null;
build() {
this.#searchField = this.container.querySelector('input[name=q]');
}
init() {
if (this.#searchField) {
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this))
}
this.#searchSettings.resolvePropertiesSuggestionsEnabled()
.then(isEnabled => this.#arePropertiesSuggestionsEnabled = isEnabled);
this.#searchSettings.resolvePropertiesSuggestionsPosition()
.then(position => this.#propertiesSuggestionsPosition = position);
this.#searchSettings.subscribe(settings => {
this.#arePropertiesSuggestionsEnabled = Boolean(settings.suggestProperties);
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition || "start";
});
}
/**
* Catch the user input and execute suggestions logic.
* @param event Source event to find the input element from.
*/
#onInputFindProperties(event: Event) {
// Ignore events until option is enabled.
if (!this.#arePropertiesSuggestionsEnabled || !(event.currentTarget instanceof HTMLInputElement)) {
return;
}
const currentFragment = this.#findCurrentTagFragment();
if (!currentFragment) {
return;
}
this.#renderSuggestions(
SearchWrapper.#resolveSuggestionsFromTerm(currentFragment),
event.currentTarget
);
}
/**
* Get the selection position in the search field.
*/
#getInputUserSelection(): number {
if (!this.#searchField) {
throw new Error('Missing search field!');
}
return Math.min(
this.#searchField.selectionStart ?? 0,
this.#searchField.selectionEnd ?? 0,
);
}
/**
* Parse the search query and return the list of parsed tokens. Result will be cached for current search query.
*/
#resolveQueryTokens(): Token[] {
if (!this.#searchField) {
throw new Error('Missing search field!');
}
const searchValue = this.#searchField.value;
if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) {
return this.#cachedParsedQuery;
}
this.#lastParsedSearchValue = searchValue;
this.#cachedParsedQuery = new QueryLexer(searchValue).parse();
return this.#cachedParsedQuery;
}
/**
* Find the currently selected term.
* @return Selected term or null if none found.
*/
#findCurrentTagFragment(): string | null {
if (!this.#searchField) {
return null;
}
let searchValue = this.#searchField.value;
if (!searchValue) {
this.#lastTermToken = null;
return null;
}
const token = SearchWrapper.#findActiveSearchTermPosition(
this.#resolveQueryTokens(),
this.#getInputUserSelection(),
);
if (token instanceof TermToken) {
this.#lastTermToken = token;
return token.value;
}
if (token instanceof QuotedTermToken) {
this.#lastTermToken = token;
return token.decodedValue;
}
this.#lastTermToken = null;
return searchValue;
}
/**
* Resolve the autocomplete container from the document. Once resolved, it can be safely reused without breaking
* anything. Assuming refactored autocomplete handler is still implemented the way it is.
*
* This means, that properties will only be suggested once actual autocomplete logic was activated.
*
* @return Resolved element or nothing.
*/
#resolveAutocompleteContainer(): HTMLElement | null {
if (this.#cachedAutocompleteContainer) {
return this.#cachedAutocompleteContainer;
}
this.#cachedAutocompleteContainer = document.querySelector('.autocomplete');
return this.#cachedAutocompleteContainer;
}
/**
* Render the list of suggestions into the existing popup or create and populate a new one.
* @param suggestions List of suggestion to render the popup from.
* @param targetInput Target input to attach the popup to.
*/
#renderSuggestions(suggestions: string[], targetInput: HTMLInputElement) {
const suggestedListItems = suggestions
.map(suggestedTerm => this.#renderTermSuggestion(suggestedTerm));
requestAnimationFrame(() => {
const autocompleteContainer = this.#resolveAutocompleteContainer();
if (!autocompleteContainer) {
return;
}
// Since the autocomplete popup was refactored to re-use the same element over and over again, we need to remove
// the options from the popup manually when autocomplete was removed from the DOM, since site is not doing that.
const termsToRemove = autocompleteContainer.isConnected
// Only removing properties when element is still connected to the DOM (popup is used by the website)
? autocompleteContainer.querySelectorAll('.autocomplete__item--property')
// Remove everything if popup was disconnected from the DOM.
: autocompleteContainer.querySelectorAll('.autocomplete__item')
for (let existingTerm of termsToRemove) {
existingTerm.remove();
}
const listContainer = autocompleteContainer.querySelector('ul');
if (!listContainer) {
return;
}
switch (this.#propertiesSuggestionsPosition) {
case "start":
listContainer.prepend(...suggestedListItems);
break;
case "end":
listContainer.append(...suggestedListItems);
break;
default:
console.warn("Invalid position for property suggestions!");
}
const parentScrollTop = targetInput.parentElement?.scrollTop ?? 0;
autocompleteContainer.style.position = 'absolute';
autocompleteContainer.style.left = `${targetInput.offsetLeft}px`;
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - parentScrollTop}px`;
document.body.append(autocompleteContainer);
})
}
/**
* Loosely estimate where current selected search term is located and return it if found.
* @param tokens Search value to find the actively selected term from.
* @param userSelectionIndex The index of the user selection.
* @return Search term object or NULL if nothing found.
*/
static #findActiveSearchTermPosition(tokens: Token[], userSelectionIndex: number): Token | null {
return tokens.find(
token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex
) ?? null;
}
/**
* Regular expression to search the properties' syntax.
*/
static #propertySearchTermHeadingRegExp = /^(?<name>[a-z\d_]+)(?<op_syntax>\.(?<op>[a-z]*))?(?<value_syntax>:(?<value>.*))?$/;
/**
* Create a list of suggested elements using the input received from the user.
* @param searchTermValue Original decoded term received from the user.
* @return {string[]} List of suggestions. Could be empty.
*/
static #resolveSuggestionsFromTerm(searchTermValue: string): string[] {
const suggestionsList: string[] = [];
this.#propertySearchTermHeadingRegExp.lastIndex = 0;
const parsedResult = this.#propertySearchTermHeadingRegExp.exec(searchTermValue);
if (!parsedResult) {
return suggestionsList;
}
const propertyName = parsedResult.groups?.name;
if (!propertyName) {
return suggestionsList;
}
const propertyType = this.#properties.get(propertyName);
const hasOperatorSyntax = Boolean(parsedResult.groups?.op_syntax);
const hasValueSyntax = Boolean(parsedResult.groups?.value_syntax);
// No suggestions for values for now, maybe could add suggestions for namespaces like my:*
if (hasValueSyntax && propertyType) {
if (this.#typeValues.has(propertyType)) {
const givenValue = parsedResult.groups?.value;
const candidateValues = this.#typeValues.get(propertyType) || [];
for (let candidateValue of candidateValues) {
if (givenValue && !candidateValue.startsWith(givenValue)) {
continue;
}
suggestionsList.push(`${propertyName}${parsedResult.groups?.op_syntax ?? ''}:${candidateValue}`);
}
}
return suggestionsList;
}
// If at least one dot placed, start suggesting operators
if (hasOperatorSyntax && propertyType) {
if (this.#typeOperators.has(propertyType)) {
const operatorName = parsedResult.groups?.op;
const candidateOperators = this.#typeOperators.get(propertyType) ?? [];
for (let candidateOperator of candidateOperators) {
if (operatorName && !candidateOperator.startsWith(operatorName)) {
continue;
}
suggestionsList.push(`${propertyName}.${candidateOperator}:`);
}
}
return suggestionsList;
}
// Otherwise, search for properties with names starting with the term
for (let [candidateProperty] of this.#properties) {
if (propertyName && !candidateProperty.startsWith(propertyName)) {
continue;
}
suggestionsList.push(candidateProperty);
}
return suggestionsList;
}
/**
* Render a single suggestion item and connect required events to interact with the user.
* @param suggestedTerm Term to use for suggestion item.
* @return Resulting element.
*/
#renderTermSuggestion(suggestedTerm: string): HTMLElement {
const suggestionItem = document.createElement('li');
suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property');
suggestionItem.dataset.value = suggestedTerm;
suggestionItem.innerText = suggestedTerm;
const propertyIcon = document.createElement('i');
propertyIcon.classList.add('fa', 'fa-info-circle');
suggestionItem.insertAdjacentElement('afterbegin', propertyIcon);
suggestionItem.addEventListener('mouseover', () => {
SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem);
suggestionItem.classList.add('autocomplete__item--selected');
});
suggestionItem.addEventListener('mouseout', () => {
SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem);
});
suggestionItem.addEventListener('click', () => {
this.#replaceLastActiveTokenWithSuggestion(suggestedTerm);
});
return suggestionItem;
}
/**
* Automatically replace the last active token stored in the variable with the new value.
* @param suggestedTerm Term to replace the value with.
*/
#replaceLastActiveTokenWithSuggestion(suggestedTerm: string) {
if (!this.#lastTermToken || !this.#searchField) {
return;
}
const searchQuery = this.#searchField.value;
const beforeToken = searchQuery.substring(0, this.#lastTermToken.index);
const afterToken = searchQuery.substring(this.#lastTermToken.index + this.#lastTermToken.value.length);
let replacementValue = suggestedTerm;
if (replacementValue.includes('"')) {
replacementValue = `"${QuotedTermToken.encode(replacementValue)}"`
}
this.#searchField.value = beforeToken + replacementValue + afterToken;
}
/**
* Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's
* front-end.
* @param suggestedElement Target element to search from. If element is disconnected from the DOM, search will be
* halted.
*/
static #findAndResetSelectedSuggestion(suggestedElement: HTMLElement) {
if (!suggestedElement.parentElement) {
return;
}
for (let selectedElement of suggestedElement.parentElement.querySelectorAll('.autocomplete__item--selected')) {
selectedElement.classList.remove('autocomplete__item--selected');
}
}
static #typeNumeric = Symbol();
static #typeDate = Symbol();
static #typeLiteral = Symbol();
static #typePersonal = Symbol();
static #typeBoolean = Symbol();
static #properties = new Map([
['animated', SearchWrapper.#typeBoolean],
['aspect_ratio', SearchWrapper.#typeNumeric],
['body_type_tag_count', SearchWrapper.#typeNumeric],
['character_tag_count', SearchWrapper.#typeNumeric],
['comment_count', SearchWrapper.#typeNumeric],
['content_fanmade_tag_count', SearchWrapper.#typeNumeric],
['content_official_tag_count', SearchWrapper.#typeNumeric],
['created_at', SearchWrapper.#typeDate],
['description', SearchWrapper.#typeLiteral],
['downvotes', SearchWrapper.#typeNumeric],
['duration', SearchWrapper.#typeNumeric],
['error_tag_count', SearchWrapper.#typeNumeric],
['faved_by', SearchWrapper.#typeLiteral],
['faved_by_id', SearchWrapper.#typeNumeric],
['faves', SearchWrapper.#typeNumeric],
['file_name', SearchWrapper.#typeLiteral],
['first_seen_at', SearchWrapper.#typeDate],
['height', SearchWrapper.#typeNumeric],
['id', SearchWrapper.#typeNumeric],
['oc_tag_count', SearchWrapper.#typeNumeric],
['orig_sha512_hash', SearchWrapper.#typeLiteral],
['original_format', SearchWrapper.#typeLiteral],
['pixels', SearchWrapper.#typeNumeric],
['rating_tag_count', SearchWrapper.#typeNumeric],
['score', SearchWrapper.#typeNumeric],
['sha512_hash', SearchWrapper.#typeLiteral],
['size', SearchWrapper.#typeNumeric],
['source_count', SearchWrapper.#typeNumeric],
['source_url', SearchWrapper.#typeLiteral],
['species_tag_count', SearchWrapper.#typeNumeric],
['spoiler_tag_count', SearchWrapper.#typeNumeric],
['tag_count', SearchWrapper.#typeNumeric],
['updated_at', SearchWrapper.#typeDate],
['uploader', SearchWrapper.#typeLiteral],
['uploader_id', SearchWrapper.#typeNumeric],
['upvotes', SearchWrapper.#typeNumeric],
['width', SearchWrapper.#typeNumeric],
['wilson_score', SearchWrapper.#typeNumeric],
['my', SearchWrapper.#typePersonal],
]);
static #comparisonOperators = ['gt', 'gte', 'lt', 'lte'];
static #typeOperators = new Map([
[SearchWrapper.#typeNumeric, SearchWrapper.#comparisonOperators],
[SearchWrapper.#typeDate, SearchWrapper.#comparisonOperators],
]);
static #typeValues = new Map([
[SearchWrapper.#typePersonal, [
'comments',
'faves',
'posts',
'uploads',
'upvotes',
'watched',
]],
[SearchWrapper.#typeBoolean, [
'true',
'false',
]]
]);
}
-22
View File
@@ -1,22 +0,0 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { SearchWrapper } from "$lib/components/SearchWrapper";
class SiteHeaderWrapper extends BaseComponent {
#searchWrapper: SearchWrapper | null = null;
build() {
const searchForm = this.container.querySelector<HTMLElement>('.header__search');
this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null;
}
init() {
if (this.#searchWrapper) {
this.#searchWrapper.initialize();
}
}
}
export function initializeSiteHeader(siteHeaderElement: HTMLElement) {
new SiteHeaderWrapper(siteHeaderElement)
.initialize();
}
-150
View File
@@ -1,150 +0,0 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { emit, on, type UnsubscribeFunction } from "$lib/components/events/comms";
import { EVENT_FETCH_COMPLETE } from "$lib/components/events/booru-events";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
export class TagsForm extends BaseComponent {
protected init() {
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
const unsubscribe = on(
this.container,
EVENT_FETCH_COMPLETE,
() => this.#waitAndDetectUpdatedForm(unsubscribe),
);
}
#waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void {
const elementContainingTagEditor = this.container
.closest('#image_tags_and_source')
?.parentElement;
if (!elementContainingTagEditor) {
return;
}
const observer = new MutationObserver(() => {
const tagsFormElement = elementContainingTagEditor.querySelector<HTMLElement>('#tags-form');
if (!tagsFormElement || getComponent(tagsFormElement)) {
return;
}
const tagFormComponent = new TagsForm(tagsFormElement);
tagFormComponent.initialize();
const fullTagEditor = tagFormComponent.parentTagEditorElement;
if (fullTagEditor) {
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
} else {
console.info('Tag form is not in the tag editor. Event is not sent.');
}
observer.disconnect();
unsubscribe();
});
observer.observe(elementContainingTagEditor, {
subtree: true,
childList: true,
});
// Make sure to forcibly disconnect everything after a while.
setTimeout(() => {
observer.disconnect();
unsubscribe();
}, 5000);
}
get parentTagEditorElement(): HTMLElement | null {
return this.container.closest<HTMLElement>('.js-tagsauce')
}
/**
* Collect all the tag categories available on the page and color the tags in the editor according to them.
*/
refreshTagColors() {
const tagCategories = this.#gatherTagCategories();
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
for (const tagElement of editableTags) {
// Tag name is stored in the "remove" link and not in the tag itself.
const removeLink = tagElement.querySelector('a');
if (!removeLink) {
continue;
}
const tagName = removeLink.dataset.tagName;
if (!tagName || !tagCategories.has(tagName)) {
continue;
}
const categoryName = tagCategories.get(tagName)!;
tagElement.dataset.tagCategory = categoryName;
tagElement.setAttribute('data-tag-category', categoryName);
}
}
/**
* Collect list of categories from the tags on the page.
* @return
*/
#gatherTagCategories(): Map<string, string> {
const tagCategories: Map<string, string> = new Map();
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
const tagName = tagElement.dataset.tagName;
const tagCategory = tagElement.dataset.tagCategory;
if (!tagName || !tagCategory) {
console.warn('Missing tag name or category!');
continue;
}
tagCategories.set(tagName, tagCategory);
}
return tagCategories;
}
static watchForEditors() {
document.body.addEventListener('click', event => {
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) {
return;
}
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
if (!tagEditorWrapper) {
return;
}
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
if (!refreshTrigger) {
return;
}
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
if (!tagFormElement) {
return;
}
let tagEditor = getComponent(tagFormElement);
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
tagEditor = new TagsForm(tagFormElement);
tagEditor.initialize();
}
(tagEditor as TagsForm).refreshTagColors();
});
}
}
@@ -1,5 +0,0 @@
export const EVENT_FETCH_COMPLETE = 'fetchcomplete';
export interface BooruEventsMap {
[EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation.
}
@@ -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;
}
@@ -1,19 +0,0 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { ImageListInfo } from "$lib/components/listing/ImageListInfo";
export class ImageListContainer extends BaseComponent {
#info: ImageListInfo | null = null;
protected build() {
const imageListInfoContainer = this.container.querySelector<HTMLElement>('.js-imagelist-info');
if (imageListInfoContainer) {
this.#info = new ImageListInfo(imageListInfoContainer);
this.#info.initialize();
}
}
}
export function initializeImageListContainer(element: HTMLElement) {
new ImageListContainer(element).initialize();
}
+4
View File
@@ -0,0 +1,4 @@
/**
* Automatically generated name of the plugin.
*/
export const PLUGIN_NAME = __CURRENT_SITE_NAME__ + ' Tagging Assistant';
+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;
}
@@ -0,0 +1,124 @@
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 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]>;
}
export default class BulkEntitiesTransporter {
#lastSameSiteStatus: SameSiteStatus = null;
get lastImportSameSiteStatus() {
return this.#lastSameSiteStatus;
}
parseAndImportFromJSON(jsonString: string): StorageEntity[] {
let parsedObject: any;
this.#lastSameSiteStatus = null;
try {
parsedObject = JSON.parse(jsonString);
} catch (e) {
throw new TypeError('Invalid JSON!', {cause: e});
}
if (!BulkEntitiesTransporter.isList(parsedObject)) {
throw new TypeError('Invalid or unsupported object!');
}
this.#lastSameSiteStatus = EntitiesTransporter.checkIsSameSiteImportedObject(parsedObject);
let hasDifferentStatuses = false;
const resultEntities = parsedObject.elements
.map(importableObject => {
if (!(importableObject.$type in BulkEntitiesTransporter.#transporters)) {
console.warn('Attempting to import unsupported entity: ' + importableObject.$type);
return null;
}
const transporter = BulkEntitiesTransporter.#transporters[importableObject.$type as keyof App.EntityNamesMap];
const resultEntity = transporter.importFromObject(importableObject);
if (transporter.lastImportSameSiteStatus !== this.#lastSameSiteStatus) {
hasDifferentStatuses = true;
}
return resultEntity;
})
.filter(maybeEntity => !!maybeEntity);
if (hasDifferentStatuses) {
this.#lastSameSiteStatus = 'unknown';
}
return resultEntities;
}
parseAndImportFromCompressedJSON(compressedJsonString: string): StorageEntity[] {
return this.parseAndImportFromJSON(
decompressFromEncodedURIComponent(compressedJsonString)
);
}
exportToJSON(entities: StorageEntity[]): string {
return JSON.stringify({
$type: 'list',
$site: __CURRENT_SITE__,
elements: entities
.map(entity => {
switch (true) {
case entity instanceof 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;
})
.filter(value => !!value)
} as ImportableElementsList<ImportableEntityObject<StorageEntity>>, null, 2);
}
exportToCompressedJSON(entities: StorageEntity[]): string {
return compressToEncodedURIComponent(
this.exportToJSON(entities)
);
}
static isList(targetObject: any): targetObject is ImportableElementsList<ImportableEntityObject<StorageEntity>> {
return targetObject.$type
&& targetObject.$type === 'list'
&& targetObject.elements
&& Array.isArray(targetObject.elements);
}
static #transporters: TransportersMapping = {
profiles: new EntitiesTransporter(TaggingProfile),
groups: new EntitiesTransporter(TagGroup),
presets: new EntitiesTransporter(TagEditorPreset),
}
/**
* Check if the imported object is created for the same site extension or not.
* @param importedObject Object to check.
* @private
*/
static #checkIsSameSiteImportedObject(importedObject: Record<string, any>): SameSiteStatus {
if (!('$site' in importedObject)) {
return "unknown";
}
return importedObject.$site === __CURRENT_SITE__
? "same"
: "different";
}
}
+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);
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more