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

91 Commits

Author SHA1 Message Date
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
58b620ef09 Renaming Philomena and scraping-related classes directory 2026-03-07 17:35:38 +04:00
9445b1e862 Restructuring and renaming content components and their initialization 2026-03-07 17:22:13 +04:00
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
dc29c6ca69 Renaming for tagging profiles and preferences classes 2026-02-28 22:49:57 +04:00
441091142c Added screenshot preview of tag link replacement 2026-02-26 15:22:52 +04:00
94733c9ff3 Merge pull request #160 from koloml/release/0.6.1
Release: 0.6.1
2026-02-26 04:27:47 +04:00
d11cc2a9c5 Bumped version to 0.6.1 2026-02-26 04:27:01 +04:00
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
9031055ec9 Replace the tag link text to resolved tag name when possible 2026-02-26 03:30:10 +04:00
8194a84ef7 Fix pluses not being decoded from the path 2026-02-26 03:28:48 +04:00
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
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
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
a2ab0d4e7c Adding 3 more namespaces unique to Tantabus 2026-02-26 02:34:06 +04:00
5123b57320 Fixed content scripts not properly receiving Tantabus constant 2026-02-26 02:33:15 +04:00
2bdb789777 Support links to /search?q= when detecting tag links to replace 2026-02-25 20:47:22 +04:00
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
ba7b96d888 Bumped version to 0.6.0.1 2026-02-23 20:27:48 +04:00
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
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
2920946015 Merge pull request #151 from koloml/release/0.6.0
Release: 0.6.0
2026-02-23 20:10:37 +04:00
f879c45517 Bumping version to 0.6.0 2026-02-23 20:09:30 +04:00
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
7ffee170c3 Merge pull request #149 from koloml/feature/tantabus-support
Added Tantabus version of extension
2026-02-23 19:03:19 +04:00
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
bf81b7111f Tagging Popup: Automatically color tags by their tag namespaces 2026-02-23 18:38:07 +04:00
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
dfdab180ee Added option to decorate tag links in forum posts 2026-02-23 17:51:27 +04:00
b768f9072c Updating README to include info on Tantabus as well 2026-02-22 20:08:42 +04:00
72a731aaff Updating CI to build for Tantabus on release as well 2026-02-22 20:07:57 +04:00
d181509d6f Preparing extension for Tantabus 2026-02-22 01:49:31 +04:00
03b0763db4 Merge pull request #146 from koloml/release/0.5.4
Release: 0.5.4
2026-02-09 11:29:08 +04:00
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
687c12a8f4 Putting errors and warnings into separate component 2026-02-09 11:24:01 +04:00
9b7ba4a6e2 Bumped version to 0.5.4 2026-02-09 11:05:42 +04:00
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
fccd79292d Fixed tags list didn't update itself once last group was deleted 2026-02-09 10:59:40 +04:00
8041f2d2a1 Merge pull request #145 from koloml/chore/dependencies
Updating dependencies
2026-02-09 10:56:00 +04:00
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
44aca3120c Updated @types/node from 25.0.3 to 25.2.2 2026-02-09 10:50:01 +04:00
3aee3defba Updated @types/chrome from 0.1.32 to 0.1.36 2026-02-09 10:49:33 +04:00
b7a9dc2a2b Updated jsdom from 27.4.0 to 28.0.0 2026-02-09 10:48:56 +04:00
242dfc5972 Updated cheerio from 1.1.2 to 1.2.0 2026-02-09 10:48:01 +04:00
b6840996b6 Updated sass from 1.97.2 to 1.97.3 2026-02-09 10:47:22 +04:00
4c5b796f1d Updated @sveltejs/vite-plugin-svelte from 6.2.3 to 6.2.4 2026-02-09 10:46:35 +04:00
7f2e06a1b1 Updated vitest and @vitest/coverage-v8 from 4.0.16 to 4.0.18 2026-02-09 10:45:40 +04:00
31a33131cd Updated svelte-check from 4.3.5 to 4.3.6 2026-02-09 10:44:56 +04:00
7063459622 Updated @sveltejs/kit from 2.49.4 to 2.50.2 2026-02-09 10:44:03 +04:00
5a82b8751d Updated svelte from 5.46.1 to 5.50.0 2026-02-09 10:43:25 +04:00
9318bd51fa Fixed bulk import only saving last entry 2026-02-09 10:41:10 +04:00
ab625d0181 Merge pull request #141 from koloml/release/0.5.3
Release: 0.5.3
2026-01-09 08:51:45 +04:00
c59d8f55f0 Bumped version to 0.5.3 2026-01-09 08:50:05 +04:00
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
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
c8ff80d445 Move list of tag categories into the tags config script 2026-01-09 06:55:51 +04:00
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
26f09c7c46 Fixed tags list for tagging profiles not updating reactively in popup 2026-01-09 06:41:28 +04:00
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
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
5c5e0812dc Provide names for all popup routes using new store 2026-01-09 05:56:33 +04:00
70129d7a0e Added the store for dynamically changing the popup titles 2026-01-09 05:27:48 +04:00
5fd6dee999 Added constant with the full name of the plugin 2026-01-09 05:27:11 +04:00
ec41ba5030 Furbooru: Added screenshots for tag groups feature 2025-11-04 19:26:47 -05:00
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
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
ef76560bfb Merge pull request #136 from koloml/release/0.5.2
Release: 0.5.2
2025-09-27 22:24:38 +04:00
faa909a0db Properly rearranging non-dev dependencies 2025-09-27 22:22:40 +04:00
3955e3191e Bumped version to 0.5.2 2025-09-27 22:15:38 +04:00
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
a20632e58e Furbooru: Updated tags appearance in media box popups 2025-09-22 22:50:48 +04:00
5f4a1a6c00 Furbooru: Use margin for tag category titles used for Derpibooru 2025-09-22 22:49:33 +04:00
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
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
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
f3d80b58b1 Merge pull request #130 from koloml/release/0.5.1
Release: 0.5.1
2025-08-13 18:23:14 +04:00
d567ab4dec Merge pull request #133 from koloml/feature/derpibooru-smaller-tags
Derpibooru: Making tags slightly smaller in height to fit the styling used by the site
2025-08-13 17:55:39 +04:00
e4322b3021 Derpibooru: Making tags slightly smaller inside popup 2025-08-13 17:54:40 +04:00
4907efdaab Bumped version to 0.5.1 2025-08-13 17:50:31 +04:00
c6b9250d71 Merge pull request #132 from koloml/feature/add-to-profile-icon
Derpibooru: Added icon to the tag dropdown option
2025-08-13 17:27:05 +04:00
c330aa303a Derpibooru: Added icon to the tag dropdown option 2025-08-13 17:24:36 +04:00
9ed3f6939d Merge pull request #129 from koloml/bugfix/derpibooru-tag-editor-styling
Fixed tag categories headlines having inconsistent spacing between Derpibooru and Furbooru
2025-08-13 16:52:39 +04:00
5584733b17 Merge pull request #131 from koloml/bugfix/inconsistent-auto-run
Firefox: Fixed content scripts randomly loading asynchronously and not auto-running
2025-08-13 16:52:23 +04:00
91947b8cc7 Merge remote-tracking branch 'origin/release/0.5.1' into bugfix/inconsistent-auto-run
# Conflicts:
#	src/content/deps/amd.ts
2025-08-13 16:49:18 +04:00
df61c812fe Updated autorun logic to resolve issues with loading modules on Firefox
Sometimes Firefox decides to load different groups of content scripts
asynchronously, causing our trick with `requestAnimationFrame` to miss
everything. To prevent this, I decided to just attempt to autorun
everything on each definition using `setTimeout`.

I've also tried to use `queueMicrotask` to put autorun logic right
between different groups of modules, but this trick was only working on
Firefox and completely breaking on Chromium. I sure love browsers!
2025-08-13 16:48:27 +04:00
65c420c36c Merge pull request #128 from koloml/bugfix/ignore-duplicated-modules
Firefox: Fixed an error message appearing when single chunk is trying to execute multiple times
2025-08-13 16:42:16 +04:00
79cd9bc44d Reduced the space used by the tag category headline
This is mainly affecting the Derpibooru version of the extension. Tags
list on Derpibooru is using flex with gaps instead of flex with margins
appearing like gaps (what currently Furbooru uses). This change would
likely be applied to the Furbooru as well.
2025-08-13 15:56:31 +04:00
cf28d2d131 AMD Loader: Ignore duplicated module definitions
This fixes an error appearing when chunk is mention multiple times for
different entry content scripts.
2025-08-13 15:27:25 +04:00
50238d8ef4 Added links to the Derpibooru extension 2025-08-13 14:56:44 +04:00
98b5311cfc Derpibooru: Added screenshot about tag colors in editor 2025-08-12 13:58:34 +04:00
e60d20fd60 Added showcase screenshots for Derpibooru 2025-08-11 09:11:52 +04:00
110 changed files with 2680 additions and 2369 deletions

BIN
.github/assets/colors-in-editor.png vendored Normal file

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

63
.github/workflows/build-extensions.yml vendored Normal file
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@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
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@v4
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@v4
with:
path: artifacts
- name: Create combined artifact
uses: actions/upload-artifact@v4
with:
name: all-extensions
path: artifacts/
retention-days: 90

View File

@@ -51,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'),
@@ -173,6 +174,15 @@ export async function buildScriptsAndStyles(buildOptions) {
}
});
const tantabusSwapPlugin = SwapDefinedVariablesPlugin({
envVariable: 'SITE',
expectedValue: 'tantabus',
define: {
__CURRENT_SITE__: JSON.stringify('tantabus'),
__CURRENT_SITE_NAME__: JSON.stringify('Tantabus'),
}
});
// Building all scripts together with AMD loader in mind
await build({
configFile: false,
@@ -208,6 +218,7 @@ export async function buildScriptsAndStyles(buildOptions) {
?.push(...dependencies);
}),
derpibooruSwapPlugin,
tantabusSwapPlugin,
],
define: defineConstants,
});
@@ -234,6 +245,7 @@ export async function buildScriptsAndStyles(buildOptions) {
wrapScriptIntoIIFE(),
ScssViteReadEnvVariableFunctionPlugin(),
derpibooruSwapPlugin,
tantabusSwapPlugin,
],
define: defineConstants,
});

View File

@@ -67,13 +67,24 @@ export async function packExtension(settings) {
return entry;
})
if (process.env.SITE === 'derpibooru') {
manifest.replaceHostTo([
'derpibooru.org',
'trixiebooru.org'
]);
manifest.replaceBooruNameWith('Derpibooru');
manifest.setGeckoIdentifier('derpibooru-tagging-assistant@thecore.city');
switch (process.env.SITE) {
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:
console.warn('No replacement set up for site: ' + process.env.SITE);
}
manifest.passVersionFromPackage(path.resolve(settings.rootDir, 'package.json'));

View File

@@ -1,8 +1,8 @@
# Philomena Tagging Assistant
This is a browser extension written for the [Furbooru](https://furbooru.org) and [Derpibooru](https://derpibooru.org)
image-boards. It gives you the ability to manually go over the list of images and apply tags to them without opening
each individual image.
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
@@ -16,7 +16,8 @@ below.
### Derpibooru Tagging Assistant
I wasn't able to release the extension for Derpibooru yet. Links will be available shortly.
[![Get the Add-on on Firefox](.github/assets/firefox.png)](https://addons.mozilla.org/en-US/firefox/addon/derpibooru-tagging-assistant/)
[![Get the extension on Chrome](.github/assets/chrome.png)](https://chromewebstore.google.com/detail/pnmbomcdbfcghgmegklfofncfigdielb)
## Features
@@ -58,14 +59,17 @@ npm install --save-dev
Second, you need to run the `build` command. It will first build the popup using SvelteKit and then build all the
content scripts/stylesheets and copy the manifest afterward.
Extension can currently be built for 2 different imageboards using one of the following commands:
Extension can currently be built for multiple different imageboards using one of the following commands:
```shell
# To build the extension for Furbooru, use:
# Furbooru:
npm run build
# To build the extension for Derpbooru, use:
# Derpibooru:
npm run build:derpibooru
# Tantabus:
npm run build:tantabus
```
When build is complete, extension files can be found in the `/build` directory. These files can be either used

View File

@@ -1,10 +1,15 @@
{
"name": "Furbooru Tagging Assistant",
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
"version": "0.5.0",
"version": "0.6.1",
"browser_specific_settings": {
"gecko": {
"id": "furbooru-tagging-assistant@thecore.city"
"id": "furbooru-tagging-assistant@thecore.city",
"data_collection_permissions": {
"required": [
"none"
]
}
}
},
"icons": {
@@ -47,6 +52,9 @@
],
"js": [
"src/content/tags-editor.ts"
],
"css": [
"src/styles/content/tags-editor.scss"
]
},
{
@@ -66,6 +74,19 @@
"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"
]
}
],
"action": {

2244
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,12 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.5.0",
"version": "0.6.1",
"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:popup": "vite build",
"build:extension": "node build-extension.js",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@@ -12,27 +14,26 @@
"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",
"cross-env": "^10.0.0",
"jsdom": "^26.1.0",
"sass": "^1.89.1",
"svelte": "^5.33.14",
"svelte-check": "^4.2.1",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.2.0"
},
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.53.0",
"@fortawesome/fontawesome-free": "^7.2.0",
"amd-lite": "^1.0.1",
"lz-string": "^1.5.0"
"lz-string": "^1.5.0",
"sass": "^1.97.3",
"svelte": "^5.53.3"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@types/chrome": "^0.1.37",
"@types/node": "^25.3.0",
"@vitest/coverage-v8": "^4.0.18",
"cheerio": "^1.2.0",
"cross-env": "^10.1.0",
"jsdom": "^28.1.0",
"svelte-check": "^4.4.3",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}

4
src/app.d.ts vendored
View File

@@ -1,6 +1,6 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
import MaintenanceProfile from "$entities/MaintenanceProfile";
import TaggingProfile from "$entities/TaggingProfile";
import type TagGroup from "$entities/TagGroup";
declare global {
@@ -37,7 +37,7 @@ declare global {
);
interface EntityNamesMap {
profiles: MaintenanceProfile;
profiles: TaggingProfile;
groups: TagGroup;
}

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import type MaintenanceProfile from "$entities/MaintenanceProfile";
import type TaggingProfile from "$entities/TaggingProfile";
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">

View File

@@ -1,5 +1,9 @@
<script lang="ts">
import { PLUGIN_NAME } from "$lib/constants";
</script>
<header>
<a href="/">{__CURRENT_SITE_NAME__} Tagging Assistant</a>
<a href="/">{PLUGIN_NAME}</a>
</header>
<style lang="scss">

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>

View File

@@ -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;

View File

@@ -1,3 +1,50 @@
/**
* List of categories defined by the sites.
*/
export const categories: string[] = [
'rating',
'spoiler',
'origin',
'oc',
'error',
'character',
'content-official',
'content-fanmade',
'species',
'body-type',
];
/**
* 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', 'origin'],
['art pack', 'content-fanmade'],
['colorist', 'origin'],
['comic', 'content-fanmade'],
['editor', 'origin'],
['fanfic', 'content-fanmade'],
['oc', 'oc'],
['photographer', 'origin'],
['series', 'content-fanmade'],
['spoiler', 'spoiler'],
['video', 'content-fanmade'],
...(__CURRENT_SITE__ === 'tantabus' ? <const> [
["prompter", "origin"],
["creator", "origin"],
["generator", "origin"]
] : [])
]);
/**
* 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",

View File

@@ -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;

View File

@@ -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');

View File

@@ -1,9 +1,9 @@
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 { MaintenancePopupEventsMap } from "$content/components/events/maintenance-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";
type EventsMapping =
MaintenancePopupEventsMap

View File

@@ -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';

View File

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

View File

@@ -1,7 +1,7 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
import { emit, on } from "$lib/components/events/comms";
import { 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 } from "$content/components/events/comms";
import { EVENT_SIZE_LOADED } from "$content/components/events/fullscreen-viewer-events";
export class FullscreenViewer extends BaseComponent {
#videoElement: HTMLVideoElement = document.createElement('video');
@@ -53,8 +53,8 @@ export class FullscreenViewer extends BaseComponent {
this.#imageElement.addEventListener('load', this.#onLoaded.bind(this));
this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation());
FullscreenViewer.#miscSettings
.resolveFullscreenViewerPreviewSize()
FullscreenViewer.#preferences
.fullscreenViewerSize.get()
.then(this.#onSizeResolved.bind(this))
.then(this.#watchForSizeSelectionChanges.bind(this));
}
@@ -179,7 +179,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) {
@@ -202,7 +202,7 @@ export class FullscreenViewer extends BaseComponent {
}
lastActiveSize = targetSize;
void FullscreenViewer.#miscSettings.setFullscreenViewerPreviewSize(targetSize);
void FullscreenViewer.#preferences.fullscreenViewerSize.set(targetSize as FullscreenViewerSize);
});
}
@@ -289,7 +289,7 @@ export class FullscreenViewer extends BaseComponent {
return url.endsWith('.mp4') || url.endsWith('.webm');
}
static #miscSettings = new MiscSettings();
static #preferences = new MiscPreferences();
static #offsetProperty = '--offset';
static #opacityProperty = '--opacity';

View File

@@ -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();
})
@@ -58,6 +56,15 @@ export class ImageShowFullscreenButton extends BaseComponent {
?.show(imageLinks);
}
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;
static #resolveViewer(): FullscreenViewer {
@@ -76,14 +83,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();
}

View File

@@ -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/maintenance-popup-events";
import type { MediaBox } from "$content/components/philomena/MediaBox";
import type TaggingProfile from "$entities/TaggingProfile";
export class MediaBoxTools extends BaseComponent {
#mediaBox: MediaBox | null = null;
#maintenancePopup: TaggingProfilePopup | null = null;
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 TaggingProfilePopup) {
this.#maintenancePopup = 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 maintenancePopup(): TaggingProfilePopup | null {
return this.#maintenancePopup;
}
get mediaBox(): MediaBox | 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.
*/
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;
}
}

View File

@@ -1,16 +1,17 @@
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 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 } from "$lib/components/events/comms";
import { emitterAt } from "$content/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";
} from "$content/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
import { resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils";
class BlackListedTagsEncounteredError extends Error {
constructor(tagName: string) {
@@ -20,11 +21,11 @@ class BlackListedTagsEncounteredError extends Error {
}
}
export class MaintenancePopup extends BaseComponent {
export class TaggingProfilePopup extends BaseComponent {
#tagsListElement: HTMLElement = document.createElement('div');
#tagsList: HTMLElement[] = [];
#suggestedInvalidTags: Map<string, HTMLElement> = new Map();
#activeProfile: MaintenanceProfile | null = null;
#activeProfile: TaggingProfile | null = null;
#mediaBoxTools: MediaBoxTools | null = null;
#tagsToRemove: Set<string> = new Set();
#tagsToAdd: Set<string> = new Set();
@@ -65,7 +66,7 @@ export class MaintenancePopup extends BaseComponent {
this.#mediaBoxTools = mediaBoxTools;
MaintenancePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
TaggingProfilePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
this.#tagsListElement.addEventListener('click', this.#handleTagClick.bind(this));
const mediaBox = this.#mediaBoxTools.mediaBox;
@@ -78,7 +79,7 @@ export class MaintenancePopup extends BaseComponent {
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
}
#onActiveProfileChanged(activeProfile: MaintenanceProfile | null) {
#onActiveProfileChanged(activeProfile: TaggingProfile | null) {
this.#activeProfile = activeProfile;
this.container.classList.toggle('is-active', activeProfile !== null);
this.#refreshTagsList();
@@ -109,7 +110,7 @@ export class MaintenancePopup extends BaseComponent {
activeProfileTagsList
.sort((a, b) => a.localeCompare(b))
.forEach((tagName, index) => {
const tagElement = MaintenancePopup.#buildTagElement(tagName);
const tagElement = TaggingProfilePopup.#buildTagElement(tagName);
this.#tagsList[index] = tagElement;
this.#tagsListElement.appendChild(tagElement);
@@ -121,8 +122,13 @@ export class MaintenancePopup extends BaseComponent {
// Just to prevent duplication, we need to include this tag to the map of suggested invalid tags
if (tagsBlacklist.includes(tagName)) {
MaintenancePopup.#markTagAsInvalid(tagElement);
TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error');
this.#suggestedInvalidTags.set(tagName, tagElement);
} else {
TaggingProfilePopup.#markTagElementWithCategory(
tagElement,
resolveTagCategoryFromTagName(tagName) ?? '',
);
}
});
}
@@ -173,7 +179,7 @@ export class MaintenancePopup extends BaseComponent {
if (this.#tagsToAdd.size || this.#tagsToRemove.size) {
// Notify only once, when first planning to submit
if (!this.#isPlanningToSubmit) {
MaintenancePopup.#notifyAboutPendingSubmission(true);
TaggingProfilePopup.#notifyAboutPendingSubmission(true);
}
this.#isPlanningToSubmit = true;
@@ -191,7 +197,7 @@ export class MaintenancePopup extends BaseComponent {
if (this.#isPlanningToSubmit && !this.#isSubmitting) {
this.#tagsSubmissionTimer = setTimeout(
this.#onSubmissionTimerPassed.bind(this),
MaintenancePopup.#delayBeforeSubmissionMs
TaggingProfilePopup.#delayBeforeSubmissionMs
);
}
}
@@ -208,10 +214,10 @@ export class MaintenancePopup extends BaseComponent {
let maybeTagsAndAliasesAfterUpdate;
const shouldAutoRemove = await MaintenancePopup.#maintenanceSettings.resolveStripBlacklistedTags();
const shouldAutoRemove = await TaggingProfilePopup.#preferences.stripBlacklistedTags.get();
try {
maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
maybeTagsAndAliasesAfterUpdate = await TaggingProfilePopup.#scrapedAPI.updateImageTags(
this.#mediaBoxTools.mediaBox.imageId,
tagsList => {
for (let tagName of this.#tagsToRemove) {
@@ -244,7 +250,7 @@ export class MaintenancePopup extends BaseComponent {
console.warn('Tags submission failed:', e);
}
MaintenancePopup.#notifyAboutPendingSubmission(false);
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
this.#isSubmitting = false;
@@ -262,7 +268,7 @@ export class MaintenancePopup extends BaseComponent {
this.#tagsToRemove.clear();
this.#refreshTagsList();
MaintenancePopup.#notifyAboutPendingSubmission(false);
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
this.#isSubmitting = false;
}
@@ -286,8 +292,8 @@ export class MaintenancePopup extends BaseComponent {
continue;
}
const tagElement = MaintenancePopup.#buildTagElement(tagName);
MaintenancePopup.#markTagAsInvalid(tagElement);
const tagElement = TaggingProfilePopup.#buildTagElement(tagName);
TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error');
tagElement.classList.add('is-present');
this.#suggestedInvalidTags.set(tagName, tagElement);
@@ -305,6 +311,14 @@ export class MaintenancePopup extends BaseComponent {
return this.container.classList.contains('is-active');
}
static create(): HTMLElement {
const container = document.createElement('div');
new this(container);
return container;
}
static #buildTagElement(tagName: string): HTMLElement {
const tagElement = document.createElement('span');
tagElement.classList.add('tag');
@@ -315,18 +329,19 @@ export class MaintenancePopup extends BaseComponent {
}
/**
* Marks the tag with red color.
* Mark the tag element with specified category.
* @param tagElement Element to mark.
* @param category Code name of category to mark.
*/
static #markTagAsInvalid(tagElement: HTMLElement) {
tagElement.dataset.tagCategory = 'error';
tagElement.setAttribute('data-tag-category', 'error');
static #markTagElementWithCategory(tagElement: HTMLElement, category: string) {
tagElement.dataset.tagCategory = category;
tagElement.setAttribute('data-tag-category', category);
}
/**
* Controller with maintenance settings.
*/
static #maintenanceSettings = new MaintenanceSettings();
static #preferences = new TaggingProfilesPreferences();
/**
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
@@ -334,10 +349,10 @@ export class MaintenancePopup extends BaseComponent {
* @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 {
static #watchActiveProfile(callback: (profile: TaggingProfile | null) => void): () => void {
let lastActiveProfileId: string | null | undefined = null;
const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => {
const unsubscribeFromProfilesChanges = TaggingProfile.subscribe(profiles => {
if (lastActiveProfileId) {
callback(
profiles.find(profile => profile.id === lastActiveProfileId) || null
@@ -345,20 +360,18 @@ export class MaintenancePopup extends BaseComponent {
}
});
const unsubscribeFromMaintenanceSettings = this.#maintenanceSettings.subscribe(settings => {
const unsubscribeFromMaintenanceSettings = this.#preferences.subscribe(settings => {
if (settings.activeProfile === lastActiveProfileId) {
return;
}
lastActiveProfileId = settings.activeProfile;
this.#maintenanceSettings
.resolveActiveProfileAsObject()
this.#preferences.activeProfile.asObject()
.then(callback);
});
this.#maintenanceSettings
.resolveActiveProfileAsObject()
this.#preferences.activeProfile.asObject()
.then(profileOrNull => {
if (profileOrNull) {
lastActiveProfileId = profileOrNull.id;
@@ -409,11 +422,3 @@ export class MaintenancePopup extends BaseComponent {
*/
static #pendingSubmissionCount: number|null = null;
}
export function createMaintenancePopup() {
const container = document.createElement('div');
new MaintenancePopup(container);
return container;
}

View File

@@ -1,10 +1,10 @@
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_MAINTENANCE_STATE_CHANGED } from "$content/components/events/maintenance-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() {
@@ -52,13 +52,13 @@ export class MaintenanceStatusIcon extends BaseComponent {
this.container.innerText = '❓';
}
}
}
export function createMaintenanceStatusIcon() {
const element = document.createElement('div');
element.classList.add('maintenance-status-icon');
new MaintenanceStatusIcon(element);
return element;
static create(): HTMLElement {
const element = document.createElement('div');
element.classList.add('maintenance-status-icon');
new TaggingProfileStatusIcon(element);
return element;
}
}

View File

@@ -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();
}
}
}

View File

@@ -0,0 +1,103 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { buildTagsAndAliasesMap } from "$lib/philomena/tag-utils";
import { on } from "$content/components/events/comms";
import { EVENT_TAGS_UPDATED } from "$content/components/events/maintenance-popup-events";
export class MediaBox 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.
*/
static initialize(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
new MediaBox(mediaBoxContainer)
.initialize();
for (let childComponentElement of childComponentElements) {
mediaBoxContainer.appendChild(childComponentElement);
getComponent(childComponentElement)?.initialize();
}
}
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');
}
})
}
}

View File

@@ -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);
});
}
}

View File

@@ -1,8 +1,8 @@
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";
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { emit, on, type UnsubscribeFunction } from "$content/components/events/comms";
import { EVENT_FETCH_COMPLETE } from "$content/components/events/booru-events";
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
export class TagsForm extends BaseComponent {
protected init() {

View File

@@ -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();
});
}

View File

@@ -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();
}
}
}

View File

@@ -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;

View File

@@ -2,21 +2,52 @@ 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) => {
return originalDefine(name, dependencies, function () {
// 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
});
// We don't have anything asynchronous, so it's safe to execute everything on the next frame.
requestAnimationFrame(() => {
amdLite.resolveDependencies(Object.keys(amdLite.waitingModules))
});

View File

@@ -1,19 +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(),
)
]);
@@ -23,8 +22,5 @@ mediaBoxes.forEach(mediaBoxElement => {
})
});
calculateMediaBoxesPositions(mediaBoxes);
if (imageListContainer) {
initializeImageListContainer(imageListContainer);
}
MediaBox.initializePositionCalculation(mediaBoxes);
ImageListContainer.findAndInitialize();

3
src/content/posts.ts Normal file
View File

@@ -0,0 +1,3 @@
import { BlockCommunication } from "$content/components/philomena/BlockCommunication";
BlockCommunication.findAndInitializeAll();

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();

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();

View File

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

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;
}

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;
}

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');
}
})
}

View File

@@ -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
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,4 @@
/**
* Automatically generated name of the plugin.
*/
export const PLUGIN_NAME = __CURRENT_SITE_NAME__ + ' Tagging Assistant';

View File

@@ -2,7 +2,7 @@ import type StorageEntity from "$lib/extension/base/StorageEntity";
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
import type { ImportableElementsList, ImportableEntityObject } from "$lib/extension/transporting/importables";
import EntitiesTransporter, { type SameSiteStatus } from "$lib/extension/EntitiesTransporter";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import TaggingProfile from "$entities/TaggingProfile";
import TagGroup from "$entities/TagGroup";
type TransportersMapping = {
@@ -73,7 +73,7 @@ export default class BulkEntitiesTransporter {
elements: entities
.map(entity => {
switch (true) {
case entity instanceof MaintenanceProfile:
case entity instanceof TaggingProfile:
return BulkEntitiesTransporter.#transporters.profiles.exportToObject(entity);
case entity instanceof TagGroup:
return BulkEntitiesTransporter.#transporters.groups.exportToObject(entity);
@@ -99,7 +99,7 @@ export default class BulkEntitiesTransporter {
}
static #transporters: TransportersMapping = {
profiles: new EntitiesTransporter(MaintenanceProfile),
profiles: new EntitiesTransporter(TaggingProfile),
groups: new EntitiesTransporter(TagGroup),
}

View File

@@ -1,13 +1,13 @@
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
import type { TagDropdown } from "$content/components/philomena/TagDropdown";
import TagGroup from "$entities/TagGroup";
import { escapeRegExp } from "$lib/utils";
import { emit } from "$lib/components/events/comms";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
import { emit } from "$content/components/events/comms";
import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdown-events";
export default class CustomCategoriesResolver {
#exactGroupMatches = new Map<string, TagGroup>();
#regExpGroupMatches = new Map<RegExp, TagGroup>();
#tagDropdowns: TagDropdownWrapper[] = [];
#tagDropdowns: TagDropdown[] = [];
#nextQueuedUpdate: Timeout | null = null;
constructor() {
@@ -15,7 +15,7 @@ export default class CustomCategoriesResolver {
TagGroup.readAll().then(this.#onTagGroupsReceived.bind(this));
}
public addElement(tagDropdown: TagDropdownWrapper): void {
public addElement(tagDropdown: TagDropdown): void {
this.#tagDropdowns.push(tagDropdown);
if (!this.#exactGroupMatches.size && !this.#regExpGroupMatches.size) {
@@ -49,7 +49,7 @@ export default class CustomCategoriesResolver {
* @return {boolean} Will return false when tag is processed and true when it is not found.
* @private
*/
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdown): boolean {
const tagName = tagDropdown.tagName!;
if (!this.#exactGroupMatches.has(tagName)) {
@@ -65,7 +65,7 @@ export default class CustomCategoriesResolver {
return false;
}
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
#matchCustomCategoryByRegExp(tagDropdown: TagDropdown) {
const tagName = tagDropdown.tagName!;
for (const targetRegularExpression of this.#regExpGroupMatches.keys()) {
@@ -90,6 +90,7 @@ export default class CustomCategoriesResolver {
this.#regExpGroupMatches.clear();
if (!tagGroups.length) {
this.#queueUpdatingTags();
return;
}
@@ -116,7 +117,7 @@ export default class CustomCategoriesResolver {
this.#queueUpdatingTags();
}
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
static #resetToOriginalCategory(tagDropdown: TagDropdown): void {
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,

View File

@@ -0,0 +1,179 @@
import ConfigurationController from "$lib/extension/ConfigurationController";
/**
* Initialization options for the preference field helper class.
*/
type PreferenceFieldOptions<FieldKey, ValueType> = {
/**
* Field name which will be read or updated.
*/
field: FieldKey;
/**
* Default value for this field.
*/
defaultValue: ValueType;
}
/**
* Helper class for a field. Contains all information needed to read or set the values into the preferences while
* retaining proper types for the values.
*/
export class PreferenceField<
/**
* Mapping of keys to fields. Usually this is the same type used for defining the structure of the storage itself.
* Is automatically captured when preferences class instance is passed into the constructor.
*/
Fields extends Record<string, any> = Record<string, any>,
/**
* Field key for resolving which value will be resolved from getter or which value type should be passed into the
* setter method.
*/
Key extends keyof Fields = keyof Fields
> {
/**
* Instance of the preferences class to read/update values on.
* @private
*/
readonly #preferences: CacheablePreferences<Fields>;
/**
* Key of a field we want to read or write with the helper class.
* @private
*/
readonly #fieldKey: Key;
/**
* Stored default value for a field.
* @private
*/
readonly #defaultValue: Fields[Key];
/**
* @param preferencesInstance Instance of preferences to work with.
* @param options Initialization options for this field.
*/
constructor(preferencesInstance: CacheablePreferences<Fields>, options: PreferenceFieldOptions<Key, Fields[Key]>) {
this.#preferences = preferencesInstance;
this.#fieldKey = options.field;
this.#defaultValue = options.defaultValue;
}
/**
* Read the field value from the preferences.
*/
get() {
return this.#preferences.readRaw(this.#fieldKey, this.#defaultValue);
}
/**
* Update the preference field with provided value.
* @param value Value to update the field with.
*/
set(value: Fields[Key]) {
return this.#preferences.writeRaw(this.#fieldKey, value);
}
}
/**
* Helper type for preference classes to enforce having field objects inside the preferences instance. It should be
* applied on child classes of {@link CacheablePreferences}.
*/
export type WithFields<FieldsType extends Record<string, any>> = {
readonly [FieldKey in keyof FieldsType]: PreferenceField<FieldsType, FieldKey>;
}
/**
* Base class for any preferences instances. It contains methods for reading or updating any arbitrary values inside
* extension storage. It also tries to save the value resolved from the storage into special internal cache after the
* first call.
*
* Should be usually paired with implementation of {@link WithFields} helper type as interface for much more usable
* API.
*/
export default abstract class CacheablePreferences<Fields> {
#controller: ConfigurationController;
#cachedValues: Map<keyof Fields, any> = new Map();
#disposables: Function[] = [];
/**
* @param settingsNamespace Name of the field inside the extension storage where these preferences stored.
* @protected
*/
protected constructor(settingsNamespace: string) {
this.#controller = new ConfigurationController(settingsNamespace);
this.#disposables.push(
this.#controller.subscribeToChanges(settings => {
for (const key of Object.keys(settings)) {
this.#cachedValues.set(
key as keyof Fields,
settings[key]
);
}
})
);
}
/**
* Read the value from the preferences by the field. This function doesn't handle default values, so you generally
* should avoid using this method and accessing the special fields instead.
* @param settingName Name of the field to read.
* @param defaultValue Default value to return if value is not set.
* @return Value of the field or default value if it is not set.
*/
public async readRaw<Key extends keyof Fields>(settingName: Key, defaultValue: Fields[Key]): Promise<Fields[Key]> {
if (this.#cachedValues.has(settingName)) {
return this.#cachedValues.get(settingName);
}
const settingValue = await this.#controller.readSetting(settingName as string, defaultValue);
this.#cachedValues.set(settingName, settingValue);
return settingValue;
}
/**
* Write the value into specific field of the storage. You should generally avoid calling this function directly and
* instead rely on special field helpers inside your preferences class.
* @param settingName Name of the setting to write.
* @param value Value to pass.
* @param force Ignore the cache and force the update.
* @protected
*/
async writeRaw<Key extends keyof Fields>(settingName: Key, value: Fields[Key], force: boolean = false): Promise<void> {
if (
!force
&& this.#cachedValues.has(settingName)
&& this.#cachedValues.get(settingName) === value
) {
return;
}
return this.#controller.writeSetting(
settingName as string,
value
);
}
/**
* Subscribe to the changes made to the storage.
* @param callback Callback which will receive list of settings on every update. This function will not be called
* on initialization.
* @return Unsubscribe function to call in order to disable the watching.
*/
subscribe(callback: (settings: Partial<Fields>) => void): () => void {
const unsubscribeCallback = this.#controller.subscribeToChanges(callback as (fields: Record<string, any>) => void);
this.#disposables.push(unsubscribeCallback);
return unsubscribeCallback;
}
/**
* Completely disable all subscriptions.
*/
dispose() {
for (let disposeCallback of this.#disposables) {
disposeCallback();
}
}
}

View File

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

View File

@@ -1,20 +1,20 @@
import StorageEntity from "$lib/extension/base/StorageEntity";
export interface MaintenanceProfileSettings {
export interface TaggingProfileSettings {
name: string;
tags: string[];
temporary: boolean;
}
/**
* Class representing the maintenance profile entity.
* Class representing the tagging profile entity.
*/
export default class MaintenanceProfile extends StorageEntity<MaintenanceProfileSettings> {
export default class TaggingProfile extends StorageEntity<TaggingProfileSettings> {
/**
* @param id ID of the entity.
* @param settings Maintenance profile settings object.
*/
constructor(id: string, settings: Partial<MaintenanceProfileSettings>) {
constructor(id: string, settings: Partial<TaggingProfileSettings>) {
super(id, {
name: settings.name || '',
tags: settings.tags || [],

View File

@@ -0,0 +1,27 @@
import CacheablePreferences, {
PreferenceField,
type WithFields
} from "$lib/extension/base/CacheablePreferences";
export type FullscreenViewerSize = keyof App.ImageURIs;
interface MiscPreferencesFields {
fullscreenViewer: boolean;
fullscreenViewerSize: FullscreenViewerSize;
}
export default class MiscPreferences extends CacheablePreferences<MiscPreferencesFields> implements WithFields<MiscPreferencesFields> {
constructor() {
super("misc");
}
readonly fullscreenViewer = new PreferenceField(this, {
field: "fullscreenViewer",
defaultValue: true,
});
readonly fullscreenViewerSize = new PreferenceField(this, {
field: "fullscreenViewerSize",
defaultValue: "large",
});
}

View File

@@ -0,0 +1,40 @@
import TaggingProfile from "$entities/TaggingProfile";
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
interface TaggingProfilePreferencesFields {
activeProfile: string | null;
stripBlacklistedTags: boolean;
}
class ActiveProfilePreference extends PreferenceField<TaggingProfilePreferencesFields, "activeProfile"> {
constructor(preferencesInstance: CacheablePreferences<TaggingProfilePreferencesFields>) {
super(preferencesInstance, {
field: "activeProfile",
defaultValue: null,
});
}
async asObject(): Promise<TaggingProfile | null> {
const activeProfileId = await this.get();
if (!activeProfileId) {
return null;
}
return (await TaggingProfile.readAll())
.find(profile => profile.id === activeProfileId) || null;
}
}
export default class TaggingProfilesPreferences extends CacheablePreferences<TaggingProfilePreferencesFields> implements WithFields<TaggingProfilePreferencesFields> {
constructor() {
super("maintenance");
}
readonly activeProfile = new ActiveProfilePreference(this);
readonly stripBlacklistedTags = new PreferenceField(this, {
field: "stripBlacklistedTags",
defaultValue: false,
});
}

View File

@@ -0,0 +1,28 @@
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
interface TagsPreferencesFields {
groupSeparation: boolean;
replaceLinks: boolean;
replaceLinkText: boolean;
}
export default class TagsPreferences extends CacheablePreferences<TagsPreferencesFields> implements WithFields<TagsPreferencesFields> {
constructor() {
super("tag");
}
readonly groupSeparation = new PreferenceField(this, {
field: "groupSeparation",
defaultValue: true,
});
readonly replaceLinks = new PreferenceField(this, {
field: "replaceLinks",
defaultValue: false,
});
readonly replaceLinkText = new PreferenceField(this, {
field: "replaceLinkText",
defaultValue: true,
});
}

View File

@@ -1,48 +0,0 @@
import MaintenanceProfile from "$entities/MaintenanceProfile";
import CacheableSettings from "$lib/extension/base/CacheableSettings";
interface MaintenanceSettingsFields {
activeProfile: string | null;
stripBlacklistedTags: boolean;
}
export default class MaintenanceSettings extends CacheableSettings<MaintenanceSettingsFields> {
constructor() {
super("maintenance");
}
/**
* Set the active maintenance profile.
*/
async resolveActiveProfileId() {
return this._resolveSetting("activeProfile", null);
}
/**
* Get the active maintenance profile if it is set.
*/
async resolveActiveProfileAsObject(): Promise<MaintenanceProfile | null> {
const resolvedProfileId = await this.resolveActiveProfileId();
return (await MaintenanceProfile.readAll())
.find(profile => profile.id === resolvedProfileId) || null;
}
async resolveStripBlacklistedTags() {
return this._resolveSetting('stripBlacklistedTags', false);
}
/**
* Set the active maintenance profile.
*
* @param profileId ID of the profile to set as active. If `null`, the active profile will be considered
* unset.
*/
async setActiveProfileId(profileId: string | null): Promise<void> {
await this._writeSetting("activeProfile", profileId);
}
async setStripBlacklistedTags(isEnabled: boolean) {
await this._writeSetting('stripBlacklistedTags', isEnabled);
}
}

View File

@@ -1,30 +0,0 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
export type FullscreenViewerSize = keyof App.ImageURIs;
interface MiscSettingsFields {
fullscreenViewer: boolean;
fullscreenViewerSize: FullscreenViewerSize;
}
export default class MiscSettings extends CacheableSettings<MiscSettingsFields> {
constructor() {
super("misc");
}
async resolveFullscreenViewerEnabled() {
return this._resolveSetting("fullscreenViewer", true);
}
async resolveFullscreenViewerPreviewSize() {
return this._resolveSetting('fullscreenViewerSize', 'large');
}
async setFullscreenViewerEnabled(isEnabled: boolean) {
return this._writeSetting("fullscreenViewer", isEnabled);
}
async setFullscreenViewerPreviewSize(size: FullscreenViewerSize | string) {
return this._writeSetting('fullscreenViewerSize', size as FullscreenViewerSize);
}
}

View File

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

View File

@@ -1,4 +1,4 @@
import PostParser from "$lib/booru/scraped/parsing/PostParser";
import PostParser from "$lib/philomena/scraping/parsing/PostParser";
type UpdaterFunction = (tags: Set<string>) => Set<string>;

View File

@@ -1,5 +1,5 @@
import PageParser from "$lib/booru/scraped/parsing/PageParser";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
import PageParser from "$lib/philomena/scraping/parsing/PageParser";
import { buildTagsAndAliasesMap } from "$lib/philomena/tag-utils";
export default class PostParser extends PageParser {
#tagEditorForm: HTMLFormElement | null = null;

View File

@@ -0,0 +1,118 @@
import { namespaceCategories } from "$config/tags";
import { QueryLexer, QuotedTermToken, TermToken } from "$lib/philomena/search/QueryLexer";
/**
* 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;
}
const tagLinkRegExp = /\/tags\/(?<encodedTagName>[^/?#]+)/;
/**
* List of encoded characters from Philomena.
*
* @see https://github.com/philomena-dev/philomena/blob/6086757b654da8792ae52adb2a2f501ea6c30d12/lib/philomena/slug.ex#L52-L57
*/
const slugEncodedCharacters: Map<string, string> = new Map([
['-dash-', '-'],
['-fwslash-', '/'],
['-bwslash-', '\\'],
['-colon-', ':'],
['-dot-', '.'],
['-plus-', '+'],
]);
/**
* Try to parse the tag name from the search query URL. It uses the same tokenizer as the booru. It only returns the
* tag name if query contains only one single tag without any additional conditions.
*
* @param searchLink Link with search query.
*
* @return Tag name or NULL if query contains more than 1 tag or doesn't have any tags at all.
*/
function parseTagNameFromSearchQuery(searchLink: URL): string | null {
const lexer = new QueryLexer(searchLink.searchParams.get('q') || '');
const parsedQuery = lexer.parse();
if (parsedQuery.length !== 1) {
return null;
}
const [token] = parsedQuery;
switch (true) {
case token instanceof TermToken:
return token.value;
case token instanceof QuotedTermToken:
return token.decodedValue;
}
return null;
}
/**
* Decode the tag name from the following link.
*
* @param tagLink Search link or link to the tag to parse the tag name from.
*
* @return Tag name or NULL if function is failed to parse the name of the tag.
*/
export function resolveTagNameFromLink(tagLink: URL): string | null {
if (tagLink.pathname.startsWith('/search') && tagLink.searchParams.has('q')) {
return parseTagNameFromSearchQuery(tagLink);
}
tagLinkRegExp.lastIndex = 0;
const result = tagLinkRegExp.exec(tagLink.pathname);
const encodedTagName = result?.groups?.encodedTagName;
if (!encodedTagName) {
return null;
}
return decodeURIComponent(encodedTagName)
.replaceAll(/-[a-z]+-/gi, match => slugEncodedCharacters.get(match) ?? match)
.replaceAll('-', ' ')
.replaceAll('+', ' ');
}
/**
* Try to resolve the category from the tag name.
*
* @param tagName Name of the tag.
*/
export function resolveTagCategoryFromTagName(tagName: string): string | null {
const namespace = tagName.split(':')[0];
return namespaceCategories.get(namespace) ?? null;
}

View File

@@ -4,6 +4,7 @@
import Footer from "$components/layout/Footer.svelte";
import { initializeLinksReplacement } from "$lib/popup-links";
import { onDestroy } from "svelte";
import { headTitle } from "$stores/popup";
interface Props {
children?: import('svelte').Snippet;
@@ -22,6 +23,10 @@
})
</script>
<svelte:head>
<title>{$headTitle}</title>
</svelte:head>
<Header/>
<main>
{@render children?.()}

View File

@@ -1,27 +1,30 @@
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { activeProfileStore, maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import { activeTaggingProfile, taggingProfiles } from "$stores/entities/tagging-profiles";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import TaggingProfile from "$entities/TaggingProfile";
import { popupTitle } from "$stores/popup";
let activeProfile = $derived<MaintenanceProfile | null>(
$maintenanceProfiles.find(profile => profile.id === $activeProfileStore) || null
$popupTitle = null;
let activeProfile = $derived<TaggingProfile | null>(
$taggingProfiles.find(profile => profile.id === $activeTaggingProfile) || null
);
function turnOffActiveProfile() {
$activeProfileStore = null;
$activeTaggingProfile = null;
}
</script>
<Menu>
{#if activeProfile}
<MenuCheckboxItem checked oninput={turnOffActiveProfile} href="/features/maintenance/{activeProfile.id}">
<MenuCheckboxItem checked oninput={turnOffActiveProfile} href="/features/profiles/{activeProfile.id}">
Active Profile: {activeProfile.settings.name}
</MenuCheckboxItem>
<hr>
{/if}
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
<MenuItem href="/features/profiles">Tagging Profiles</MenuItem>
<MenuItem href="/features/groups">Tag Groups</MenuItem>
<hr>
<MenuItem href="/transporting">Import/Export</MenuItem>

View File

@@ -1,12 +1,20 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { PLUGIN_NAME } from "$lib/constants";
import { popupTitle } from "$stores/popup";
$popupTitle = 'About';
let currentSiteUrl = 'https://furbooru.org';
if (__CURRENT_SITE__ === 'derpibooru') {
currentSiteUrl = 'https://derpibooru.org';
}
if (__CURRENT_SITE__ === 'tantabus') {
currentSiteUrl = 'https://tantabus.ai';
}
</script>
<Menu>
@@ -14,7 +22,7 @@
<hr>
</Menu>
<h1>
{__CURRENT_SITE_NAME__} Tagging Assistant
{PLUGIN_NAME}
</h1>
<p>
This is a small tool to make tagging on {__CURRENT_SITE_NAME__} just a little bit more convenient. Group tags with

View File

@@ -6,5 +6,5 @@
<Menu>
<MenuItem href="/">Back</MenuItem>
<hr>
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
<MenuItem href="/features/profiles">Tagging Profiles</MenuItem>
</Menu>

View File

@@ -3,6 +3,9 @@
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { tagGroups } from "$stores/entities/tag-groups";
import TagGroup from "$entities/TagGroup";
import { popupTitle } from "$stores/popup";
$popupTitle = 'Tag Groups';
let groups = $derived<TagGroup[]>($tagGroups.sort((a, b) => a.settings.name.localeCompare(b.settings.name)));
</script>

View File

@@ -6,6 +6,7 @@
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { tagGroups } from "$stores/entities/tag-groups";
import TagGroup from "$entities/TagGroup";
import { popupTitle } from "$stores/popup";
let groupId = $derived<string>(page.params.id);
let group = $derived<TagGroup | null>($tagGroups.find(group => group.id === groupId) || null);
@@ -19,6 +20,8 @@
if (!group) {
console.warn(`Group ${groupId} not found.`);
goto('/features/groups');
} else {
$popupTitle = `Tag Group: ${group.settings.name}`;
}
})
</script>

View File

@@ -5,6 +5,7 @@
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { tagGroups } from "$stores/entities/tag-groups";
import type TagGroup from "$entities/TagGroup";
import { popupTitle } from "$stores/popup";
const groupId = $derived<string>(page.params.id);
const targetGroup = $derived<TagGroup | undefined>($tagGroups.find(group => group.id === groupId));
@@ -12,6 +13,8 @@
$effect(() => {
if (!targetGroup) {
goto('/features/groups');
} else {
$popupTitle = `Deleting Tag Group: ${targetGroup.settings.name}`;
}
})

View File

@@ -12,6 +12,7 @@
import TagGroup from "$entities/TagGroup";
import { tagGroups } from "$stores/entities/tag-groups";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
import { popupTitle } from "$stores/popup";
let groupId = $derived(page.params.id);
@@ -32,6 +33,7 @@
$effect(() => {
if (groupId === 'new') {
$popupTitle = 'Create Tag Group';
return;
}
@@ -40,6 +42,8 @@
return;
}
$popupTitle = `Edit Tag Group: ${targetGroup.settings.name}`;
groupName = targetGroup.settings.name;
tagsList = [...targetGroup.settings.tags].sort((a, b) => a.localeCompare(b));
prefixesList = [...targetGroup.settings.prefixes].sort((a, b) => a.localeCompare(b));

View File

@@ -8,6 +8,7 @@
import TagGroup from "$entities/TagGroup";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
import { tagGroups } from "$stores/entities/tag-groups";
import { popupTitle } from "$stores/popup";
let isEncodedGroupShown = $state(true);
@@ -17,6 +18,8 @@
$effect(() => {
if (!group) {
goto('/features/groups');
} else {
$popupTitle = `Export Tag Group: ${group.settings.name}`;
}
});

View File

@@ -8,6 +8,8 @@
import TagGroup from "$entities/TagGroup";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
import { tagGroups } from "$stores/entities/tag-groups";
import { popupTitle } from "$stores/popup";
import Notice from "$components/ui/Notice.svelte";
const groupTransporter = new EntitiesTransporter(TagGroup);
@@ -17,6 +19,12 @@
let candidateGroup = $state<TagGroup | null>(null);
let existingGroup = $state<TagGroup | null>(null);
$effect(() => {
$popupTitle = candidateGroup
? 'Confirm Imported Tag Group'
: 'Import Tag Group';
});
function tryImportingGroup() {
candidateGroup = null;
existingGroup = null;
@@ -74,7 +82,7 @@
<hr>
</Menu>
{#if errorMessage}
<p class="error">Failed to import: {errorMessage}</p>
<Notice level="error">Failed to import: {errorMessage}</Notice>
<Menu>
<hr>
</Menu>
@@ -91,9 +99,10 @@
</Menu>
{:else}
{#if existingGroup}
<p class="warning">
<Notice level="warning">
This group will replace the existing "{existingGroup.settings.name}" group, since it have the same ID.
</p>
</Notice>
<br>
{/if}
<GroupView group={candidateGroup}></GroupView>
<Menu>
@@ -107,24 +116,3 @@
<MenuItem onclick={() => candidateGroup = null}>Cancel</MenuItem>
</Menu>
{/if}
<style lang="scss">
@use '$styles/colors';
.error, .warning {
padding: 5px 24px;
margin: {
left: -24px;
right: -24px;
}
}
.error {
background: colors.$error-background;
}
.warning {
background: colors.$warning-background;
margin-bottom: .5em;
}
</style>

View File

@@ -1,69 +0,0 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { activeProfileStore, maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import ProfileView from "$components/features/ProfileView.svelte";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import { onMount } from "svelte";
let profileId = $derived(page.params.id);
let profile = $derived<MaintenanceProfile|null>(
$maintenanceProfiles.find(profile => profile.id === profileId) || null
);
$effect(() => {
if (profileId === 'new') {
goto('/features/maintenance/new/edit');
return;
}
if (!profile) {
console.warn(`Profile ${profileId} not found.`);
goto('/features/maintenance');
}
});
let isActiveProfile = $state(false);
$effect.pre(() => {
isActiveProfile = $activeProfileStore === profileId;
});
$effect(() => {
if (isActiveProfile && $activeProfileStore !== profileId) {
$activeProfileStore = profileId;
}
if (!isActiveProfile && $activeProfileStore === profileId) {
$activeProfileStore = null;
}
});
</script>
<Menu>
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if profile}
<ProfileView {profile}/>
{/if}
<Menu>
<hr>
<MenuItem href="/features/maintenance/{profileId}/edit" icon="wrench">Edit Profile</MenuItem>
<MenuCheckboxItem bind:checked={isActiveProfile}>
Activate Profile
</MenuCheckboxItem>
<MenuItem href="/features/maintenance/{profileId}/export" icon="file-export">
Export Profile
</MenuItem>
<MenuItem href="/features/maintenance/{profileId}/delete" icon="trash">
Delete Profile
</MenuItem>
</Menu>
<style lang="scss">
</style>

View File

@@ -2,42 +2,45 @@
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import MenuRadioItem from "$components/ui/menu/MenuRadioItem.svelte";
import { activeProfileStore, maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import { activeTaggingProfile, taggingProfiles } from "$stores/entities/tagging-profiles";
import TaggingProfile from "$entities/TaggingProfile";
import { popupTitle } from "$stores/popup";
let profiles = $derived<MaintenanceProfile[]>(
$maintenanceProfiles.sort((a, b) => a.settings.name.localeCompare(b.settings.name))
$popupTitle = 'Tagging Profiles';
let profiles = $derived<TaggingProfile[]>(
$taggingProfiles.sort((a, b) => a.settings.name.localeCompare(b.settings.name))
);
function resetActiveProfile() {
$activeProfileStore = null;
$activeTaggingProfile = null;
}
function enableSelectedProfile(event: Event) {
const target = event.target;
if (target instanceof HTMLInputElement && target.checked) {
activeProfileStore.set(target.value);
activeTaggingProfile.set(target.value);
}
}
</script>
<Menu>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/maintenance/new/edit" icon="plus">Create New</MenuItem>
<MenuItem href="/features/profiles/new/edit" icon="plus">Create New</MenuItem>
{#if profiles.length}
<hr>
{/if}
{#each profiles as profile}
<MenuRadioItem href="/features/maintenance/{profile.id}"
<MenuRadioItem href="/features/profiles/{profile.id}"
name="active-profile"
value={profile.id}
checked={$activeProfileStore === profile.id}
checked={$activeTaggingProfile === profile.id}
oninput={enableSelectedProfile}>
{profile.settings.name}
</MenuRadioItem>
{/each}
<hr>
<MenuItem href="#" onclick={resetActiveProfile}>Reset Active Profile</MenuItem>
<MenuItem href="/features/maintenance/import">Import Profile</MenuItem>
<MenuItem href="/features/profiles/import">Import Profile</MenuItem>
</Menu>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { activeTaggingProfile, taggingProfiles } from "$stores/entities/tagging-profiles";
import ProfileView from "$components/features/ProfileView.svelte";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
import TaggingProfile from "$entities/TaggingProfile";
import { popupTitle } from "$stores/popup";
let profileId = $derived(page.params.id);
let profile = $derived<TaggingProfile|null>(
$taggingProfiles.find(profile => profile.id === profileId) || null
);
$effect(() => {
if (profileId === 'new') {
goto('/features/profiles/new/edit');
return;
}
if (!profile) {
console.warn(`Profile ${profileId} not found.`);
goto('/features/profiles');
} else {
$popupTitle = `Tagging Profile: ${profile.settings.name}`;
}
});
let isActiveProfile = $state(false);
$effect.pre(() => {
isActiveProfile = $activeTaggingProfile === profileId;
});
$effect(() => {
if (isActiveProfile && $activeTaggingProfile !== profileId) {
$activeTaggingProfile = profileId;
}
if (!isActiveProfile && $activeTaggingProfile === profileId) {
$activeTaggingProfile = null;
}
});
</script>
<Menu>
<MenuItem href="/features/profiles" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if profile}
<ProfileView {profile}/>
{/if}
<Menu>
<hr>
<MenuItem href="/features/profiles/{profileId}/edit" icon="wrench">Edit Profile</MenuItem>
<MenuCheckboxItem bind:checked={isActiveProfile}>
Activate Profile
</MenuCheckboxItem>
<MenuItem href="/features/profiles/{profileId}/export" icon="file-export">
Export Profile
</MenuItem>
<MenuItem href="/features/profiles/{profileId}/delete" icon="trash">
Delete Profile
</MenuItem>
</Menu>
<style lang="scss">
</style>

View File

@@ -3,17 +3,20 @@
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { page } from "$app/state";
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import { taggingProfiles } from "$stores/entities/tagging-profiles";
import TaggingProfile from "$entities/TaggingProfile";
import { popupTitle } from "$stores/popup";
const profileId = $derived(page.params.id);
const targetProfile = $derived<MaintenanceProfile | null>(
$maintenanceProfiles.find(profile => profile.id === profileId) || null
const targetProfile = $derived<TaggingProfile | null>(
$taggingProfiles.find(profile => profile.id === profileId) || null
);
$effect(() => {
if (!targetProfile) {
goto('/features/maintenance');
goto('/features/profiles');
} else {
$popupTitle = `Deleting Tagging Profile: ${targetProfile.settings.name}`
}
});
@@ -24,12 +27,12 @@
}
await targetProfile.delete();
await goto('/features/maintenance');
await goto('/features/profiles');
}
</script>
<Menu>
<MenuItem href="/features/maintenance/{profileId}" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/profiles/{profileId}" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if targetProfile}
@@ -39,7 +42,7 @@
<Menu>
<hr>
<MenuItem onclick={deleteProfile}>Yes</MenuItem>
<MenuItem href="/features/maintenance/{profileId}">No</MenuItem>
<MenuItem href="/features/profiles/{profileId}">No</MenuItem>
</Menu>
{:else}
<p>Loading...</p>

View File

@@ -7,18 +7,19 @@
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import { taggingProfiles } from "$stores/entities/tagging-profiles";
import TaggingProfile from "$entities/TaggingProfile";
import { popupTitle } from "$stores/popup";
let profileId = $derived(page.params.id);
let targetProfile = $derived.by<MaintenanceProfile | null>(() => {
let targetProfile = $derived.by<TaggingProfile | null>(() => {
if (profileId === 'new') {
return new MaintenanceProfile(crypto.randomUUID(), {});
return new TaggingProfile(crypto.randomUUID(), {});
}
return $maintenanceProfiles.find(profile => profile.id === profileId) || null;
return $taggingProfiles.find(profile => profile.id === profileId) || null;
});
let profileName = $state('');
@@ -26,14 +27,17 @@
$effect(() => {
if (profileId === 'new') {
$popupTitle = 'Create Tagging Profile';
return;
}
if (!targetProfile) {
goto('/features/maintenance');
goto('/features/profiles');
return;
}
$popupTitle = `Edit Tagging Profile: ${targetProfile.settings.name}`;
profileName = targetProfile.settings.name;
tagsList = [...targetProfile.settings.tags].sort((a, b) => a.localeCompare(b));
});
@@ -49,12 +53,12 @@
targetProfile.settings.temporary = false;
await targetProfile.save();
await goto('/features/maintenance/' + targetProfile.id);
await goto('/features/profiles/' + targetProfile.id);
}
</script>
<Menu>
<MenuItem href="/features/maintenance{profileId === 'new' ? '' : '/' + profileId}" icon="arrow-left">
<MenuItem href="/features/profiles{profileId === 'new' ? '' : '/' + profileId}" icon="arrow-left">
Back
</MenuItem>
<hr>

View File

@@ -1,28 +1,31 @@
<script lang="ts">
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import { taggingProfiles } from "$stores/entities/tagging-profiles";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import TaggingProfile from "$entities/TaggingProfile";
import { popupTitle } from "$stores/popup";
let isCompressedProfileShown = $state(true);
const profileId = $derived(page.params.id);
const profile = $derived<MaintenanceProfile | null>(
$maintenanceProfiles.find(profile => profile.id === profileId) || null
const profile = $derived<TaggingProfile | null>(
$taggingProfiles.find(profile => profile.id === profileId) || null
);
$effect(() => {
if (!profile) {
goto('/features/maintenance/');
goto('/features/profiles/');
} else {
$popupTitle = `Export Tagging Profile: ${profile.settings.name}`;
}
});
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
const profilesTransporter = new EntitiesTransporter(TaggingProfile);
let rawExportedProfile = $derived(profile ? profilesTransporter.exportToJSON(profile) : '');
let compressedExportedProfile = $derived(profile ? profilesTransporter.exportToCompressedJSON(profile) : '');
@@ -30,7 +33,7 @@
</script>
<Menu>
<MenuItem href="/features/maintenance/{profileId}" icon="arrow-left">
<MenuItem href="/features/profiles/{profileId}" icon="arrow-left">
Back
</MenuItem>
<hr>

View File

@@ -2,21 +2,28 @@
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import TaggingProfile from "$entities/TaggingProfile";
import FormControl from "$components/ui/forms/FormControl.svelte";
import ProfileView from "$components/features/ProfileView.svelte";
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import { taggingProfiles } from "$stores/entities/tagging-profiles";
import { goto } from "$app/navigation";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
import { popupTitle } from "$stores/popup";
import Notice from "$components/ui/Notice.svelte";
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
const profilesTransporter = new EntitiesTransporter(TaggingProfile);
let importedString = $state('');
let errorMessage = $state('');
let candidateProfile = $state<MaintenanceProfile | null>(null);
let existingProfile = $state<MaintenanceProfile | null>(null);
let candidateProfile = $state<TaggingProfile | null>(null);
let existingProfile = $state<TaggingProfile | null>(null);
$effect(() => {
$popupTitle = candidateProfile
? 'Confirm Imported Tagging Profile'
: 'Import Tagging Profile';
})
function tryImportingProfile() {
candidateProfile = null;
existingProfile = null;
@@ -42,7 +49,7 @@
}
if (candidateProfile) {
existingProfile = $maintenanceProfiles.find(profile => profile.id === candidateProfile?.id) ?? null;
existingProfile = $taggingProfiles.find(profile => profile.id === candidateProfile?.id) ?? null;
}
}
@@ -52,7 +59,7 @@
}
candidateProfile.save().then(() => {
goto(`/features/maintenance`);
goto(`/features/profiles`);
});
}
@@ -61,20 +68,20 @@
return;
}
const clonedProfile = new MaintenanceProfile(crypto.randomUUID(), candidateProfile.settings);
const clonedProfile = new TaggingProfile(crypto.randomUUID(), candidateProfile.settings);
clonedProfile.settings.name += ` (Clone ${new Date().toISOString()})`;
clonedProfile.save().then(() => {
goto(`/features/maintenance`);
goto(`/features/profiles`);
});
}
</script>
<Menu>
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/profiles" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if errorMessage}
<p class="error">Failed to import: {errorMessage}</p>
<Notice level="error">Failed to import: {errorMessage}</Notice>
<Menu>
<hr>
</Menu>
@@ -91,9 +98,10 @@
</Menu>
{:else}
{#if existingProfile}
<p class="warning">
<Notice level="warning">
This profile will replace the existing "{existingProfile.settings.name}" profile, since it have the same ID.
</p>
</Notice>
<br>
{/if}
<ProfileView profile={candidateProfile}></ProfileView>
<Menu>
@@ -107,24 +115,3 @@
<MenuItem onclick={() => candidateProfile = null}>Cancel</MenuItem>
</Menu>
{/if}
<style lang="scss">
@use '$styles/colors';
.error, .warning {
padding: 5px 24px;
margin: {
left: -24px;
right: -24px;
}
}
.error {
background: colors.$error-background;
}
.warning {
background: colors.$warning-background;
margin-bottom: .5em;
}
</style>

View File

@@ -1,6 +1,9 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { popupTitle } from "$stores/popup";
$popupTitle = 'Preferences';
</script>
<Menu>

View File

@@ -1,6 +1,9 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { popupTitle } from "$stores/popup";
$popupTitle = 'Debugging Tools';
</script>
<Menu>

View File

@@ -2,6 +2,9 @@
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import { storagesCollection } from "$stores/debug";
import { popupTitle } from "$stores/popup";
$popupTitle = 'Storage Inspector';
</script>
<Menu>

View File

@@ -2,6 +2,7 @@
import StorageViewer from "$components/debugging/StorageViewer.svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { popupTitle } from "$stores/popup";
let pathArray = $derived.by<string[]>(() => {
const pathString = page.params.path;
@@ -30,6 +31,10 @@
if (!storageName) {
goto("/preferences/debug/storage");
}
$popupTitle = storageName
? 'Inspecting: ' + storageName
: 'Storage Inspector';
});
</script>

View File

@@ -5,6 +5,9 @@
import FormControl from "$components/ui/forms/FormControl.svelte";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
import { fullScreenViewerEnabled } from "$stores/preferences/misc";
import { popupTitle } from "$stores/popup";
$popupTitle = 'Misc Preferences';
</script>
<Menu>

View File

@@ -4,8 +4,15 @@
import FormControl from "$components/ui/forms/FormControl.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { stripBlacklistedTagsEnabled } from "$stores/preferences/maintenance";
import { shouldSeparateTagGroups } from "$stores/preferences/tag";
import { stripBlacklistedTagsEnabled } from "$stores/preferences/profiles";
import {
shouldReplaceLinksOnForumPosts,
shouldReplaceTextOfTagLinks,
shouldSeparateTagGroups
} from "$stores/preferences/tags";
import { popupTitle } from "$stores/popup";
$popupTitle = 'Tagging Preferences';
</script>
<Menu>
@@ -23,4 +30,16 @@
Enable separation of custom tag groups on the image pages
</CheckboxField>
</FormControl>
<FormControl>
<CheckboxField bind:checked={$shouldReplaceLinksOnForumPosts}>
Find and replace links to the tags in the forum posts
</CheckboxField>
</FormControl>
{#if $shouldReplaceLinksOnForumPosts}
<FormControl>
<CheckboxField bind:checked={$shouldReplaceTextOfTagLinks}>
Try to replace text on links pointing to tags in forum posts
</CheckboxField>
</FormControl>
{/if}
</FormContainer>

View File

@@ -1,6 +1,9 @@
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { popupTitle } from "$stores/popup";
$popupTitle = 'Import/Export';
</script>
<Menu>

View File

@@ -1,13 +1,14 @@
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import { taggingProfiles } from "$stores/entities/tagging-profiles";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
import { tagGroups } from "$stores/entities/tag-groups";
import BulkEntitiesTransporter from "$lib/extension/BulkEntitiesTransporter";
import type StorageEntity from "$lib/extension/base/StorageEntity";
import FormControl from "$components/ui/forms/FormControl.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import { popupTitle } from "$stores/popup";
const bulkTransporter = new BulkEntitiesTransporter();
@@ -29,7 +30,7 @@
if (displayExportedString) {
const elementsToExport: StorageEntity[] = [];
$maintenanceProfiles.forEach(profile => {
$taggingProfiles.forEach(profile => {
if (exportedEntities.profiles[profile.id]) {
elementsToExport.push(profile);
}
@@ -46,9 +47,15 @@
}
});
$effect(() => {
$popupTitle = displayExportedString
? 'Exported String'
: 'Select Entities to Export';
});
function refreshAreAllEntitiesChecked() {
requestAnimationFrame(() => {
exportAllProfiles = $maintenanceProfiles.every(profile => exportedEntities.profiles[profile.id]);
exportAllProfiles = $taggingProfiles.every(profile => exportedEntities.profiles[profile.id]);
exportAllGroups = $tagGroups.every(group => exportedEntities.groups[group.id]);
});
}
@@ -62,7 +69,7 @@
requestAnimationFrame(() => {
switch (targetEntity) {
case "profiles":
$maintenanceProfiles.forEach(profile => exportedEntities.profiles[profile.id] = exportAllProfiles);
$taggingProfiles.forEach(profile => exportedEntities.profiles[profile.id] = exportAllProfiles);
break;
case "groups":
$tagGroups.forEach(group => exportedEntities.groups[group.id] = exportAllGroups);
@@ -87,11 +94,11 @@
<Menu>
<MenuItem href="/transporting" icon="arrow-left">Back</MenuItem>
<hr>
{#if $maintenanceProfiles.length}
{#if $taggingProfiles.length}
<MenuCheckboxItem bind:checked={exportAllProfiles} oninput={createToggleAllOnUserInput('profiles')}>
Export All Profiles
</MenuCheckboxItem>
{#each $maintenanceProfiles as profile}
{#each $taggingProfiles as profile}
<MenuCheckboxItem bind:checked={exportedEntities.profiles[profile.id]} oninput={refreshAreAllEntitiesChecked}>
Profile: {profile.settings.name}
</MenuCheckboxItem>

View File

@@ -3,27 +3,31 @@
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import TaggingProfile from "$entities/TaggingProfile";
import TagGroup from "$entities/TagGroup";
import BulkEntitiesTransporter from "$lib/extension/BulkEntitiesTransporter";
import type StorageEntity from "$lib/extension/base/StorageEntity";
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import { taggingProfiles } from "$stores/entities/tagging-profiles";
import { tagGroups } from "$stores/entities/tag-groups";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
import ProfileView from "$components/features/ProfileView.svelte";
import GroupView from "$components/features/GroupView.svelte";
import { goto } from "$app/navigation";
import type { SameSiteStatus } from "$lib/extension/EntitiesTransporter";
import { popupTitle } from "$stores/popup";
import Notice from "$components/ui/Notice.svelte";
let importedString = $state('');
let errorMessage = $state('');
let importedProfiles = $state<MaintenanceProfile[]>([]);
let importedProfiles = $state<TaggingProfile[]>([]);
let importedGroups = $state<TagGroup[]>([]);
let saveAllProfiles = $state(false);
let saveAllGroups = $state(false);
let isSaving = $state(false);
let selectedEntities: Record<keyof App.EntityNamesMap, Record<string, boolean>> = $state({
profiles: {},
groups: {},
@@ -32,10 +36,10 @@
let previewedEntity = $state<StorageEntity | null>(null);
const existingProfilesMap = $derived(
$maintenanceProfiles.reduce((map, profile) => {
$taggingProfiles.reduce((map, profile) => {
map.set(profile.id, profile);
return map;
}, new Map<string, MaintenanceProfile>())
}, new Map<string, TaggingProfile>())
);
const existingGroupsMap = $derived(
@@ -49,6 +53,16 @@
Boolean(importedProfiles.length || importedGroups.length)
);
$effect(() => {
$popupTitle = hasImportedEntities
? (
previewedEntity
? 'Preview of Imported Entity'
: 'Select & Preview Imported Entities'
)
: 'Import';
});
const transporter = new BulkEntitiesTransporter();
let lastImportStatus = $state<SameSiteStatus>(null);
@@ -84,7 +98,7 @@
for (const targetImportedEntity of importedEntities) {
switch (targetImportedEntity.type) {
case "profiles":
importedProfiles.push(targetImportedEntity as MaintenanceProfile);
importedProfiles.push(targetImportedEntity as TaggingProfile);
break;
case "groups":
importedGroups.push(targetImportedEntity as TagGroup);
@@ -134,31 +148,42 @@
}
}
function saveSelectedEntities() {
Promise.allSettled([
Promise.allSettled(
importedProfiles
.filter(profile => selectedEntities.profiles[profile.id])
.map(profile => profile.save())
),
Promise.allSettled(
importedGroups
.filter(group => selectedEntities.groups[group.id])
.map(group => group.save())
),
]).then(() => {
goto("/transporting");
});
async function saveSelectedEntities() {
if (isSaving) {
return;
}
isSaving = true;
for (const profile of importedProfiles) {
if (!selectedEntities.profiles[profile.id]) {
continue;
}
await profile.save();
}
for (const group of importedGroups) {
if (!selectedEntities.groups[group.id]) {
continue;
}
await group.save();
}
await goto("/transporting");
}
</script>
{#if !hasImportedEntities}
{#if isSaving}
<p>Saving imported entities...</p>
{:else if !hasImportedEntities}
<Menu>
<MenuItem href="/transporting" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if errorMessage}
<p class="error">{errorMessage}</p>
<Notice level="error">{errorMessage}</Notice>
<Menu>
<hr>
</Menu>
@@ -177,7 +202,7 @@
<MenuItem onclick={() => previewedEntity = null} icon="arrow-left">Back to Selection</MenuItem>
<hr>
</Menu>
{#if previewedEntity instanceof MaintenanceProfile}
{#if previewedEntity instanceof TaggingProfile}
<ProfileView profile={previewedEntity}></ProfileView>
{:else if previewedEntity instanceof TagGroup}
<GroupView group={previewedEntity}></GroupView>
@@ -190,18 +215,18 @@
{/if}
</Menu>
{#if lastImportStatus === "different"}
<p class="warning">
<Notice level="warning">
<b>Warning!</b>
Looks like these entities were exported for the different extension! There are many differences between tagging
systems of Furobooru and Derpibooru, so make sure to check if these settings are correct before using them!
</p>
</Notice>
{/if}
{#if lastImportStatus === 'unknown'}
<p class="warning">
<Notice level="warning">
<b>Warning!</b>
We couldn't verify if these settings are meant for this site or not. There are many differences between tagging
systems of Furbooru and Derpibooru, so make sure to check if these settings are correct before using them.
</p>
</Notice>
{/if}
<Menu>
{#if importedProfiles.length}
@@ -253,23 +278,3 @@
</MenuItem>
</Menu>
{/if}
<style lang="scss">
@use '$styles/colors';
.error, .warning {
padding: 5px 24px;
margin: {
left: -24px;
right: -24px;
}
}
.warning {
background: colors.$warning-background;
}
.error {
background: colors.$error-background;
}
</style>

View File

@@ -1,52 +0,0 @@
import { type Writable, writable } from "svelte/store";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
/**
* Store for working with maintenance profiles in the Svelte popup.
*/
export const maintenanceProfiles: Writable<MaintenanceProfile[]> = writable([]);
/**
* Store for the active maintenance profile ID.
*/
export const activeProfileStore: Writable<string|null> = writable(null);
const maintenanceSettings = new MaintenanceSettings();
/**
* Active profile ID stored locally. Used to reset active profile once the existing profile was removed.
*/
let lastActiveProfileId: string|null = null;
Promise.allSettled([
// Read the initial values from the storages first
MaintenanceProfile.readAll().then(profiles => {
maintenanceProfiles.set(profiles);
}),
maintenanceSettings.resolveActiveProfileId().then(activeProfileId => {
activeProfileStore.set(activeProfileId);
})
]).then(() => {
// And only after initial values are loaded, start watching for changes from storage and from user's interaction
MaintenanceProfile.subscribe(profiles => {
maintenanceProfiles.set(profiles);
});
maintenanceSettings.subscribe(settings => {
activeProfileStore.set(settings.activeProfile || null);
});
activeProfileStore.subscribe(profileId => {
lastActiveProfileId = profileId;
void maintenanceSettings.setActiveProfileId(profileId);
});
// Watch the existence of the active profile on every change.
MaintenanceProfile.subscribe(profiles => {
if (!profiles.find(profile => profile.id === lastActiveProfileId)) {
activeProfileStore.set(null);
}
});
});

View File

@@ -0,0 +1,52 @@
import { type Writable, writable } from "svelte/store";
import TaggingProfile from "$entities/TaggingProfile";
import TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences";
/**
* Store for working with maintenance profiles in the Svelte popup.
*/
export const taggingProfiles: Writable<TaggingProfile[]> = writable([]);
/**
* Store for the active maintenance profile ID.
*/
export const activeTaggingProfile: Writable<string|null> = writable(null);
const preferences = new TaggingProfilesPreferences();
/**
* Active profile ID stored locally. Used to reset active profile once the existing profile was removed.
*/
let lastActiveProfileId: string|null = null;
Promise.allSettled([
// Read the initial values from the storages first
TaggingProfile.readAll().then(profiles => {
taggingProfiles.set(profiles);
}),
preferences.activeProfile.get().then(activeProfileId => {
activeTaggingProfile.set(activeProfileId);
})
]).then(() => {
// And only after initial values are loaded, start watching for changes from storage and from user's interaction
TaggingProfile.subscribe(profiles => {
taggingProfiles.set(profiles);
});
preferences.subscribe(settings => {
activeTaggingProfile.set(settings.activeProfile || null);
});
activeTaggingProfile.subscribe(profileId => {
lastActiveProfileId = profileId;
void preferences.activeProfile.set(profileId);
});
// Watch the existence of the active profile on every change.
TaggingProfile.subscribe(profiles => {
if (!profiles.find(profile => profile.id === lastActiveProfileId)) {
activeTaggingProfile.set(null);
}
});
});

15
src/stores/popup.ts Normal file
View File

@@ -0,0 +1,15 @@
import { derived, writable } from "svelte/store";
import { PLUGIN_NAME } from "$lib/constants";
/**
* Store containing the name of the subpage. This name will be added to the title alongside the name of the plugin.
*/
export const popupTitle = writable<string | null>(null);
/**
* Name of the current route with the name of the plugin appended to it.
*/
export const headTitle = derived(
popupTitle,
$popupTitle => ($popupTitle ? `${$popupTitle} | ` : '') + PLUGIN_NAME
);

View File

@@ -1,18 +0,0 @@
import { writable } from "svelte/store";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
export const stripBlacklistedTagsEnabled = writable(true);
const maintenanceSettings = new MaintenanceSettings();
Promise
.all([
maintenanceSettings.resolveStripBlacklistedTags().then(v => stripBlacklistedTagsEnabled.set(v ?? true))
])
.then(() => {
maintenanceSettings.subscribe(settings => {
stripBlacklistedTagsEnabled.set(typeof settings.stripBlacklistedTags === 'boolean' ? settings.stripBlacklistedTags : true);
});
stripBlacklistedTagsEnabled.subscribe(v => maintenanceSettings.setStripBlacklistedTags(v));
});

View File

@@ -1,18 +1,18 @@
import { writable } from "svelte/store";
import MiscSettings from "$lib/extension/settings/MiscSettings";
import MiscPreferences from "$lib/extension/preferences/MiscPreferences";
export const fullScreenViewerEnabled = writable(true);
const miscSettings = new MiscSettings();
const preferences = new MiscPreferences();
Promise.allSettled([
miscSettings.resolveFullscreenViewerEnabled().then(v => fullScreenViewerEnabled.set(v))
preferences.fullscreenViewer.get().then(v => fullScreenViewerEnabled.set(v))
]).then(() => {
fullScreenViewerEnabled.subscribe(value => {
void miscSettings.setFullscreenViewerEnabled(value);
void preferences.fullscreenViewer.set(value);
});
miscSettings.subscribe(settings => {
preferences.subscribe(settings => {
fullScreenViewerEnabled.set(Boolean(settings.fullscreenViewer));
});
});

View File

@@ -0,0 +1,18 @@
import { writable } from "svelte/store";
import TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences";
export const stripBlacklistedTagsEnabled = writable(true);
const preferences = new TaggingProfilesPreferences();
Promise
.all([
preferences.stripBlacklistedTags.get().then(v => stripBlacklistedTagsEnabled.set(v ?? true))
])
.then(() => {
preferences.subscribe(settings => {
stripBlacklistedTagsEnabled.set(typeof settings.stripBlacklistedTags === 'boolean' ? settings.stripBlacklistedTags : true);
});
stripBlacklistedTagsEnabled.subscribe(v => preferences.stripBlacklistedTags.set(v));
});

View File

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

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