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

Merge pull request #164 from koloml/release/0.7.0

Release: 0.7.0
This commit is contained in:
2026-03-14 21:58:02 +04:00
committed by GitHub
99 changed files with 2615 additions and 1181 deletions

View File

@@ -96,21 +96,16 @@ class ManifestProcessor {
singleOrMultipleHostnames = [singleOrMultipleHostnames];
}
const matchPatterReplacer = ManifestProcessor.#createHostnameReplacementReduce(singleOrMultipleHostnames);
this.#manifestObject.host_permissions = singleOrMultipleHostnames.map(hostname => `*://*.${hostname}/`);
this.#manifestObject.content_scripts?.forEach(entry => {
entry.matches = entry.matches.reduce((resultMatches, originalMatchPattern) => {
for (const updatedHostname of singleOrMultipleHostnames) {
resultMatches.push(
originalMatchPattern.replace(
/\*:\/\/\*\.[a-z]+\.[a-z]+\//,
`*://*.${updatedHostname}/`
),
);
}
entry.matches = entry.matches.reduce(matchPatterReplacer, []);
return resultMatches;
}, []);
if (entry.exclude_matches) {
entry.exclude_matches = entry.exclude_matches.reduce(matchPatterReplacer, []);
}
})
}
@@ -148,6 +143,32 @@ class ManifestProcessor {
}
);
}
/**
* @param {string|(string[])} singleOrMultipleHostnames
* @return {function(string[], string): string[]}
*/
static #createHostnameReplacementReduce(singleOrMultipleHostnames) {
return (
/**
* @param {string[]} resultMatches
* @param {string} originalMatchPattern
* @return {string[]}
*/
(resultMatches, originalMatchPattern) => {
for (const updatedHostname of singleOrMultipleHostnames) {
resultMatches.push(
originalMatchPattern.replace(
/\*:\/\/\*\.[a-z]+\.[a-z]+\//,
`*://*.${updatedHostname}/`
),
);
}
return resultMatches;
}
);
}
}
/**
@@ -186,6 +207,7 @@ export function loadManifest(filePath) {
/**
* @typedef {Object} ContentScriptsEntry
* @property {string[]} matches
* @property {string[]} exclude_matches
* @property {string[]|undefined} js
* @property {string[]|undefined} css
*/

View File

@@ -3,6 +3,7 @@ import path from "path";
import { buildScriptsAndStyles } from "./lib/content-scripts.js";
import { extractInlineScriptsFromIndex } from "./lib/index-file.js";
import { normalizePath } from "vite";
import fs from "fs";
/**
* Build addition assets required for the extension and pack it into the directory.
@@ -84,12 +85,55 @@ export async function packExtension(settings) {
break;
default:
console.warn('No replacement set up for site: ' + process.env.SITE);
if (process.env.SITE) {
console.warn('No replacement set up for site: ' + process.env.SITE);
}
}
manifest.passVersionFromPackage(path.resolve(settings.rootDir, 'package.json'));
manifest.saveTo(path.resolve(settings.exportDir, 'manifest.json'));
const iconsDirectory = path.resolve(settings.exportDir, 'icons');
switch (process.env.SITE) {
case "derpibooru":
case "tantabus":
const siteIconsDirectory = path.resolve(iconsDirectory, process.env.SITE);
if (!fs.existsSync(siteIconsDirectory)) {
console.warn(`Can't find replacement icons for site ${process.env.SITE}`);
break;
}
console.log(`Found replacement icons for ${process.env.SITE}, swapping them...`);
fs.readdirSync(siteIconsDirectory).forEach(fileName => {
const originalIconPath = path.resolve(settings.exportDir, fileName);
const replacementIconPath = path.resolve(siteIconsDirectory, fileName);
if (!fs.existsSync(originalIconPath)) {
console.warn(`Original icon not found: ${originalIconPath}`)
return;
}
fs.rmSync(originalIconPath);
fs.cpSync(replacementIconPath, originalIconPath);
console.log(`Replaced: ${path.relative(settings.rootDir, replacementIconPath)}${path.relative(settings.rootDir, originalIconPath)}`);
});
break;
}
if (fs.existsSync(iconsDirectory)) {
console.log('Cleaning up icon replacements directory');
fs.rmSync(iconsDirectory, {
recursive: true,
force: true,
});
}
extractInlineScriptsFromIndex(path.resolve(settings.exportDir, 'index.html'));
}

View File

@@ -1,7 +1,7 @@
{
"name": "Furbooru Tagging Assistant",
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
"version": "0.6.1",
"version": "0.7.0",
"browser_specific_settings": {
"gecko": {
"id": "furbooru-tagging-assistant@thecore.city",
@@ -50,11 +50,16 @@
"matches": [
"*://*.furbooru.org/images/*"
],
"exclude_matches": [
"*://*.furbooru.org/images/new",
"*://*.furbooru.org/images/new?*"
],
"js": [
"src/content/tags-editor.ts"
],
"css": [
"src/styles/content/tags-editor.scss"
"src/styles/content/tags-editor.scss",
"src/styles/content/tag-presets.scss"
]
},
{
@@ -87,6 +92,17 @@
"css": [
"src/styles/content/posts.scss"
]
},
{
"matches": [
"*://*.furbooru.org/images/new"
],
"js": [
"src/content/upload.ts"
],
"css": [
"src/styles/content/tag-presets.scss"
]
}
],
"action": {

786
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.6.1",
"version": "0.7.0",
"private": true,
"type": "module",
"scripts": {
@@ -16,24 +16,24 @@
},
"dependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.53.0",
"@sveltejs/kit": "^2.55.0",
"@fortawesome/fontawesome-free": "^7.2.0",
"amd-lite": "^1.0.1",
"lz-string": "^1.5.0",
"sass": "^1.97.3",
"svelte": "^5.53.3"
"sass": "^1.98.0",
"svelte": "^5.53.12"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@types/chrome": "^0.1.37",
"@types/node": "^25.3.0",
"@vitest/coverage-v8": "^4.0.18",
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "^4.1.0",
"cheerio": "^1.2.0",
"cross-env": "^10.1.0",
"jsdom": "^28.1.0",
"svelte-check": "^4.4.3",
"svelte-check": "^4.4.5",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18"
"vitest": "^4.1.0"
}
}

6
src/app.d.ts vendored
View File

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

57
src/assets/icon/README.md Normal file
View File

@@ -0,0 +1,57 @@
# Extension Icon
This folder contains original resources used to make an icon for the extension. Since I'm not really an icon designer, I
ended up just composing the icon from sites logos + the shorthand name of the extension with fancy font. Nothing
special.
## Sources
All resources used for composing an icon are stored here as copies just to not lose anything. Original assets are
sourced from the following places:
- [Derpibooru Logo](https://github.com/derpibooru/philomena/blob/40ffb1b75bd0d96db24fa7c84bce36fcb7f2935f/assets/static/favicon.svg)
- [Furbooru Logo](https://github.com/furbooru/philomena/blob/cbfde406de34734403c06952bcaca51db6df1390/assets/static/favicon.svg)
- [Tantabus Logo](https://github.com/tantabus-ai/philomena/blob/285a7666ae4be46ac4da36bbc9ac8fda9e5c0fc3/assets/static/favicon.svg)
- [RoundFeather Font](https://drive.google.com/file/d/18ggNplAZNYtO4eNtMUpv3XpkeOAxSkxm/view?usp=sharing)
- Made by [allorus162](https://bsky.app/profile/allorus162.bsky.social)
- [Original Bluesky post](https://bsky.app/profile/allorus162.bsky.social/post/3mfqntff4j22i)
## Rendering
**Note:** You don't need to do anything to pack current version of icon to the extension. All icons are already pre-rendered and
placed into the `static` directory.
For now, any change to the icons will require manual re-rendering of PNG versions of the logos used when packing
extension for the release. All you need is to open `/src/assets/icon/icon.svg` in software like Inskape, hide the
currently opened logo and toggle the required one and save it into `icon256.png`, `icon128.png`, `icon48.png` and
`icon16.png`.
For the font on the bottom-right to work, you will need to install it from the file
`src/assets/icon/fonts/roundfeather-regular-1.001.ttf` (or you can download and install it from the source link).
You should render them into `/static` directory in the following structure:
- Place Furbooru icons into `/static` directory
- Then add same icons for Derpibooru and Tantabus into `/static/icons/depribooru` and `/static/icons/tantabus`
respectively.
Resulting structure will look like this:
```
static/
icons/
derpibooru/
icon16.png
icon48.png
icon128.png
icon256.png
tantabus/
icon16.png
icon48.png
icon128.png
icon256.png
icon16.png
icon48.png
icon128.png
icon256.png
```

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="132.29mm" height="132.29mm" version="1.1" viewBox="0 0 132.29 132.29" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(1.0427 0 0 1.0427 -.017147 -1.1711)"><path d="m103.79 1.5c2.226 55.884-2.592 66.082-26.774 81.11-10.46 6.495-24.44-7.2571-36.56-5.1501-14.529 2.53-33.119 12.655-39.169 20.603l0.00607-0.0012c56.82 1.164 44.414 29.924 80.7 29.64 21.39-0.17 35.89-18.483 38.62-27.615 10.456-34.942 3.1379-65.073-16.823-98.587zm11.375 57.714c-1.6412 10.692-0.54281 11.478 6.66 16.938-8.649-2.0114-10.977 0.7243-14.702 8.2179 1.5999-8.243 0.4741-11.62-5.8526-15.099 8.3149 0.88078 9.4155 0.05081 13.895-10.057zm-34.884 33.692c-3.7887 11.898-1.8578 13.462 6.4355 18.092-12.032-2.4927-11.44 1.5364-16.965 7.6122 2.9076-9.5873 1.2336-12.084-5.6426-16.122 5.6959 0.20558 11.418 1.8392 16.172-9.5819z" style="fill:#73d6ed"/><path d="m69.863 33.123-42.793 78.941 5.4648 2.9629 42.791-78.943z" style="-inkscape-stroke:none;color:#000000;fill:#73d6ed"/><g style="fill:#73d6ed"><path d="m64.894 48.218 7.18-13.796" style="-inkscape-stroke:none;color:#000000;fill:#73d6ed;stroke-width:6.557"/></g><path d="m89.844 3.2499-14.504 13.697-16.245-10.04 7.31 17.535-16.03 14.152 21.282-2.9352 8.782 19.4 2.8299-22.61 19.26-1.614-17.67-8.8749zm-7.235 13.028-1.5585 8.3965 7.2681 3.9461-8.1864 1.1558-1.6952 9.6819-3.8619-8.8428-9.5827 1.4895 7.3746-6.5192-3.4781-7.6725 7.4099 4.1481z" style="fill:#73d6ed"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

115
src/assets/icon/icon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
import type TagGroup from "$entities/TagGroup";
import DetailsBlock from "$components/ui/DetailsBlock.svelte";
import TagsList from "$components/tags/TagsList.svelte";
interface GroupViewProps {
group: TagGroup;
@@ -14,60 +16,27 @@
</script>
<div class="block">
<strong>Group Name:</strong>
<div>{group.settings.name}</div>
</div>
<DetailsBlock title="Group Name">
{group.settings.name}
</DetailsBlock>
{#if sortedTagsList.length}
<div class="block">
<strong>Tags:</strong>
<DetailsBlock title="Tags">
<TagsColorContainer targetCategory={group.settings.category}>
<div class="tags-list">
{#each sortedTagsList as tagName}
<span class="tag">{tagName}</span>
{/each}
</div>
<TagsList tags={sortedTagsList} />
</TagsColorContainer>
</div>
</DetailsBlock>
{/if}
{#if sortedPrefixes.length}
<div class="block">
<strong>Prefixes:</strong>
<DetailsBlock title="Prefixes">
<TagsColorContainer targetCategory={group.settings.category}>
<div class="tags-list">
{#each sortedPrefixes as prefixName}
<span class="tag">{prefixName}*</span>
{/each}
</div>
<TagsList tags={sortedPrefixes} append="*" />
</TagsColorContainer>
</div>
</DetailsBlock>
{/if}
{#if sortedSuffixes.length}
<div class="block">
<strong>Suffixes:</strong>
<DetailsBlock title="Suffixes">
<TagsColorContainer targetCategory={group.settings.category}>
<div class="tags-list">
{#each sortedSuffixes as suffixName}
<span class="tag">*{suffixName}</span>
{/each}
</div>
<TagsList tags={sortedSuffixes} prepend="*" />
</TagsColorContainer>
</div>
</DetailsBlock>
{/if}
<style lang="scss">
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.block + .block {
margin-top: .5em;
strong {
display: block;
margin-bottom: .25em;
}
}
</style>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type TagEditorPreset from "$entities/TagEditorPreset";
import DetailsBlock from "$components/ui/DetailsBlock.svelte";
import TagsList from "$components/tags/TagsList.svelte";
interface PresetViewProps {
preset: TagEditorPreset;
}
let { preset }: PresetViewProps = $props();
const sortedTagsList = $derived(preset.settings.tags.toSorted((a, b) => a.localeCompare(b)));
</script>
<DetailsBlock title="Preset Name">
{preset.settings.name}
</DetailsBlock>
<DetailsBlock title="Tags">
<TagsList tags={sortedTagsList}></TagsList>
</DetailsBlock>

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import type MaintenanceProfile from "$entities/MaintenanceProfile";
import type TaggingProfile from "$entities/TaggingProfile";
import DetailsBlock from "$components/ui/DetailsBlock.svelte";
import TagsList from "$components/tags/TagsList.svelte";
interface ProfileViewProps {
profile: MaintenanceProfile;
profile: TaggingProfile;
}
let { profile }: ProfileViewProps = $props();
@@ -10,32 +12,9 @@
const sortedTagsList = $derived(profile.settings.tags.sort((a, b) => a.localeCompare(b)));
</script>
<div class="block">
<strong>Profile:</strong>
<div>{profile.settings.name}</div>
</div>
<div class="block">
<strong>Tags:</strong>
<div class="tags-list">
{#each sortedTagsList as tagName}
<span class="tag">{tagName}</span>
{/each}
</div>
</div>
<style lang="scss">
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.block + .block {
margin-top: .5em;
strong {
display: block;
margin-bottom: .25em;
}
}
</style>
<DetailsBlock title="Profile">
{profile.settings.name}
</DetailsBlock>
<DetailsBlock title="Tags">
<TagsList tags={sortedTagsList} />
</DetailsBlock>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
interface TagsListProps {
tags: string[];
prepend?: string;
append?: string;
}
let { tags, prepend, append }: TagsListProps = $props();
</script>
<div class="tags-list">
{#each tags as tagName}
<div class="tag">{prepend || ''}{tagName}{append || ''}</div>
{/each}
</div>
<style lang="scss">
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
</style>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface DetailsBlockProps {
title?: string;
children?: Snippet;
}
let { title, children }: DetailsBlockProps = $props();
</script>
<div class="block">
{#if title?.length}
<strong>{title}:</strong>
{/if}
<div>
{@render children?.()}
</div>
</div>
<style lang="scss">
.block strong {
display: block;
margin-bottom: .25em;
}
.block + :global(.block) {
margin-top: .5em;
}
</style>

View File

@@ -1,150 +0,0 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { emit, on, type UnsubscribeFunction } from "$content/components/events/comms";
import { EVENT_FETCH_COMPLETE } from "$content/components/events/booru-events";
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
export class TagsForm extends BaseComponent {
protected init() {
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
const unsubscribe = on(
this.container,
EVENT_FETCH_COMPLETE,
() => this.#waitAndDetectUpdatedForm(unsubscribe),
);
}
#waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void {
const elementContainingTagEditor = this.container
.closest('#image_tags_and_source')
?.parentElement;
if (!elementContainingTagEditor) {
return;
}
const observer = new MutationObserver(() => {
const tagsFormElement = elementContainingTagEditor.querySelector<HTMLElement>('#tags-form');
if (!tagsFormElement || getComponent(tagsFormElement)) {
return;
}
const tagFormComponent = new TagsForm(tagsFormElement);
tagFormComponent.initialize();
const fullTagEditor = tagFormComponent.parentTagEditorElement;
if (fullTagEditor) {
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
} else {
console.info('Tag form is not in the tag editor. Event is not sent.');
}
observer.disconnect();
unsubscribe();
});
observer.observe(elementContainingTagEditor, {
subtree: true,
childList: true,
});
// Make sure to forcibly disconnect everything after a while.
setTimeout(() => {
observer.disconnect();
unsubscribe();
}, 5000);
}
get parentTagEditorElement(): HTMLElement | null {
return this.container.closest<HTMLElement>('.js-tagsauce')
}
/**
* Collect all the tag categories available on the page and color the tags in the editor according to them.
*/
refreshTagColors() {
const tagCategories = this.#gatherTagCategories();
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
for (const tagElement of editableTags) {
// Tag name is stored in the "remove" link and not in the tag itself.
const removeLink = tagElement.querySelector('a');
if (!removeLink) {
continue;
}
const tagName = removeLink.dataset.tagName;
if (!tagName || !tagCategories.has(tagName)) {
continue;
}
const categoryName = tagCategories.get(tagName)!;
tagElement.dataset.tagCategory = categoryName;
tagElement.setAttribute('data-tag-category', categoryName);
}
}
/**
* Collect list of categories from the tags on the page.
* @return
*/
#gatherTagCategories(): Map<string, string> {
const tagCategories: Map<string, string> = new Map();
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
const tagName = tagElement.dataset.tagName;
const tagCategory = tagElement.dataset.tagCategory;
if (!tagName || !tagCategory) {
console.warn('Missing tag name or category!');
continue;
}
tagCategories.set(tagName, tagCategory);
}
return tagCategories;
}
static watchForEditors() {
document.body.addEventListener('click', event => {
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) {
return;
}
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
if (!tagEditorWrapper) {
return;
}
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
if (!refreshTrigger) {
return;
}
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
if (!tagFormElement) {
return;
}
let tagEditor = getComponent(tagFormElement);
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
tagEditor = new TagsForm(tagFormElement);
tagEditor.initialize();
}
(tagEditor as TagsForm).refreshTagColors();
});
}
}

View File

@@ -1,5 +1,15 @@
export const EVENT_FETCH_COMPLETE = 'fetchcomplete';
export const EVENT_RELOAD = 'reload';
/**
* Custom data for the reload event on plain editor textarea. Philomena doesn't send anything on this event.
*/
export interface ReloadCustomOptions {
skipTagColorRefresh?: boolean;
skipTagRefresh?: boolean;
}
export interface BooruEventsMap {
[EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation.
[EVENT_RELOAD]: ReloadCustomOptions|null;
}

View File

@@ -4,13 +4,15 @@ import type { FullscreenViewerEventsMap } from "$content/components/events/fulls
import type { BooruEventsMap } from "$content/components/events/booru-events";
import type { TagsFormEventsMap } from "$content/components/events/tags-form-events";
import type { TagDropdownEvents } from "$content/components/events/tag-dropdown-events";
import type { PresetBlockEventsMap } from "$content/components/events/preset-block-events";
type EventsMapping =
MaintenancePopupEventsMap
& FullscreenViewerEventsMap
& BooruEventsMap
& TagsFormEventsMap
& TagDropdownEvents;
& TagDropdownEvents
& PresetBlockEventsMap;
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
export type UnsubscribeFunction = () => void;

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

@@ -0,0 +1,10 @@
export const EVENT_PRESET_TAG_CHANGE_APPLIED = 'preset-tag-changed';
export interface PresetTagChange {
addedTags?: Set<string>;
removedTags?: Set<string>;
}
export interface PresetBlockEventsMap {
[EVENT_PRESET_TAG_CHANGE_APPLIED]: PresetTagChange;
}

View File

@@ -1,5 +1,5 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
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";
@@ -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 "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import MiscSettings from "$lib/extension/settings/MiscSettings";
import { FullscreenViewer } from "$content/components/FullscreenViewer";
import type { MediaBoxTools } from "$content/components/MediaBoxTools";
import MiscPreferences from "$lib/extension/preferences/MiscPreferences";
import { FullscreenViewer } from "$content/components/extension/FullscreenViewer";
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
export class ImageShowFullscreenButton extends BaseComponent {
#mediaBoxTools: MediaBoxTools | null = null;
@@ -10,8 +10,6 @@ export class ImageShowFullscreenButton extends BaseComponent {
protected build() {
this.container.innerText = '🔍';
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
}
protected init() {
@@ -27,14 +25,14 @@ export class ImageShowFullscreenButton extends BaseComponent {
this.on('click', this.#onButtonClicked.bind(this));
if (ImageShowFullscreenButton.#miscSettings) {
ImageShowFullscreenButton.#miscSettings.resolveFullscreenViewerEnabled()
if (ImageShowFullscreenButton.#preferences) {
ImageShowFullscreenButton.#preferences.fullscreenViewer.get()
.then(isEnabled => {
this.#isFullscreenButtonEnabled = isEnabled;
this.#updateFullscreenButtonVisibility();
})
.then(() => {
ImageShowFullscreenButton.#miscSettings?.subscribe(settings => {
ImageShowFullscreenButton.#preferences?.subscribe(settings => {
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
this.#updateFullscreenButtonVisibility();
})
@@ -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

@@ -1,14 +1,14 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { MaintenancePopup } from "$content/components/MaintenancePopup";
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 { MediaBoxWrapper } from "$content/components/MediaBoxWrapper";
import type MaintenanceProfile from "$entities/MaintenanceProfile";
import type { MediaBox } from "$content/components/philomena/MediaBox";
import type TaggingProfile from "$entities/TaggingProfile";
export class MediaBoxTools extends BaseComponent {
#mediaBox: MediaBoxWrapper | null = null;
#maintenancePopup: MaintenancePopup | null = null;
#mediaBox: MediaBox | null = null;
#maintenancePopup: TaggingProfilePopup | null = null;
init() {
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
@@ -34,7 +34,7 @@ export class MediaBoxTools extends BaseComponent {
component.initialize();
}
if (!this.#maintenancePopup && component instanceof MaintenancePopup) {
if (!this.#maintenancePopup && component instanceof TaggingProfilePopup) {
this.#maintenancePopup = component;
}
}
@@ -42,33 +42,33 @@ export class MediaBoxTools extends BaseComponent {
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
}
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
#onActiveProfileChanged(profileChangedEvent: CustomEvent<TaggingProfile | null>) {
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
}
get maintenancePopup(): MaintenancePopup | null {
get maintenancePopup(): TaggingProfilePopup | null {
return this.#maintenancePopup;
}
get mediaBox(): MediaBoxWrapper | null {
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.
*/
export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement {
const mediaBoxToolsContainer = document.createElement('div');
mediaBoxToolsContainer.classList.add('media-box-tools');
/**
* 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);
if (childrenElements.length) {
mediaBoxToolsContainer.append(...childrenElements);
}
new MediaBoxTools(mediaBoxToolsContainer);
return mediaBoxToolsContainer;
}
new MediaBoxTools(mediaBoxToolsContainer);
return mediaBoxToolsContainer;
}

View File

@@ -0,0 +1,102 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import TagEditorPreset from "$entities/TagEditorPreset";
import PresetTableRow from "$content/components/extension/presets/PresetTableRow";
import { createFontAwesomeIcon } from "$lib/dom-utils";
import { sortEntitiesByField } from "$lib/utils";
export default class EditorPresetsBlock extends BaseComponent {
#presetsTable = document.createElement('table');
#presetBlocks: PresetTableRow[] = [];
#tags: Set<string> = new Set();
protected build() {
this.container.classList.add('block', 'hidden', 'tag-presets');
this.container.style.marginTop = 'var(--block-spacing)';
const header = document.createElement('div');
header.classList.add('block__header');
const headerTitle = document.createElement('div');
headerTitle.classList.add('block__header__title')
headerTitle.textContent = ' Presets';
const content = document.createElement('div');
content.classList.add('block__content');
this.#presetsTable.append(
document.createElement('thead'),
document.createElement('tbody'),
);
this.#presetsTable.tHead?.append(
EditorPresetsBlock.#createRowWithTableHeads([
'Name',
'Tags',
'Actions'
]),
);
headerTitle.prepend(createFontAwesomeIcon('layer-group'));
header.append(headerTitle);
content.append(this.#presetsTable);
this.container.append(
header,
content,
);
}
protected init() {
TagEditorPreset.readAll()
.then(this.#refreshPresets.bind(this))
.then(() => TagEditorPreset.subscribe(this.#refreshPresets.bind(this)));
}
toggleVisibility(shouldBeVisible: boolean | undefined = undefined) {
this.container.classList.toggle('hidden', shouldBeVisible);
}
updateTags(tags: Set<string>) {
this.#tags = tags;
for (const presetBlock of this.#presetBlocks) {
presetBlock.updateTags(tags);
}
}
#refreshPresets(presetsList: TagEditorPreset[]) {
if (this.#presetBlocks.length) {
for (const block of this.#presetBlocks) {
block.remove();
}
}
for (const preset of sortEntitiesByField(presetsList, "name")) {
const block = PresetTableRow.create(preset);
this.#presetsTable.tBodies[0]?.append(block.container);
block.initialize();
block.updateTags(this.#tags);
this.#presetBlocks.push(block);
}
}
static create(): EditorPresetsBlock {
return new EditorPresetsBlock(
document.createElement('div')
);
}
static #createRowWithTableHeads(columnNames: string[]): HTMLTableRowElement {
const rowElement = document.createElement('tr');
for (const columnName of columnNames) {
const columnHeadElement = document.createElement('th');
columnHeadElement.textContent = columnName;
rowElement.append(columnHeadElement);
}
return rowElement;
}
}

View File

@@ -0,0 +1,129 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import type TagEditorPreset from "$entities/TagEditorPreset";
import { emit } from "$content/components/events/comms";
import { EVENT_PRESET_TAG_CHANGE_APPLIED } from "$content/components/events/preset-block-events";
import { createFontAwesomeIcon } from "$lib/dom-utils";
export default class PresetTableRow extends BaseComponent {
#preset: TagEditorPreset;
#tagsList: HTMLElement[] = [];
#applyAllButton = document.createElement('button');
#removeAllButton = document.createElement('button');
constructor(container: HTMLElement, preset: TagEditorPreset) {
super(container);
this.#preset = preset;
}
protected build() {
this.#tagsList = this.#preset.settings.tags
.toSorted((a, b) => a.localeCompare(b))
.map(tagName => {
const tagElement = document.createElement('span');
tagElement.classList.add('tag');
tagElement.textContent = tagName;
tagElement.dataset.tagName = tagName;
return tagElement;
});
const nameCell = document.createElement('td');
nameCell.textContent = this.#preset.settings.name;
const tagsCell = document.createElement('td');
const tagsListContainer = document.createElement('div');
tagsListContainer.classList.add('tag-list');
tagsListContainer.append(...this.#tagsList);
tagsCell.append(tagsListContainer);
const actionsCell = document.createElement('td');
const actionsContainer = document.createElement('div');
actionsContainer.classList.add('flex', 'flex--gap-small');
this.#applyAllButton.classList.add('button', 'button--state-success', 'button--bold');
this.#applyAllButton.append(createFontAwesomeIcon('circle-plus'));
this.#applyAllButton.title = 'Add all tags from this preset into the editor';
this.#removeAllButton.classList.add('button', 'button--state-danger', 'button--bold');
this.#removeAllButton.append(createFontAwesomeIcon('circle-minus'));
this.#removeAllButton.title = 'Remove all tags from this preset from the editor';
actionsContainer.append(
this.#applyAllButton,
this.#removeAllButton,
);
actionsCell.append(actionsContainer);
this.container.append(
nameCell,
tagsCell,
actionsCell,
);
}
protected init() {
for (const tagElement of this.#tagsList) {
tagElement.addEventListener('click', this.#onTagClicked.bind(this));
}
this.#applyAllButton.addEventListener('click', this.#onApplyAllClicked.bind(this));
this.#removeAllButton.addEventListener('click', this.#onRemoveAllClicked.bind(this));
}
#onTagClicked(event: Event) {
const targetElement = event.currentTarget;
if (!(targetElement instanceof HTMLElement)) {
return;
}
const tagName = targetElement.dataset.tagName;
const isMissing = targetElement.classList.contains(PresetTableRow.#tagMissingClassName);
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
[isMissing ? 'addedTags' : 'removedTags']: new Set([tagName])
});
}
#onApplyAllClicked(event: Event) {
event.preventDefault();
event.stopPropagation();
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
addedTags: new Set(this.#preset.settings.tags),
});
}
#onRemoveAllClicked(event: Event) {
event.preventDefault();
event.stopPropagation();
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
removedTags: new Set(this.#preset.settings.tags),
});
}
updateTags(tags: Set<string>) {
for (const tagElement of this.#tagsList) {
tagElement.classList.toggle(
PresetTableRow.#tagMissingClassName,
!tags.has(tagElement.dataset.tagName || ''),
);
}
}
remove() {
this.container.remove();
}
static create(preset: TagEditorPreset) {
return new this(document.createElement('tr'), preset);
}
static #tagMissingClassName = 'is-missing';
}

View File

@@ -1,8 +1,8 @@
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
import MaintenanceProfile from "$entities/MaintenanceProfile";
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/booru/scraped/ScrapedAPI";
import ScrapedAPI from "$lib/philomena/scraping/ScrapedAPI";
import { tagsBlacklist } from "$config/tags";
import { emitterAt } from "$content/components/events/comms";
import {
@@ -10,8 +10,8 @@ import {
EVENT_MAINTENANCE_STATE_CHANGED,
EVENT_TAGS_UPDATED
} from "$content/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$content/components/MediaBoxTools";
import { resolveTagCategoryFromTagName } from "$lib/booru/tag-utils";
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
import { resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils";
class BlackListedTagsEncounteredError extends Error {
constructor(tagName: string) {
@@ -21,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();
@@ -66,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;
@@ -79,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();
@@ -110,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);
@@ -122,10 +122,10 @@ 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.#markTagElementWithCategory(tagElement, 'error');
TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error');
this.#suggestedInvalidTags.set(tagName, tagElement);
} else {
MaintenancePopup.#markTagElementWithCategory(
TaggingProfilePopup.#markTagElementWithCategory(
tagElement,
resolveTagCategoryFromTagName(tagName) ?? '',
);
@@ -179,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;
@@ -197,7 +197,7 @@ export class MaintenancePopup extends BaseComponent {
if (this.#isPlanningToSubmit && !this.#isSubmitting) {
this.#tagsSubmissionTimer = setTimeout(
this.#onSubmissionTimerPassed.bind(this),
MaintenancePopup.#delayBeforeSubmissionMs
TaggingProfilePopup.#delayBeforeSubmissionMs
);
}
}
@@ -214,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) {
@@ -250,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;
@@ -268,7 +268,7 @@ export class MaintenancePopup extends BaseComponent {
this.#tagsToRemove.clear();
this.#refreshTagsList();
MaintenancePopup.#notifyAboutPendingSubmission(false);
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
this.#isSubmitting = false;
}
@@ -292,8 +292,8 @@ export class MaintenancePopup extends BaseComponent {
continue;
}
const tagElement = MaintenancePopup.#buildTagElement(tagName);
MaintenancePopup.#markTagElementWithCategory(tagElement, 'error');
const tagElement = TaggingProfilePopup.#buildTagElement(tagName);
TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error');
tagElement.classList.add('is-present');
this.#suggestedInvalidTags.set(tagName, tagElement);
@@ -311,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');
@@ -333,7 +341,7 @@ export class MaintenancePopup extends BaseComponent {
/**
* 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
@@ -341,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
@@ -352,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;
@@ -416,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

@@ -2,9 +2,9 @@ import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { on } from "$content/components/events/comms";
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$content/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$content/components/MediaBoxTools";
import 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

@@ -1,7 +1,7 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import TagSettings from "$lib/extension/settings/TagSettings";
import TagsPreferences from "$lib/extension/preferences/TagsPreferences";
import { getComponent } from "$content/components/base/component-utils";
import { resolveTagNameFromLink, resolveTagCategoryFromTagName } from "$lib/booru/tag-utils";
import { resolveTagNameFromLink, resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils";
export class BlockCommunication extends BaseComponent {
#contentSection: HTMLElement | null = null;
@@ -17,8 +17,8 @@ export class BlockCommunication extends BaseComponent {
protected init() {
Promise.all([
BlockCommunication.#tagSettings.resolveReplaceLinks(),
BlockCommunication.#tagSettings.resolveReplaceLinkText(),
BlockCommunication.#preferences.replaceLinks.get(),
BlockCommunication.#preferences.replaceLinkText.get(),
]).then(([replaceLinks, replaceLinkText]) => {
this.#onReplaceLinkSettingResolved(
replaceLinks,
@@ -26,7 +26,7 @@ export class BlockCommunication extends BaseComponent {
);
});
BlockCommunication.#tagSettings.subscribe(settings => {
BlockCommunication.#preferences.subscribe(settings => {
this.#onReplaceLinkSettingResolved(
settings.replaceLinks ?? false,
settings.replaceLinkText ?? true
@@ -112,7 +112,7 @@ export class BlockCommunication extends BaseComponent {
);
}
static #tagSettings = new TagSettings();
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

View File

@@ -1,10 +1,10 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-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 MediaBoxWrapper extends BaseComponent {
export class MediaBox extends BaseComponent {
#thumbnailContainer: HTMLElement | null = null;
#imageLinkElement: HTMLAnchorElement | null = null;
#tagsAndAliases: Map<string, string> | null = null;
@@ -60,40 +60,44 @@ export class MediaBoxWrapper extends BaseComponent {
return JSON.parse(jsonUris);
}
}
/**
* Wrap the media box element into the special wrapper.
*/
export function initializeMediaBox(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
new MediaBoxWrapper(mediaBoxContainer)
.initialize();
/**
* 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();
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');
}
})
}
}
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,6 +1,6 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
import TaggingProfile from "$entities/TaggingProfile";
import TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences";
import { getComponent } from "$content/components/base/component-utils";
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
import { on } from "$content/components/events/comms";
@@ -8,9 +8,7 @@ import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-
import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdown-events";
import type TagGroup from "$entities/TagGroup";
const categoriesResolver = new CustomCategoriesResolver();
export class TagDropdownWrapper extends BaseComponent {
export class TagDropdown extends BaseComponent {
/**
* Container with dropdown elements to insert options into.
*/
@@ -29,7 +27,7 @@ export class TagDropdownWrapper extends BaseComponent {
/**
* Local clone of the currently active profile used for updating the list of tags.
*/
#activeProfile: MaintenanceProfile | null = null;
#activeProfile: TaggingProfile | null = null;
/**
* Is cursor currently entered the dropdown.
@@ -46,7 +44,7 @@ export class TagDropdownWrapper extends BaseComponent {
this.on('mouseenter', this.#onDropdownEntered.bind(this));
this.on('mouseleave', this.#onDropdownLeft.bind(this));
TagDropdownWrapper.#watchActiveProfile(activeProfileOrNull => {
TagDropdown.#watchActiveProfile(activeProfileOrNull => {
this.#activeProfile = activeProfileOrNull;
if (this.#isEntered) {
@@ -122,7 +120,7 @@ export class TagDropdownWrapper extends BaseComponent {
#updateButtons() {
if (!this.#activeProfile) {
this.#addToNewButton ??= TagDropdownWrapper.#createDropdownLink(
this.#addToNewButton ??= TagDropdown.#createDropdownLink(
'Add to new tagging profile',
this.#onAddToNewClicked.bind(this)
);
@@ -135,7 +133,7 @@ export class TagDropdownWrapper extends BaseComponent {
}
if (this.#activeProfile) {
this.#toggleOnExistingButton ??= TagDropdownWrapper.#createDropdownLink(
this.#toggleOnExistingButton ??= TagDropdown.#createDropdownLink(
'Add to existing tagging profile',
this.#onToggleInExistingClicked.bind(this)
);
@@ -172,14 +170,14 @@ export class TagDropdownWrapper extends BaseComponent {
throw new Error('Missing tag name to create the profile!');
}
const profile = new MaintenanceProfile(crypto.randomUUID(), {
const profile = new TaggingProfile(crypto.randomUUID(), {
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
tags: [this.tagName],
temporary: true,
});
await profile.save();
await TagDropdownWrapper.#maintenanceSettings.setActiveProfileId(profile.id);
await TagDropdown.#preferences.activeProfile.set(profile.id);
}
async #onToggleInExistingClicked() {
@@ -205,25 +203,25 @@ export class TagDropdownWrapper extends BaseComponent {
await this.#activeProfile.save();
}
static #maintenanceSettings = new MaintenanceSettings();
static #preferences = new TaggingProfilesPreferences();
/**
* Watch for changes to active profile.
* @param onActiveProfileChange Callback to call when profile was
* changed.
*/
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile | null) => void) {
static #watchActiveProfile(onActiveProfileChange: (profile: TaggingProfile | null) => void) {
let lastActiveProfile: string | null = null;
this.#maintenanceSettings.subscribe((settings) => {
this.#preferences.subscribe((settings) => {
lastActiveProfile = settings.activeProfile ?? null;
this.#maintenanceSettings
.resolveActiveProfileAsObject()
this.#preferences
.activeProfile.asObject()
.then(onActiveProfileChange);
});
MaintenanceProfile.subscribe(profiles => {
TaggingProfile.subscribe(profiles => {
const activeProfile = profiles
.find(profile => profile.id === lastActiveProfile);
@@ -231,8 +229,8 @@ export class TagDropdownWrapper extends BaseComponent {
);
});
this.#maintenanceSettings
.resolveActiveProfileAsObject()
this.#preferences
.activeProfile.asObject()
.then(activeProfile => {
lastActiveProfile = activeProfile?.id ?? null;
onActiveProfileChange(activeProfile);
@@ -263,58 +261,65 @@ export class TagDropdownWrapper extends BaseComponent {
return dropdownLink;
}
}
export function wrapTagDropdown(element: HTMLElement) {
// Skip initialization when tag component is already wrapped
if (getComponent(element)) {
return;
static #categoriesResolver = new CustomCategoriesResolver();
static #processedElements: WeakSet<HTMLElement> = new WeakSet();
static #findAll(parentNode: ParentNode = document): NodeListOf<HTMLElement> {
return parentNode.querySelectorAll('.tag.dropdown');
}
const tagDropdown = new TagDropdownWrapper(element);
tagDropdown.initialize();
static #initialize(element: HTMLElement) {
// Skip initialization when tag component is already wrapped
if (getComponent(element)) {
return;
}
categoriesResolver.addElement(tagDropdown);
}
const tagDropdown = new TagDropdown(element);
tagDropdown.initialize();
const processedElementsSet = new WeakSet<HTMLElement>();
export function watchTagDropdownsInTagsEditor() {
// We only need to watch for new editor elements if there is a tag editor present on the page
if (!document.querySelector('#image_tags_and_source')) {
return;
this.#categoriesResolver.addElement(tagDropdown);
}
document.body.addEventListener('mouseover', event => {
const targetElement = event.target;
static findAllAndInitialize(parentNode: ParentNode = document) {
for (const element of this.#findAll(parentNode)) {
this.#initialize(element);
}
}
if (!(targetElement instanceof HTMLElement)) {
static watch() {
// We only need to watch for new editor elements if there is a tag editor present on the page
if (!document.querySelector('#image_tags_and_source')) {
return;
}
if (processedElementsSet.has(targetElement)) {
return;
}
document.body.addEventListener('mouseover', event => {
const targetElement = event.target;
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
if (!(targetElement instanceof HTMLElement)) {
return;
}
if (!closestTagEditor || processedElementsSet.has(closestTagEditor)) {
processedElementsSet.add(targetElement);
return;
}
if (this.#processedElements.has(targetElement)) {
return;
}
processedElementsSet.add(targetElement);
processedElementsSet.add(closestTagEditor);
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
for (const tagDropdownElement of closestTagEditor.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
});
if (!closestTagEditor || this.#processedElements.has(closestTagEditor)) {
this.#processedElements.add(targetElement);
return;
}
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
for (const tagDropdownElement of event.detail.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
});
this.#processedElements.add(targetElement);
this.#processedElements.add(closestTagEditor);
this.findAllAndInitialize(closestTagEditor);
});
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
this.findAllAndInitialize(event.detail);
});
}
}

View File

@@ -0,0 +1,291 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { emit, on, type UnsubscribeFunction } from "$content/components/events/comms";
import { EVENT_FETCH_COMPLETE, EVENT_RELOAD, type ReloadCustomOptions } from "$content/components/events/booru-events";
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
import EditorPresetsBlock from "$content/components/extension/presets/EditorPresetsBlock";
import { EVENT_PRESET_TAG_CHANGE_APPLIED, type PresetTagChange } from "$content/components/events/preset-block-events";
export class TagsForm extends BaseComponent {
#togglePresetsButton: HTMLButtonElement = document.createElement('button');
#presetsList = EditorPresetsBlock.create();
#plainEditorTextarea: HTMLTextAreaElement|null = null;
#fancyEditorInput: HTMLInputElement|null = null;
#tagsSet: Set<string> = new Set();
protected build() {
this.#togglePresetsButton.classList.add(
'button',
'button--state-primary',
'button--bold',
'button--separate-left',
);
this.#togglePresetsButton.textContent = 'Presets';
this.container
.querySelector(':is(.fancy-tag-edit, .fancy-tag-upload) ~ button:last-of-type')
?.after(this.#togglePresetsButton, this.#presetsList.container);
this.#plainEditorTextarea = this.container.querySelector('textarea.tagsinput');
this.#fancyEditorInput = this.container.querySelector('.js-taginput-fancy input');
}
protected init() {
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
const unsubscribe = on(
this.container,
EVENT_FETCH_COMPLETE,
() => this.#waitAndDetectUpdatedForm(unsubscribe),
);
this.#togglePresetsButton.addEventListener('click', this.#togglePresetsList.bind(this));
this.#presetsList.initialize();
this.#plainEditorTextarea?.addEventListener('input', this.#refreshTagsListForPresets.bind(this));
this.#fancyEditorInput?.addEventListener('keydown', this.#refreshTagsListForPresets.bind(this));
this.#refreshTagsListForPresets();
on(this.#presetsList, EVENT_PRESET_TAG_CHANGE_APPLIED, this.#onTagChangeRequested.bind(this));
if (this.#plainEditorTextarea) {
// When reloaded, we should catch and refresh the colors. Extension reuses this event to force site to update
// list of tags in the fancy tag editor.
on(this.#plainEditorTextarea, EVENT_RELOAD, this.#onPlainEditorReloadRequested.bind(this));
}
}
#waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void {
const elementContainingTagEditor = this.container
.closest('#image_tags_and_source')
?.parentElement;
if (!elementContainingTagEditor) {
return;
}
const observer = new MutationObserver(() => {
const tagsFormElement = elementContainingTagEditor.querySelector<HTMLElement>('#tags-form');
if (!tagsFormElement || getComponent(tagsFormElement)) {
return;
}
const tagFormComponent = new TagsForm(tagsFormElement);
tagFormComponent.initialize();
const fullTagEditor = tagFormComponent.parentTagEditorElement;
if (fullTagEditor) {
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
} else {
console.info('Tag form is not in the tag editor. Event is not sent.');
}
observer.disconnect();
unsubscribe();
});
observer.observe(elementContainingTagEditor, {
subtree: true,
childList: true,
});
// Make sure to forcibly disconnect everything after a while.
setTimeout(() => {
observer.disconnect();
unsubscribe();
}, 5000);
}
get parentTagEditorElement(): HTMLElement | null {
return this.container.closest<HTMLElement>('.js-tagsauce')
}
/**
* Collect all the tag categories available on the page and color the tags in the editor according to them.
*/
refreshTagColors() {
const tagCategories = this.#gatherTagCategories();
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
for (const tagElement of editableTags) {
// Tag name is stored in the "remove" link and not in the tag itself.
const removeLink = tagElement.querySelector('a');
if (!removeLink) {
continue;
}
const tagName = removeLink.dataset.tagName;
if (!tagName || !tagCategories.has(tagName)) {
continue;
}
const categoryName = tagCategories.get(tagName)!;
tagElement.dataset.tagCategory = categoryName;
tagElement.setAttribute('data-tag-category', categoryName);
}
}
/**
* Collect list of categories from the tags on the page.
* @return
*/
#gatherTagCategories(): Map<string, string> {
const tagCategories: Map<string, string> = new Map();
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
const tagName = tagElement.dataset.tagName;
const tagCategory = tagElement.dataset.tagCategory;
if (!tagName || !tagCategory) {
console.warn('Missing tag name or category!');
continue;
}
tagCategories.set(tagName, tagCategory);
}
return tagCategories;
}
#togglePresetsList(event: Event) {
event.stopPropagation();
event.preventDefault();
this.#presetsList.toggleVisibility();
this.#refreshTagsListForPresets();
}
#refreshTagsListForPresets() {
this.#tagsSet = new Set(
this.#plainEditorTextarea?.value
.split(',')
.map(tagName => tagName.trim())
);
this.#presetsList.updateTags(this.#tagsSet);
}
#onTagChangeRequested(event: CustomEvent<PresetTagChange>) {
const { addedTags = null, removedTags = null } = event.detail;
let tagChangeList: string[] = [];
if (addedTags) {
tagChangeList.push(...addedTags);
}
if (removedTags) {
tagChangeList.push(
...Array.from(removedTags)
.filter(tagName => this.#tagsSet.has(tagName))
.map(tagName => `-${tagName}`)
);
}
const offsetBeforeSubmission = this.#presetsList.container.offsetTop;
this.#applyTagChangesWithFancyTagEditor(
tagChangeList.join(',')
);
const offsetDifference = this.#presetsList.container.offsetTop - offsetBeforeSubmission;
// Compensating for the layout shift: when user clicks on a tag (or on "add/remove all tags"), tag editor might
// overflow the current line and wrap tags around to the next line, causing presets section to shift. We need to
// avoid that for better UX.
if (offsetDifference !== 0) {
window.scrollTo({
top: window.scrollY + offsetDifference,
behavior: 'instant',
});
}
}
#applyTagChangesWithFancyTagEditor(tagsListWithChanges: string): void {
if (!this.#fancyEditorInput || !this.#plainEditorTextarea) {
return;
}
const originalValue = this.#fancyEditorInput.value;
// We have to tell plain text editor to also refresh the list of tags in the fancy editor, just in case user
// made changes to it in plain mode.
emit(this.#plainEditorTextarea, EVENT_RELOAD, {
// Sending that we don't need to refresh the color on this event, since we will do that ourselves later, after
// changes are applied.
skipTagColorRefresh: true,
skipTagRefresh: true,
});
this.#fancyEditorInput.value = tagsListWithChanges;
this.#fancyEditorInput.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Comma',
}));
this.#fancyEditorInput.value = originalValue;
this.refreshTagColors();
}
#onPlainEditorReloadRequested(event: CustomEvent<ReloadCustomOptions|null>) {
if (!event.detail?.skipTagColorRefresh) {
this.refreshTagColors();
}
if (!event.detail?.skipTagRefresh) {
this.#refreshTagsListForPresets();
}
}
static watchForEditors() {
document.body.addEventListener('click', event => {
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) {
return;
}
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
if (!tagEditorWrapper) {
return;
}
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
if (!refreshTrigger) {
return;
}
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
if (!tagFormElement) {
return;
}
let tagEditor = getComponent(tagFormElement);
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
tagEditor = new TagsForm(tagFormElement);
tagEditor.initialize();
}
(tagEditor as TagsForm).refreshTagColors();
});
}
static initializeUploadEditor() {
const uploadEditorContainer = document.querySelector<HTMLElement>('.field:has(.fancy-tag-upload)');
if (!uploadEditorContainer) {
return;
}
new TagsForm(uploadEditorContainer).initialize();
}
}

View File

@@ -1,11 +1,11 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import type TagGroup from "$entities/TagGroup";
import type { TagDropdownWrapper } from "$content/components/TagDropdownWrapper";
import type { TagDropdown } from "$content/components/philomena/TagDropdown";
import { on } from "$content/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
import { getComponent } from "$content/components/base/component-utils";
import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdown-events";
import TagSettings from "$lib/extension/settings/TagSettings";
import TagsPreferences from "$lib/extension/preferences/TagsPreferences";
export class TagsListBlock extends BaseComponent {
#tagsListButtonsContainer: HTMLElement | null = null;
@@ -14,14 +14,14 @@ export class TagsListBlock extends BaseComponent {
#toggleGroupingButton = document.createElement('a');
#toggleGroupingButtonIcon = document.createElement('i');
#tagSettings = new TagSettings();
#preferences = new TagsPreferences();
#shouldDisplaySeparation = false;
#separatedGroups = new Map<string, TagGroup>();
#separatedHeaders = new Map<string, HTMLElement>();
#groupsCount = new Map<string, number>();
#lastTagGroup = new WeakMap<TagDropdownWrapper, TagGroup | null>;
#lastTagGroup = new WeakMap<TagDropdown, TagGroup | null>;
#isReorderingPlanned = false;
@@ -44,8 +44,8 @@ export class TagsListBlock extends BaseComponent {
}
init() {
this.#tagSettings.resolveGroupSeparation().then(this.#onTagSeparationChange.bind(this));
this.#tagSettings.subscribe(settings => {
this.#preferences.groupSeparation.get().then(this.#onTagSeparationChange.bind(this));
this.#preferences.subscribe(settings => {
this.#onTagSeparationChange(Boolean(settings.groupSeparation))
});
@@ -80,7 +80,7 @@ export class TagsListBlock extends BaseComponent {
return;
}
const tagDropdown = getComponent<TagDropdownWrapper>(maybeDropdownElement);
const tagDropdown = getComponent<TagDropdown>(maybeDropdownElement);
if (!tagDropdown) {
return;
@@ -103,7 +103,7 @@ export class TagsListBlock extends BaseComponent {
#onToggleGroupingClicked(event: Event) {
event.preventDefault();
void this.#tagSettings.setGroupSeparation(!this.#shouldDisplaySeparation);
void this.#preferences.groupSeparation.set(!this.#shouldDisplaySeparation);
}
#handleTagGroupChanges(tagGroup: TagGroup) {
@@ -146,7 +146,7 @@ export class TagsListBlock extends BaseComponent {
heading.innerText = group.settings.name;
}
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdownWrapper) {
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdown) {
const previousGroupId = this.#lastTagGroup.get(tagComponent)?.id;
const currentGroupId = resolvedGroup?.id;
const isDifferentId = currentGroupId !== previousGroupId;
@@ -217,28 +217,28 @@ export class TagsListBlock extends BaseComponent {
static #iconGroupingDisabled = 'fa-folder';
static #iconGroupingEnabled = 'fa-folder-tree';
}
export function initializeAllTagsLists() {
for (let element of document.querySelectorAll<HTMLElement>('#image_tags_and_source')) {
if (getComponent(element)) {
return;
static initializeAll() {
for (let element of document.querySelectorAll<HTMLElement>('#image_tags_and_source')) {
if (getComponent(element)) {
return;
}
new TagsListBlock(element)
.initialize();
}
}
new TagsListBlock(element)
.initialize();
static watchUpdatedLists() {
on(document, EVENT_FORM_EDITOR_UPDATED, event => {
const tagsListElement = event.detail.closest<HTMLElement>('#image_tags_and_source');
if (!tagsListElement || getComponent(tagsListElement)) {
return;
}
new TagsListBlock(tagsListElement)
.initialize();
})
}
}
export function watchForUpdatedTagLists() {
on(document, EVENT_FORM_EDITOR_UPDATED, event => {
const tagsListElement = event.detail.closest<HTMLElement>('#image_tags_and_source');
if (!tagsListElement || getComponent(tagsListElement)) {
return;
}
new TagsListBlock(tagsListElement)
.initialize();
});
}

View File

@@ -1,5 +1,5 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { ImageListInfo } from "$content/components/listing/ImageListInfo";
import { ImageListInfo } from "$content/components/philomena/listing/ImageListInfo";
export class ImageListContainer extends BaseComponent {
#info: ImageListInfo | null = null;
@@ -12,8 +12,12 @@ export class ImageListContainer extends BaseComponent {
this.#info.initialize();
}
}
}
export function initializeImageListContainer(element: HTMLElement) {
new ImageListContainer(element).initialize();
static findAndInitialize() {
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
if (imageListContainer) {
new ImageListContainer(imageListContainer).initialize();
}
}
}

View File

@@ -1,19 +1,18 @@
import { createMaintenancePopup } from "$content/components/MaintenancePopup";
import { createMediaBoxTools } from "$content/components/MediaBoxTools";
import { calculateMediaBoxesPositions, initializeMediaBox } from "$content/components/MediaBoxWrapper";
import { createMaintenanceStatusIcon } from "$content/components/MaintenanceStatusIcon";
import { createImageShowFullscreenButton } from "$content/components/ImageShowFullscreenButton";
import { initializeImageListContainer } from "$content/components/listing/ImageListContainer";
import { TaggingProfilePopup } from "$content/components/extension/profiles/TaggingProfilePopup";
import { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
import { MediaBox } from "$content/components/philomena/MediaBox";
import { TaggingProfileStatusIcon } from "$content/components/extension/profiles/TaggingProfileStatusIcon";
import { ImageShowFullscreenButton } from "$content/components/extension/ImageShowFullscreenButton";
import { ImageListContainer } from "$content/components/philomena/listing/ImageListContainer";
const mediaBoxes = document.querySelectorAll<HTMLElement>('.media-box');
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
const mediaBoxes = MediaBox.findElements();
mediaBoxes.forEach(mediaBoxElement => {
initializeMediaBox(mediaBoxElement, [
createMediaBoxTools(
createMaintenancePopup(),
createMaintenanceStatusIcon(),
createImageShowFullscreenButton(),
MediaBox.initialize(mediaBoxElement, [
MediaBoxTools.create(
TaggingProfilePopup.create(),
TaggingProfileStatusIcon.create(),
ImageShowFullscreenButton.create(),
)
]);
@@ -23,8 +22,5 @@ mediaBoxes.forEach(mediaBoxElement => {
})
});
calculateMediaBoxesPositions(mediaBoxes);
if (imageListContainer) {
initializeImageListContainer(imageListContainer);
}
MediaBox.initializePositionCalculation(mediaBoxes);
ImageListContainer.findAndInitialize();

View File

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

View File

@@ -1,6 +1,6 @@
import { TagsForm } from "$content/components/TagsForm";
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$content/components/TagsListBlock";
import { TagsForm } from "$content/components/philomena/TagsForm";
import { TagsListBlock } from "$content/components/philomena/TagsListBlock";
initializeAllTagsLists();
watchForUpdatedTagLists();
TagsListBlock.initializeAll();
TagsListBlock.watchUpdatedLists();
TagsForm.watchForEditors();

View File

@@ -1,7 +1,4 @@
import { watchTagDropdownsInTagsEditor, wrapTagDropdown } from "$content/components/TagDropdownWrapper";
import { TagDropdown } from "$content/components/philomena/TagDropdown";
for (let tagDropdownElement of document.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
watchTagDropdownsInTagsEditor();
TagDropdown.findAllAndInitialize();
TagDropdown.watch();

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

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

11
src/lib/dom-utils.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* Reusable function to create icons from FontAwesome. Usable only for website, since extension doesn't host its own
* copy of FA styles. Extension should use imports of SVGs inside CSS instead.
* @param iconSlug Slug of the icon to be added.
* @return Element with classes for FontAwesome icon added.
*/
export function createFontAwesomeIcon(iconSlug: string): HTMLElement {
const iconElement = document.createElement('i');
iconElement.classList.add('fa-solid', `fa-${iconSlug}`);
return iconElement;
}

View File

@@ -2,8 +2,9 @@ import type StorageEntity from "$lib/extension/base/StorageEntity";
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
import type { ImportableElementsList, ImportableEntityObject } from "$lib/extension/transporting/importables";
import EntitiesTransporter, { type SameSiteStatus } from "$lib/extension/EntitiesTransporter";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import TaggingProfile from "$entities/TaggingProfile";
import TagGroup from "$entities/TagGroup";
import TagEditorPreset from "$entities/TagEditorPreset";
type TransportersMapping = {
[EntityName in keyof App.EntityNamesMap]: EntitiesTransporter<App.EntityNamesMap[EntityName]>;
@@ -73,10 +74,12 @@ export default class BulkEntitiesTransporter {
elements: entities
.map(entity => {
switch (true) {
case entity instanceof MaintenanceProfile:
case entity instanceof TaggingProfile:
return BulkEntitiesTransporter.#transporters.profiles.exportToObject(entity);
case entity instanceof TagGroup:
return BulkEntitiesTransporter.#transporters.groups.exportToObject(entity);
case entity instanceof TagEditorPreset:
return BulkEntitiesTransporter.#transporters.presets.exportToObject(entity);
}
return null;
@@ -99,8 +102,9 @@ export default class BulkEntitiesTransporter {
}
static #transporters: TransportersMapping = {
profiles: new EntitiesTransporter(MaintenanceProfile),
profiles: new EntitiesTransporter(TaggingProfile),
groups: new EntitiesTransporter(TagGroup),
presets: new EntitiesTransporter(TagEditorPreset),
}
/**

View File

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

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

@@ -0,0 +1,17 @@
import StorageEntity from "$lib/extension/base/StorageEntity";
interface TagEditorPresetSettings {
name: string;
tags: string[];
}
export default class TagEditorPreset extends StorageEntity<TagEditorPresetSettings> {
constructor(id: string, settings: Partial<TagEditorPresetSettings>) {
super(id, {
name: settings.name || '',
tags: settings.tags || [],
});
}
public static readonly _entityName = 'presets';
}

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,37 +0,0 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
interface TagSettingsFields {
groupSeparation: boolean;
replaceLinks: boolean;
replaceLinkText: boolean;
}
export default class TagSettings extends CacheableSettings<TagSettingsFields> {
constructor() {
super("tag");
}
async resolveGroupSeparation() {
return this._resolveSetting("groupSeparation", true);
}
async resolveReplaceLinks() {
return this._resolveSetting("replaceLinks", false);
}
async resolveReplaceLinkText() {
return this._resolveSetting("replaceLinkText", true);
}
async setGroupSeparation(value: boolean) {
return this._writeSetting("groupSeparation", Boolean(value));
}
async setReplaceLinks(value: boolean) {
return this._writeSetting("replaceLinks", Boolean(value));
}
async setReplaceLinkText(value: boolean) {
return this._writeSetting("replaceLinkText", Boolean(value));
}
}

View File

@@ -33,6 +33,16 @@ const entitiesExporters: ExportersMap = {
category: entity.settings.category,
separate: entity.settings.separate,
}
},
presets: entity => {
return {
$type: "presets",
$site: __CURRENT_SITE__,
v: 1,
id: entity.id,
name: entity.settings.name,
tags: entity.settings.tags,
}
}
};

View File

@@ -64,6 +64,19 @@ const entitiesValidators: EntitiesValidationMap = {
throw new Error('Invalid group format detected!');
}
},
presets: importedObject => {
if (!importedObject.v || importedObject.v > 1) {
throw new Error('Unsupported preset version!');
}
if (
!validateRequiredString(importedObject?.id)
|| !validateRequiredString(importedObject?.name)
|| !validateOptionalArray(importedObject?.tags)
) {
throw new Error('Invalid preset format detected!');
}
}
};
/**

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

@@ -1,5 +1,5 @@
import { namespaceCategories } from "$config/tags";
import { QueryLexer, QuotedTermToken, TermToken } from "$lib/booru/search/QueryLexer";
import { QueryLexer, QuotedTermToken, TermToken } from "$lib/philomena/search/QueryLexer";
/**
* Build the map containing both real tags and their aliases.

View File

@@ -1,3 +1,6 @@
import type StorageEntity from "$lib/extension/base/StorageEntity";
import type TagGroup from "$entities/TagGroup";
/**
* Traverse and find the object using the key path.
* @param targetObject Target object to traverse into.
@@ -39,3 +42,14 @@ export function escapeRegExp(value: string): string {
unsafeRegExpCharacters.lastIndex = 0;
return value.replace(unsafeRegExpCharacters, "\\$&");
}
type OnlyStringFields<Fields extends Record<string, any>> = {
[FieldKey in keyof Fields as Fields[FieldKey] extends string ? FieldKey : never]: string;
};
export function sortEntitiesByField<Fields extends Object>(entities: StorageEntity<Fields>[], fieldName: keyof OnlyStringFields<Fields>) {
return entities.toSorted(
(a, b) => (a.settings[fieldName] as string)
.localeCompare(b.settings[fieldName] as string)
);
}

View File

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

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

@@ -0,0 +1,19 @@
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
import { sortEntitiesByField } from "$lib/utils";
let presets = $derived(sortEntitiesByField($tagEditorPresets, "name"))
</script>
<Menu>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/presets/new/edit" icon="plus">Create New</MenuItem>
{#if presets.length}
<hr>
{#each presets as preset}
<MenuItem href="/features/presets/{preset.id}">{preset.settings.name}</MenuItem>
{/each}
{/if}
</Menu>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { page } from "$app/state";
import TagEditorPreset from "$entities/TagEditorPreset";
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
import { goto } from "$app/navigation";
import { popupTitle } from "$stores/popup";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import PresetView from "$components/features/PresetView.svelte";
let presetId = $derived(page.params.id);
let preset = $derived<TagEditorPreset|null>(
$tagEditorPresets.find(preset => preset.id === presetId) || null
);
$effect(() => {
if (presetId === 'new') {
goto(`/features/presets/new/edit`);
return;
}
if (!preset) {
console.warn(`Preset ${presetId} not found.`);
goto('/features/presets');
} else {
$popupTitle = `Preset: ${preset.settings.name}`;
}
});
</script>
<Menu>
<MenuItem href="/features/presets" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if preset}
<PresetView {preset}></PresetView>
{/if}
<Menu>
<hr>
<MenuItem href="/features/presets/{presetId}/edit" icon="wrench">Edit Preset</MenuItem>
<MenuItem href="/features/presets/{presetId}/delete" icon="trash">Delete Preset</MenuItem>
</Menu>

View File

@@ -0,0 +1,49 @@
<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 TagEditorPreset from "$entities/TagEditorPreset";
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
import { goto } from "$app/navigation";
import { popupTitle } from "$stores/popup";
const presetId = $derived(page.params.id);
const targetPreset = $derived<TagEditorPreset | null>(
$tagEditorPresets.find(preset => preset.id === presetId) || null
);
$effect(() => {
if (!targetPreset) {
goto('/features/presets');
} else {
$popupTitle = `Deleting Preset: ${targetPreset.settings.name}`
}
});
async function deletePreset() {
if (!targetPreset) {
console.warn('Attempting to delete the preset, but the preset is not loaded yet.');
return;
}
await targetPreset.delete();
await goto('/features/presets');
}
</script>
<Menu>
<MenuItem href="/features/presets/{presetId}" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if targetPreset}
<p>
Do you want to remove preset "{targetPreset.settings.name}"? This action is irreversible.
</p>
<Menu>
<hr>
<MenuItem onclick={deletePreset}>Yes</MenuItem>
<MenuItem href="/features/presets/{presetId}">No</MenuItem>
</Menu>
{:else}
<p>Loading...</p>
{/if}

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import { page } from "$app/state";
import TagEditorPreset from "$entities/TagEditorPreset";
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
import { popupTitle } from "$stores/popup";
import { goto } from "$app/navigation";
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 TextField from "$components/ui/forms/TextField.svelte";
import TagsEditor from "$components/tags/TagsEditor.svelte";
let presetId = $derived(page.params.id);
let targetPreset = $derived.by<TagEditorPreset | null>(() => {
if (presetId === 'new') {
return new TagEditorPreset(crypto.randomUUID(), {});
}
return $tagEditorPresets.find(preset => preset.id === presetId) || null;
});
let presetName = $state('');
let tagsList = $state<string[]>([]);
$effect(() => {
if (presetId === 'new') {
$popupTitle = 'Create New Preset';
return;
}
if (!targetPreset) {
goto('/features/presets');
return;
}
$popupTitle = `Edit Tagging Preset: ${targetPreset.settings.name}}`;
presetName = targetPreset.settings.name;
tagsList = [...targetPreset.settings.tags].sort((a, b) => a.localeCompare(b));
});
async function savePreset() {
if (!targetPreset) {
console.warn('Attempting to save the preset, but the preset is not loaded yet.');
return;
}
targetPreset.settings.name = presetName;
targetPreset.settings.tags = [...tagsList];
await targetPreset.save();
await goto(`/features/presets/${targetPreset.id}`);
}
</script>
<Menu>
<MenuItem href="/features/presets{presetId === 'new' ? '' : '/' + presetId}" icon="arrow-left">
Back
</MenuItem>
</Menu>
<FormContainer>
<FormControl label="Preset Name">
<TextField bind:value={presetName} placeholder="Preset Name"></TextField>
</FormControl>
<FormControl label="Tags">
<TagsEditor bind:tags={tagsList}></TagsEditor>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem href="#" onclick={savePreset}>Save Preset</MenuItem>
</Menu>

View File

@@ -2,45 +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";
$popupTitle = 'Tagging Profiles';
let profiles = $derived<MaintenanceProfile[]>(
$maintenanceProfiles.sort((a, b) => a.settings.name.localeCompare(b.settings.name))
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

@@ -3,26 +3,26 @@
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 { activeTaggingProfile, taggingProfiles } from "$stores/entities/tagging-profiles";
import ProfileView from "$components/features/ProfileView.svelte";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import TaggingProfile from "$entities/TaggingProfile";
import { popupTitle } from "$stores/popup";
let profileId = $derived(page.params.id);
let profile = $derived<MaintenanceProfile|null>(
$maintenanceProfiles.find(profile => profile.id === profileId) || null
let profile = $derived<TaggingProfile|null>(
$taggingProfiles.find(profile => profile.id === profileId) || null
);
$effect(() => {
if (profileId === 'new') {
goto('/features/maintenance/new/edit');
goto('/features/profiles/new/edit');
return;
}
if (!profile) {
console.warn(`Profile ${profileId} not found.`);
goto('/features/maintenance');
goto('/features/profiles');
} else {
$popupTitle = `Tagging Profile: ${profile.settings.name}`;
}
@@ -31,22 +31,22 @@
let isActiveProfile = $state(false);
$effect.pre(() => {
isActiveProfile = $activeProfileStore === profileId;
isActiveProfile = $activeTaggingProfile === profileId;
});
$effect(() => {
if (isActiveProfile && $activeProfileStore !== profileId) {
$activeProfileStore = profileId;
if (isActiveProfile && $activeTaggingProfile !== profileId) {
$activeTaggingProfile = profileId;
}
if (!isActiveProfile && $activeProfileStore === profileId) {
$activeProfileStore = null;
if (!isActiveProfile && $activeTaggingProfile === profileId) {
$activeTaggingProfile = null;
}
});
</script>
<Menu>
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/profiles" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if profile}
@@ -54,14 +54,14 @@
{/if}
<Menu>
<hr>
<MenuItem href="/features/maintenance/{profileId}/edit" icon="wrench">Edit Profile</MenuItem>
<MenuItem href="/features/profiles/{profileId}/edit" icon="wrench">Edit Profile</MenuItem>
<MenuCheckboxItem bind:checked={isActiveProfile}>
Activate Profile
</MenuCheckboxItem>
<MenuItem href="/features/maintenance/{profileId}/export" icon="file-export">
<MenuItem href="/features/profiles/{profileId}/export" icon="file-export">
Export Profile
</MenuItem>
<MenuItem href="/features/maintenance/{profileId}/delete" icon="trash">
<MenuItem href="/features/profiles/{profileId}/delete" icon="trash">
Delete Profile
</MenuItem>
</Menu>

View File

@@ -3,18 +3,18 @@
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}`
}
@@ -27,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}
@@ -42,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,19 +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('');
@@ -32,7 +32,7 @@
}
if (!targetProfile) {
goto('/features/maintenance');
goto('/features/profiles');
return;
}
@@ -53,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,31 +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) : '');
@@ -33,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,22 +2,22 @@
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
@@ -49,7 +49,7 @@
}
if (candidateProfile) {
existingProfile = $maintenanceProfiles.find(profile => profile.id === candidateProfile?.id) ?? null;
existingProfile = $taggingProfiles.find(profile => profile.id === candidateProfile?.id) ?? null;
}
}
@@ -59,7 +59,7 @@
}
candidateProfile.save().then(() => {
goto(`/features/maintenance`);
goto(`/features/profiles`);
});
}
@@ -68,16 +68,16 @@
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}

View File

@@ -4,12 +4,12 @@
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 { stripBlacklistedTagsEnabled } from "$stores/preferences/profiles";
import {
shouldReplaceLinksOnForumPosts,
shouldReplaceTextOfTagLinks,
shouldSeparateTagGroups
} from "$stores/preferences/tag";
} from "$stores/preferences/tags";
import { popupTitle } from "$stores/popup";
$popupTitle = 'Tagging Preferences';

View File

@@ -1,7 +1,7 @@
<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";
@@ -9,11 +9,13 @@
import FormControl from "$components/ui/forms/FormControl.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import { popupTitle } from "$stores/popup";
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
const bulkTransporter = new BulkEntitiesTransporter();
let exportAllProfiles = $state(false);
let exportAllGroups = $state(false);
let exportAllPresets = $state(false);
let displayExportedString = $state(false);
let shouldUseCompressed = $state(true);
@@ -24,13 +26,14 @@
const exportedEntities: Record<keyof App.EntityNamesMap, Record<string, boolean>> = $state({
profiles: {},
groups: {},
presets: {},
});
$effect(() => {
if (displayExportedString) {
const elementsToExport: StorageEntity[] = [];
$maintenanceProfiles.forEach(profile => {
$taggingProfiles.forEach(profile => {
if (exportedEntities.profiles[profile.id]) {
elementsToExport.push(profile);
}
@@ -42,6 +45,12 @@
}
});
$tagEditorPresets.forEach(preset => {
if (exportedEntities.presets[preset.id]) {
elementsToExport.push(preset);
}
});
plainExport = bulkTransporter.exportToJSON(elementsToExport);
compressedExport = bulkTransporter.exportToCompressedJSON(elementsToExport);
}
@@ -55,8 +64,9 @@
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]);
exportAllPresets = $tagEditorPresets.every(preset => exportedEntities.presets[preset.id]);
});
}
@@ -69,11 +79,14 @@
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);
break;
case "presets":
$tagEditorPresets.forEach(preset => exportedEntities.presets[preset.id] = exportAllPresets);
break;
default:
console.warn(`Trying to toggle unsupported entity type: ${targetEntity}`);
}
@@ -94,11 +107,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>
@@ -116,6 +129,17 @@
{/each}
<hr>
{/if}
{#if $tagEditorPresets.length}
<MenuCheckboxItem bind:checked={exportAllPresets} oninput={createToggleAllOnUserInput('presets')}>
Export All Presets
</MenuCheckboxItem>
{#each $tagEditorPresets as preset}
<MenuCheckboxItem bind:checked={exportedEntities.presets[preset.id]} oninput={refreshAreAllEntitiesChecked}>
Preset: {preset.settings.name}
</MenuCheckboxItem>
{/each}
<hr>
{/if}
<MenuItem icon="file-export" onclick={toggleExportedStringDisplay}>Export Selected</MenuItem>
</Menu>
{:else}

View File

@@ -3,11 +3,11 @@
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";
@@ -16,30 +16,35 @@
import type { SameSiteStatus } from "$lib/extension/EntitiesTransporter";
import { popupTitle } from "$stores/popup";
import Notice from "$components/ui/Notice.svelte";
import TagEditorPreset from "$entities/TagEditorPreset";
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
let importedString = $state('');
let errorMessage = $state('');
let importedProfiles = $state<MaintenanceProfile[]>([]);
let importedProfiles = $state<TaggingProfile[]>([]);
let importedGroups = $state<TagGroup[]>([]);
let importedPresets = $state<TagEditorPreset[]>([]);
let saveAllProfiles = $state(false);
let saveAllGroups = $state(false);
let saveAllPresets = $state(false);
let isSaving = $state(false);
let selectedEntities: Record<keyof App.EntityNamesMap, Record<string, boolean>> = $state({
profiles: {},
groups: {},
presets: {},
});
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,8 +54,15 @@
}, new Map<string, TagGroup>())
);
const existingPresetsMap = $derived(
$tagEditorPresets.reduce((map, preset) => {
map.set(preset.id, preset);
return map;
}, new Map<string, TagEditorPreset>())
);
const hasImportedEntities = $derived(
Boolean(importedProfiles.length || importedGroups.length)
Boolean(importedProfiles.length || importedGroups.length || importedPresets.length)
);
$effect(() => {
@@ -70,6 +82,7 @@
function tryBulkImport() {
importedProfiles = [];
importedGroups = [];
importedPresets = [];
errorMessage = '';
importedString = importedString.trim();
@@ -98,11 +111,14 @@
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);
break;
case "presets":
importedPresets.push(targetImportedEntity as TagEditorPreset);
break;
default:
console.warn(`Unprocessed entity type detected: ${targetImportedEntity.type}`, targetImportedEntity);
}
@@ -115,12 +131,14 @@
function cancelImport() {
importedProfiles = [];
importedGroups = [];
importedPresets = [];
}
function refreshAreAllEntitiesChecked() {
requestAnimationFrame(() => {
saveAllProfiles = importedProfiles.every(profile => selectedEntities.profiles[profile.id]);
saveAllGroups = importedGroups.every(group => selectedEntities.groups[group.id]);
saveAllPresets = importedPresets.every(preset => selectedEntities.presets[preset.id]);
});
}
@@ -134,6 +152,9 @@
case "groups":
importedGroups.forEach(group => selectedEntities.groups[group.id] = saveAllGroups);
break;
case "presets":
importedPresets.forEach(preset => selectedEntities.presets[preset.id] = saveAllPresets);
break;
default:
console.warn(`Trying to toggle unsupported entity type: ${entityType}`);
}
@@ -171,6 +192,14 @@
await group.save();
}
for (const preset of importedPresets) {
if (!selectedEntities.presets[preset.id]) {
continue;
}
await preset.save();
}
await goto("/transporting");
}
</script>
@@ -202,7 +231,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>
@@ -251,10 +280,7 @@
{/if}
{#if importedGroups.length}
<hr>
<MenuCheckboxItem
bind:checked={saveAllGroups}
oninput={createToggleAllOnUserInput('groups')}
>
<MenuCheckboxItem bind:checked={saveAllGroups} oninput={createToggleAllOnUserInput('groups')}>
Import All Groups
</MenuCheckboxItem>
{#each importedGroups as candidateGroup}
@@ -272,6 +298,26 @@
</MenuCheckboxItem>
{/each}
{/if}
{#if importedPresets.length}
<hr>
<MenuCheckboxItem bind:checked={saveAllPresets} oninput={createToggleAllOnUserInput('presets')}>
Import All Presets
</MenuCheckboxItem>
{#each importedPresets as candidatePreset}
<MenuCheckboxItem
bind:checked={selectedEntities.presets[candidatePreset.id]}
oninput={refreshAreAllEntitiesChecked}
onitemclick={createShowPreviewForEntity(candidatePreset)}
>
{#if existingPresetsMap.has(candidatePreset.id)}
Update:
{:else}
New:
{/if}
{candidatePreset.settings.name || 'Unnamed Preset'}
</MenuCheckboxItem>
{/each}
{/if}
<hr>
<MenuItem onclick={saveSelectedEntities}>
Imported Selected

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,11 @@
import { type Writable, writable } from "svelte/store";
import TagEditorPreset from "$entities/TagEditorPreset";
export const tagEditorPresets: Writable<TagEditorPreset[]> = writable([]);
TagEditorPreset
.readAll()
.then(presets => tagEditorPresets.set(presets))
.then(() => {
TagEditorPreset.subscribe(presets => tagEditorPresets.set(presets))
});

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

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,7 +1,7 @@
import { writable } from "svelte/store";
import TagSettings from "$lib/extension/settings/TagSettings";
import TagsPreferences from "$lib/extension/preferences/TagsPreferences";
const tagSettings = new TagSettings();
const preferences = new TagsPreferences();
export const shouldSeparateTagGroups = writable(false);
export const shouldReplaceLinksOnForumPosts = writable(false);
@@ -9,24 +9,24 @@ export const shouldReplaceTextOfTagLinks = writable(true);
Promise
.allSettled([
tagSettings.resolveGroupSeparation().then(value => shouldSeparateTagGroups.set(value)),
tagSettings.resolveReplaceLinks().then(value => shouldReplaceLinksOnForumPosts.set(value)),
tagSettings.resolveReplaceLinkText().then(value => shouldReplaceTextOfTagLinks.set(value)),
preferences.groupSeparation.get().then(value => shouldSeparateTagGroups.set(value)),
preferences.replaceLinks.get().then(value => shouldReplaceLinksOnForumPosts.set(value)),
preferences.replaceLinkText.get().then(value => shouldReplaceTextOfTagLinks.set(value)),
])
.then(() => {
shouldSeparateTagGroups.subscribe(value => {
void tagSettings.setGroupSeparation(value);
void preferences.groupSeparation.set(value);
});
shouldReplaceLinksOnForumPosts.subscribe(value => {
void tagSettings.setReplaceLinks(value);
void preferences.replaceLinks.set(value);
});
shouldReplaceTextOfTagLinks.subscribe(value => {
void tagSettings.setReplaceLinkText(value);
void preferences.replaceLinkText.set(value);
});
tagSettings.subscribe(settings => {
preferences.subscribe(settings => {
shouldSeparateTagGroups.set(Boolean(settings.groupSeparation));
shouldReplaceLinksOnForumPosts.set(Boolean(settings.replaceLinks));
shouldReplaceTextOfTagLinks.set(Boolean(settings.replaceLinkText));

View File

@@ -0,0 +1,16 @@
@use '$styles/booru-vars';
.block.tag-presets {
.tag {
cursor: pointer;
&.is-missing {
opacity: 0.5;
}
&:hover {
color: booru-vars.$resolved-tag-background;
background: booru-vars.$resolved-tag-color;
}
}
}

View File

@@ -1,76 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="132.29mm" height="132.29mm" version="1.1" viewBox="0 0 132.29 132.29" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(471.71 80.22)">
<g transform="translate(.53454 1.7373)">
<path d="m-360.22-74.459c-28.032 5.3761-64.196 11.821-92.316 21.773-22.49 7.9602-19.605 30.849 5.0539 26.858 30-4.8548 67.281-10.699 95.353-14.616-0.64545-11.834-2.5854-23.356-8.0905-34.016zm-96.954 29.841c3.9562-0.74441 7.7233 1.6284 8.4142 5.2999 0.69043 3.6716-1.9562 7.2513-5.9124 7.9957-3.9562 0.7444-7.7232-1.6285-8.4141-5.2999-0.69042-3.6715 1.9562-7.2512 5.9123-7.9957z" fill="#113456" style="paint-order:normal"/>
<path d="m-354.11-76.234c-6.1145 1.1727-18.326 4.0969-24.43 5.2807l7.3928 33.203c6.3875-0.9175 18.616-2.3159 24.909-3.1941-0.64547-11.834-2.3666-24.63-7.8717-35.29z" fill="#275a8d" style="paint-order:normal"/>
<g transform="matrix(1.1047 -.26755 .26755 1.1047 -295.73 5.0268)" fill="#418dd9" stroke-width="1px" aria-label="+ i really">
<path d="m-109.59-68.714c-0.089 1.3875-2.4458-0.1706-2.4103 1.0204 0.36948 0.92893-0.61602 2.6504-1.1951 1.1347 0.0168-0.93201 0.24139-2.0611-1.1621-1.5039-1.6749 0.12542-0.90266-1.8258 0.45486-1.2068 1.4742 0.43966-0.0647-2.8816 1.747-1.9823 0.14653 1.24-0.13245 2.5429 1.722 1.9166 0.36032 0.02 0.86116 0.16781 0.84361 0.62124z"/>
<path d="m-102.78-72.896c-2.2543-0.55533 1.0522-2.7623 0.72678-0.48004-0.12625 0.27727-0.413 0.49201-0.72678 0.48004zm0.30213 5.4517c0.0582 0.94586 0.41317 4.0299-1.2236 2.6788-0.17956-2.0272-0.0573-4.0762 0.0872-6.1012 2.0744-1.5718 0.95718 2.4924 1.1364 3.4224z"/>
<path d="m-90.867-69.358c-0.35082 1.7588-1.6949 0.24631-1.2891-0.85938-1.4916 0.05506-2.992 1.3472-2.6886 2.9103-0.07892 1.057 0.19116 2.1931-0.15638 3.1909-1.3457 0.70152-1.0836-1.1527-1.0893-1.9485 0.01934-1.633-0.05224-3.2705 0.0705-4.8993 1.0358-1.4884 1.0728 1.7574 1.8807 0.18967 1.4368-1.159 3.8305-0.95952 3.2722 1.4162z"/>
<path d="m-86.348-64.088c-2.353 0.27226-4.1408-2.1361-3.3655-4.3384 0.25129-2.1773 2.5915-3.6168 4.6256-2.7749 2.4364 0.84593 0.66104 3.3783-1.138 3.6566-0.78817 0.37345-1.5763 0.7469-2.3645 1.1204 1.2036 1.8958 3.7418 0.96604 5.0623 0.05203 1.3351 1.4318-1.7546 2.3186-2.8198 2.2844zm-0.18127-6.1163c-1.2603-0.54741-3.1258 3.3447-1.3751 2.164 1.0179-0.5018 2.0935-0.92053 2.9931-1.6269-0.4321-0.4072-1.0427-0.53936-1.618-0.53711z"/>
<path d="m-76.694-63.953c-1.2507-1.5544-3.7187 1.1675-5.1562-0.9668-1.5477-2.2077-0.43496-5.8956 2.3599-6.4134 1.7565-0.47081 3.6364 0.76684 2.7141 2.6104-0.77494 1.6064 1.2116 3.4857 0.31555 4.7261zm-1.3965-4.6661c0.95522-2.544-2.6591-1.5586-2.9272 0.16617-0.9372 1.7855 0.85633 4.353 2.7544 2.8181 0.82545-0.56077-0.03186-2.0799 0.17288-2.9843z"/>
<path d="m-73.196-69.674c-0.08328 1.7284-0.03569 3.4717-0.22072 5.1881-1.9466 1.2665-1.0589-2.4666-1.1435-3.5088 0.08144-2.2743 0.03377-4.5571 0.1498-6.8272 1.924-1.2991 1.1388 2.3198 1.2333 3.3867-0.01142 0.58697-0.01883 1.174-0.01888 1.7611z"/>
<path d="m-69.436-69.674c-0.08328 1.7284-0.03569 3.4717-0.22072 5.1881-1.9466 1.2665-1.0589-2.4666-1.1435-3.5088 0.08144-2.2743 0.03377-4.5571 0.1498-6.8272 1.924-1.2991 1.1388 2.3198 1.2333 3.3867-0.01142 0.58697-0.01883 1.174-0.01888 1.7611z"/>
<path d="m-61.527-70.385c-1.4366 3.1829-3.0461 6.2999-4.1853 9.6042-2.1757 0.84518-0.54642-2.5672-0.08224-3.4957 0.74289-0.83986-0.24231-1.6266-0.52828-2.4351-0.6457-1.3327-1.442-2.6142-2.0188-3.9621 1.2038-1.9336 2.3288 1.7382 2.9679 2.6755 0.42367 1.4505 0.79477 1.47 1.1681 0.15822 0.5285-1.1723 0.95491-2.4281 1.752-3.4447 0.54145-0.35656 1.2854 0.33916 0.92651 0.89966z"/>
</g>
<g transform="matrix(.96209 -.2075 .2075 .96209 25.912 -52.71)" fill="#dadada" stroke-width="1px" aria-label="69">
<path d="m-388.94-81.96c-3.1615 0.07399-3.9724-4.0595-2.8389-6.3982 0.29035-1.6345 2.7892-5.2548 4.0606-3.6089-0.44097 1.0134-4.056 3.4132-1.0784 3.0781 2.8498-0.20087 4.3801 3.4509 2.6287 5.5412-0.6173 0.88091-1.6763 1.4927-2.772 1.3877zm0-5.5121c-2.8259-0.34004-2.5344 4.6205 0.17604 4.3457 2.5863-0.22618 2.7512-4.6838-0.17604-4.3457z"/>
<path d="m-379.28-83.256c-0.7292 0.75861-4.068 2.2452-3.2966 0.2874 1.247-0.55674 4.5546-2.2796 3.3763-3.1846-2.8507 1.1008-5.9514-2.2308-4.2021-4.9031 1.74-3.059 6.8816-1.6565 6.6098 1.9514 0.0631 2.1314-0.83485 4.4599-2.4875 5.8489zm-0.95336-8.3521c-3.2899-0.34723-2.7447 5.4317 0.54912 4.3465 2.2445-0.5994 1.8649-4.0795-0.38969-4.3095l-0.13256-0.03075z"/>
</g>
</g>
<g transform="matrix(.9297 .16991 -.16991 .9297 1852.1 945.27)" fill="#4aa158" stroke-width="1px" aria-label="i love tags">
<path d="m-2555.4-574.69c-0.3676 1.6669 0.9835 4.556-0.6889 5.4079-1.3252-1.1002-0.2302-3.0805-0.6116-4.5626-0.059-1.9432-0.1407-3.8859-0.1648-5.83 1.8661-1.2106 1.4497 2.2146 1.3957 3.2387 0.018 0.58223 0.038 1.1644 0.07 1.7461z"/>
<path d="m-2550.7-569.12c-1.695 0.0205-2.9714-1.6262-3.0509-3.2248-0.1501-1.8024 1.0236-3.7632 2.8687-4.1196 1.1017-0.20636 2.2681 0.43217 2.6999 1.465 0.9646 1.8328 0.4888 4.4339-1.3293 5.5548-0.3586 0.20843-0.7713 0.33513-1.1884 0.3246zm0.2522-6.0037c-1.2907 0.0944-2.056 1.5013-1.9549 2.6922 0.029 0.91158 0.6876 1.9392 1.6952 1.8903 1.2615-0.0788 1.9363-1.493 1.8203-2.6284-0.071-0.83944-0.6072-1.921-1.5606-1.9541z"/>
<path d="m-2542.9-570.06c-1.3061 1.5667-1.9961-1.4672-2.3256-2.4011-0.1987-1.32-2.0746-3.0697-0.9388-4.1448 1.525 0.20085 1.3152 2.1228 1.8816 3.1912 0.1403 0.79789 0.9706 2.1182 1.1744 0.58798 0.7835-1.1228 0.5292-3.8843 2.1726-3.7984 0.8949 1.4035-0.7473 2.7947-0.9974 4.1803-0.3221 0.79501-0.6494 1.588-0.9668 2.3849z"/>
<path d="m-2536.2-569.57c-1.8379 0.25369-3.7629-1.3102-3.5737-3.2271-0.1012-1.9097 1.1916-4.0598 3.2616-4.1279 1.6801-0.45649 3.7706 1.815 1.9193 3.0052-1.0691 1.0064-3.0605 1.1153-3.7768 2.3094 1.2557 1.3521 3.3826 0.86213 4.6361-0.23707 1.7687 0.85334-1.0741 2.4516-2.1601 2.2488zm-0.3347-6.1099c-1.0721-0.21792-2.5866 1.9217-1.5497 2.2758 0.8278-0.54282 3.244-1.0441 2.6665-2.1507-0.3611-0.12265-0.7486-0.0491-1.1168-0.12512z"/>
<path d="m-2524.9-575.13c-1.5405-0.67558-1.6985 1.2986-1.5948 2.4528 0.099 1.1011 0.4855 2.9463-0.6623 3.5102-1.5514-0.27135-0.3798-2.0448-0.6241-3.0366-0.2283-0.92722 0.4148-2.4056-0.3475-2.9818-0.9704 0.62006-2.45-1.4007-0.8898-1.386 1.1211 0.29871 1.5228-0.80797 1.1782-1.6566 0.5385-1.1269 1.8956-0.16987 1.4068 0.84134-0.1404 1.6457 1.8808 0.0764 2.1409 1.4955 0.1437 0.39517-0.2034 0.79615-0.6074 0.7612z"/>
<path d="m-2517.9-568.69c-0.5023-0.0829-0.6877-1.036-1.2791-0.6416-0.7152 0.37561-1.5473 0.56594-2.3442 0.36228-0.7872-0.14923-1.5089-0.66497-1.7954-1.4298-0.3806-1.0402-0.3821-2.2244-0.036-3.2745 0.5829-1.706 2.5214-2.9201 4.3075-2.445 0.5707 0.18706 1.265 0.40504 1.5386 0.98673 0 0.5278-0.316 1.0105-0.2776 1.5567-0.063 1.055-0.2308 2.1533 0.1349 3.1741 0.074 0.48738 0.5674 0.98265 0.2806 1.4585-0.1224 0.16152-0.3244 0.26651-0.5291 0.25261zm-1.2138-4.717c-0.1232-0.48283 0.4777-1.2468-0.099-1.5045-1.015-0.3911-2.1226 0.31928-2.6364 1.1837-0.5199 0.86317-0.573 1.9914-0.187 2.9144 0.3983 0.86477 1.5766 0.91664 2.3401 0.55213 0.4068-0.16553 0.8256-0.49949 0.6616-0.99063-0.082-0.71322-0.089-1.4375-0.08-2.1551z"/>
<path d="m-2510.5-571.74c-0.3089 1.7209-0.065 3.5936-0.951 5.1716-0.2379 0.48456-0.6503 0.8476-1.1523 1.0399-1.2351 0.52484-2.7013 0.49722-3.915-0.0756-0.2566-0.46006-0.08-1.2164 0.582-1.0383 0.7552 0.0602 1.5125 0.42319 2.2691 0.18519 1.0202-0.20188 1.6183-1.2671 1.6977-2.2374 0.022-0.27324-0.01-0.94901-0.2136-0.93026-0.6468 0.90767-1.9707 1.0068-2.9064 0.52172-0.7507-0.34336-1.2755-1.0692-1.414-1.8769-0.3954-1.8524 0.632-3.9893 2.4517-4.642 1.0211-0.32659 2.1925-0.25087 3.1384 0.26587 0.5576 0.29287 0.9341 0.92545 0.8125 1.5612-0.068 0.69708-0.3593 1.3534-0.3991 2.0548zm-2.0403-2.9888c-1.1195-0.15393-2.1374 0.68405-2.5193 1.6896-0.3037 0.75721-0.3717 1.6748 0.03 2.4073 0.3353 0.54377 1.0446 0.82935 1.6492 0.58276 0.9398-0.35299 1.5684-1.2979 1.6896-2.2747 0.1379-0.6666 0.3574-1.4067 0.05-2.0577-0.1732-0.31946-0.5679-0.39385-0.8993-0.3473z"/>
<path d="m-2504.1-573.77c-0.3876-2.4091-4.4757 0.62756-1.3652 0.92784 2.6224 0.90121 1.7346 4.9148-0.9851 4.7134-1.2495 0.50199-3.7735-1.8039-1.5043-1.8019 1.1893 0.86998 3.8843-0.32005 1.8423-1.4348-2.0484-0.34111-3.7316-2.7402-1.4066-4.0613 1.3001-1.1931 5.2979-0.75714 3.679 1.5424-0.087 0.0381-0.1734 0.0762-0.2601 0.11431z"/>
</g>
<path d="m-344.74-25.124c-6.1284-1.0979-18.576-2.7472-24.699-3.8311l-5.0054 33.646c6.2917 1.434 18.208 4.5136 24.398 5.9506 3.6411-11.279 6.6229-23.84 5.3066-35.766z" fill="#2d6236" style="paint-order:normal"/>
<g transform="matrix(.9297 .16991 -.16991 .9297 1916.8 958.68)" fill="#dadada" stroke-width="1px" aria-label="34">
<path d="m-2560.2-569.43q-1.0138 0-1.8194-0.42297-0.9064-0.49011-1.2219-1.3763-0.04-0.12085-0.04-0.23499 0-0.26855 0.2014-0.44311 0.2081-0.18128 0.4767-0.18128 0.2685 0 0.4632 0.24841l0.3089 0.46326q0.2417 0.32898 0.6512 0.49683 0.4096 0.16113 0.9802 0.16113 0.7318 0 1.2824-0.45654 0.5841-0.4834 0.5841-1.1816 0-1.0138-0.7453-1.618-0.6579-0.52368-1.7456-0.63781-0.7452-0.0739-0.7452-0.60425 0-0.39612 0.5841-0.60425l1.6046-0.39612q0.4633-0.16784 0.6983-0.44983 0.235-0.28869 0.2417-0.69824 0.013-0.59082-0.4566-0.95337-0.4901-0.37597-1.3763-0.37597-0.4566 0-0.8862 0.22155l-0.7587 0.47669q-0.2216 0.14099-0.3559 0.14099-0.2685 0-0.4632-0.20142-0.188-0.20813-0.188-0.46997 0-0.53039 1.0272-1.0205 0.893-0.43641 1.4704-0.43641 1.4434 0 2.2894 0.63782 0.9064 0.68482 0.9064 1.9537 0 1.5778-1.2757 2.0813-0.1141 0.047-0.2618 0.094 0.8929 0.32898 1.336 0.98694 0.4432 0.65124 0.4432 1.6315 0 1.3226-0.9333 2.2491-0.9332 0.9198-2.276 0.9198z"/>
<path d="m-2549.4-572.67v2.4841q0 0.62439-0.611 0.62439-0.7117 0-0.7117-0.97352 0-0.12084 0.014-0.3424 0.013-0.22156 0.013-0.30213l-0.01-1.524-2.9608-0.0739q-0.9198 0-1.2757-0.0604-0.6109-0.10071-0.6109-0.47669 0-0.30212 0.3692-0.76538l0.5774-0.68481 3.3032-4.8206q0.4499-0.62439 1.1414-0.62439 0.7587 0 0.7587 0.65796v5.5792q0.1141-7e-3 0.2752-7e-3 1.2354 0 1.2354 0.66467 0 0.4834-0.5103 0.61097-0.2282 0.0604-1.0003 0.0336zm-1.3025-5.6799q-1.7926 2.8601-2.7997 4.2834l2.7997 0.0671z"/>
</g>
<g transform="matrix(.1976 .036114 -.036114 .1976 -325.27 -67.285)" fill="#4aa158" aria-label="+">
<path d="m-493.08 314.34c-0.0489 3.7279-4.7678 3.8996-7.4987 3.3367-3.6053-1.4027-7.9235 0.96777-6.2932 5.2004 1.0798 3.1434-0.60545 8.8662-4.8234 6.7976-3.6452-2.0855-1.24-6.7037-1.8488-9.936-2.0642-4.0613-7.6116-0.089-10.84-2.4827-4.1787-2.5752 1.0937-8.1378 4.6872-6.1325 3.6391 1.3653 7.8794-1.1274 5.9251-5.2444-0.89217-3.2023 1.9875-8.7744 5.6881-6.1442 3.634 2.6914-0.77449 7.4233 1.9769 10.557 3.3424 2.2417 7.9403-1.1136 11.393 1.362 0.90882 0.59128 1.7649 1.5165 1.6334 2.6864z" fill="#4aa158" stroke-width="5.679px"/>
</g>
<path d="m-351.07-25.656c-28.095-5.0334-64.166-11.986-93.984-12.779-23.849-0.63414-29.364 21.767-4.9134 26.884 29.746 6.2259 66.643 14.139 94.252 20.549 3.6411-11.279 5.9617-22.729 4.6453-34.655zm-101.21-6.9112c3.96 0.72376 6.6257 4.2896 5.9541 7.9647-0.67209 3.675-4.4264 6.0676-8.3864 5.3438-3.96-0.72375-6.6256-4.2897-5.954-7.9647 0.67208-3.6749 4.4263-6.0675 8.3863-5.3438z" fill="#1b3c21" style="paint-order:normal"/>
<g transform="matrix(.9297 .16991 -.16991 .9297 1852.1 945.27)" fill="#4aa158" stroke-width="1px" aria-label="i love tags">
<path d="m-2555.4-574.69c-0.3676 1.6669 0.9835 4.556-0.6889 5.4079-1.3252-1.1002-0.2302-3.0805-0.6116-4.5626-0.059-1.9432-0.1407-3.8859-0.1648-5.83 1.8661-1.2106 1.4497 2.2146 1.3957 3.2387 0.018 0.58223 0.038 1.1644 0.07 1.7461z"/>
<path d="m-2550.7-569.12c-1.695 0.0205-2.9714-1.6262-3.0509-3.2248-0.1501-1.8024 1.0236-3.7632 2.8687-4.1196 1.1017-0.20636 2.2681 0.43217 2.6999 1.465 0.9646 1.8328 0.4888 4.4339-1.3293 5.5548-0.3586 0.20843-0.7713 0.33513-1.1884 0.3246zm0.2522-6.0037c-1.2907 0.0944-2.056 1.5013-1.9549 2.6922 0.029 0.91158 0.6876 1.9392 1.6952 1.8903 1.2615-0.0788 1.9363-1.493 1.8203-2.6284-0.071-0.83944-0.6072-1.921-1.5606-1.9541z"/>
<path d="m-2542.9-570.06c-1.3061 1.5667-1.9961-1.4672-2.3256-2.4011-0.1987-1.32-2.0746-3.0697-0.9388-4.1448 1.525 0.20085 1.3152 2.1228 1.8816 3.1912 0.1403 0.79789 0.9706 2.1182 1.1744 0.58798 0.7835-1.1228 0.5292-3.8843 2.1726-3.7984 0.8949 1.4035-0.7473 2.7947-0.9974 4.1803-0.3221 0.79501-0.6494 1.588-0.9668 2.3849z"/>
<path d="m-2536.2-569.57c-1.8379 0.25369-3.7629-1.3102-3.5737-3.2271-0.1012-1.9097 1.1916-4.0598 3.2616-4.1279 1.6801-0.45649 3.7706 1.815 1.9193 3.0052-1.0691 1.0064-3.0605 1.1153-3.7768 2.3094 1.2557 1.3521 3.3826 0.86213 4.6361-0.23707 1.7687 0.85334-1.0741 2.4516-2.1601 2.2488zm-0.3347-6.1099c-1.0721-0.21792-2.5866 1.9217-1.5497 2.2758 0.8278-0.54282 3.244-1.0441 2.6665-2.1507-0.3611-0.12265-0.7486-0.0491-1.1168-0.12512z"/>
<path d="m-2524.9-575.13c-1.5405-0.67558-1.6985 1.2986-1.5948 2.4528 0.099 1.1011 0.4855 2.9463-0.6623 3.5102-1.5514-0.27135-0.3798-2.0448-0.6241-3.0366-0.2283-0.92722 0.4148-2.4056-0.3475-2.9818-0.9704 0.62006-2.45-1.4007-0.8898-1.386 1.1211 0.29871 1.5228-0.80797 1.1782-1.6566 0.5385-1.1269 1.8956-0.16987 1.4068 0.84134-0.1404 1.6457 1.8808 0.0764 2.1409 1.4955 0.1437 0.39517-0.2034 0.79615-0.6074 0.7612z"/>
<path d="m-2517.9-568.69c-0.5023-0.0829-0.6877-1.036-1.2791-0.6416-0.7152 0.37561-1.5473 0.56594-2.3442 0.36228-0.7872-0.14923-1.5089-0.66497-1.7954-1.4298-0.3806-1.0402-0.3821-2.2244-0.036-3.2745 0.5829-1.706 2.5214-2.9201 4.3075-2.445 0.5707 0.18706 1.265 0.40504 1.5386 0.98673 0 0.5278-0.316 1.0105-0.2776 1.5567-0.063 1.055-0.2308 2.1533 0.1349 3.1741 0.074 0.48738 0.5674 0.98265 0.2806 1.4585-0.1224 0.16152-0.3244 0.26651-0.5291 0.25261zm-1.2138-4.717c-0.1232-0.48283 0.4777-1.2468-0.099-1.5045-1.015-0.3911-2.1226 0.31928-2.6364 1.1837-0.5199 0.86317-0.573 1.9914-0.187 2.9144 0.3983 0.86477 1.5766 0.91664 2.3401 0.55213 0.4068-0.16553 0.8256-0.49949 0.6616-0.99063-0.082-0.71322-0.089-1.4375-0.08-2.1551z"/>
<path d="m-2510.5-571.74c-0.3089 1.7209-0.065 3.5936-0.951 5.1716-0.2379 0.48456-0.6503 0.8476-1.1523 1.0399-1.2351 0.52484-2.7013 0.49722-3.915-0.0756-0.2566-0.46006-0.08-1.2164 0.582-1.0383 0.7552 0.0602 1.5125 0.42319 2.2691 0.18519 1.0202-0.20188 1.6183-1.2671 1.6977-2.2374 0.022-0.27324-0.01-0.94901-0.2136-0.93026-0.6468 0.90767-1.9707 1.0068-2.9064 0.52172-0.7507-0.34336-1.2755-1.0692-1.414-1.8769-0.3954-1.8524 0.632-3.9893 2.4517-4.642 1.0211-0.32659 2.1925-0.25087 3.1384 0.26587 0.5576 0.29287 0.9341 0.92545 0.8125 1.5612-0.068 0.69708-0.3593 1.3534-0.3991 2.0548zm-2.0403-2.9888c-1.1195-0.15393-2.1374 0.68405-2.5193 1.6896-0.3037 0.75721-0.3717 1.6748 0.03 2.4073 0.3353 0.54377 1.0446 0.82935 1.6492 0.58276 0.9398-0.35299 1.5684-1.2979 1.6896-2.2747 0.1379-0.6666 0.3574-1.4067 0.05-2.0577-0.1732-0.31946-0.5679-0.39385-0.8993-0.3473z"/>
<path d="m-2504.1-573.77c-0.3876-2.4091-4.4757 0.62756-1.3652 0.92784 2.6224 0.90121 1.7346 4.9148-0.9851 4.7134-1.2495 0.50199-3.7735-1.8039-1.5043-1.8019 1.1893 0.86998 3.8843-0.32005 1.8423-1.4348-2.0484-0.34111-3.7316-2.7402-1.4066-4.0613 1.3001-1.1931 5.2979-0.75714 3.679 1.5424-0.087 0.0381-0.1734 0.0762-0.2601 0.11431z"/>
</g>
<path d="m-344.74-25.124c-6.1284-1.0979-18.576-2.7472-24.699-3.8311l-5.0054 33.646c6.2917 1.434 18.208 4.5136 24.398 5.9506 3.6411-11.279 6.6229-23.84 5.3066-35.766z" fill="#2d6236" style="paint-order:normal"/>
<g transform="matrix(.9297 .16991 -.16991 .9297 1916.8 958.68)" fill="#dadada" stroke-width="1px" aria-label="34">
<path d="m-2560.2-569.43c-1.2856 0.0748-3.2487-0.8227-2.9857-2.3245 0.8676-0.90346 1.4175 0.67501 2.1254 0.91811 1.1413 0.45829 2.7852-0.26374 2.7344-1.6257-0.01-1.4734-1.5962-2.1621-2.8331-2.1868-1.0993-1.0876 0.8582-1.4069 1.6354-1.5299 1.404-0.17239 1.3825-2.2613 0-2.4456-1.1389-0.51399-1.9641 0.71013-2.9866 0.70744-1.1306-0.91929 0.7004-1.8515 1.5546-1.9774 1.4588-0.34957 3.3826 0.3203 3.6813 1.9475 0.3366 1.1155-0.4205 2.2702-1.2292 2.7692 0.9537 0.52993 1.6163 1.529 1.5264 2.6612-0.042 1.69-1.5045 3.159-3.2189 3.0864z"/>
<path d="m-2549.4-572.67c-0.01 0.87671 0.012 1.7544-0.01 2.6305-0.043 0.61406-1.0298 0.63582-1.2131 0.0978-0.1968-0.54036-0.036-1.1185-0.076-1.6787 0-0.36104 0-0.72207-0.01-1.0831-1.3196-0.0367-2.6399-0.059-3.959-0.10245-0.4686 0.0444-1.1513-0.30426-0.7961-0.85094 0.2984-0.55059 0.7995-0.9545 1.1182-1.4929 1.0334-1.5028 2.0571-3.0125 3.0965-4.511 0.3567-0.4952 1.1247-0.74148 1.6532-0.38429 0.2915 0.28649 0.1595 0.72501 0.1897 1.09v4.9827c0.4855-4e-3 1.0967-0.0553 1.4334 0.36688 0.2552 0.41477-0.1168 0.94035-0.5812 0.92651-0.2807 0.0266-0.5782 0.0165-0.849 9e-3zm-1.3025-5.6799c-0.909 1.4433-1.8196 2.8869-2.7997 4.2834 0.9332 0.0224 1.8665 0.0447 2.7997 0.0671v-4.3506z"/>
</g>
<g transform="matrix(.1976 .036114 -.036114 .1976 -325.27 -67.285)" fill="#4aa158" aria-label="+">
<path d="m-493.08 314.34c-0.0489 3.7279-4.7678 3.8996-7.4987 3.3367-3.6053-1.4027-7.9235 0.96777-6.2932 5.2004 1.0798 3.1434-0.60545 8.8662-4.8234 6.7976-3.6452-2.0855-1.24-6.7037-1.8488-9.936-2.0642-4.0613-7.6116-0.089-10.84-2.4827-4.1787-2.5752 1.0937-8.1378 4.6872-6.1325 3.6391 1.3653 7.8794-1.1274 5.9251-5.2444-0.89217-3.2023 1.9875-8.7744 5.6881-6.1442 3.634 2.6914-0.77449 7.4233 1.9769 10.557 3.3424 2.2417 7.9403-1.1136 11.393 1.362 0.90882 0.59128 1.7649 1.5165 1.6334 2.6864z" fill="#4aa158" stroke-width="5.679px"/>
</g>
<g transform="rotate(19.399 -638.5 398.75)">
<path d="m-501.72-53.894c-28.095-5.0334-64.166-11.986-93.984-12.779-23.849-0.63414-29.364 21.767-4.9134 26.884 29.746 6.2259 66.643 14.139 94.252 20.549 3.6411-11.279 5.9617-22.729 4.6453-34.655zm-101.21-6.9112c3.96 0.72376 6.6257 4.2896 5.9541 7.9647-0.67209 3.675-4.4264 6.0676-8.3864 5.3438-3.96-0.72375-6.6256-4.2897-5.954-7.9647 0.67208-3.6749 4.4263-6.0675 8.3863-5.3438z" fill="#193f47" style="paint-order:normal"/>
<path d="m-495.38-53.362c-6.1284-1.0979-18.576-2.7472-24.699-3.8311l-5.0054 33.646c6.2917 1.434 18.208 4.5136 24.398 5.9506 3.6411-11.279 6.6229-23.84 5.3066-35.766z" fill="#2b6a78" style="paint-order:normal"/>
<g transform="matrix(.9297 .16991 -.16991 .9297 1766.1 930.44)" fill="#dadada" aria-label="34">
<path d="m-2563.1-573.66c-0.01-7.3938 10.433-3.4839 10.55-0.48341 0.1174 3.0005 10.91 8.4875 10.781 0.41697-0.1286-8.0705-10.744-3.18-10.781-0.41697-0.037 2.763-10.543 7.8772-10.55 0.48341z" fill="none" stroke="#dadada" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7519" style="paint-order:normal"/>
</g>
<g transform="matrix(.1976 .036114 -.036114 .1976 -475.92 -95.524)" fill="#4aa158" aria-label="+">
<path d="m-493.08 314.34c-0.0489 3.7279-4.7678 3.8996-7.4987 3.3367-3.6053-1.4027-7.9235 0.96777-6.2932 5.2004 1.0798 3.1434-0.60545 8.8662-4.8234 6.7976-3.6452-2.0855-1.24-6.7037-1.8488-9.936-2.0642-4.0613-7.6116-0.089-10.84-2.4827-4.1787-2.5752 1.0937-8.1378 4.6872-6.1325 3.6391 1.3653 7.8794-1.1274 5.9251-5.2444-0.89217-3.2023 1.9875-8.7744 5.6881-6.1442 3.634 2.6914-0.77449 7.4233 1.9769 10.557 3.3424 2.2417 7.9403-1.1136 11.393 1.362 0.90882 0.59128 1.7649 1.5165 1.6334 2.6864z" fill="#49aabf" stroke-width="5.679px"/>
</g>
<g transform="matrix(1.317 .30927 -.30927 1.317 -47.46 164.76)" fill="#49aabf" stroke-width="1px" aria-label="a lot">
<path d="m-410.25-61.811c-0.65817-0.47252-1.0234-1.0261-1.793-0.36003-1.3776 0.68242-3.4363 0.10802-3.8268-1.5048-0.51946-1.6866-0.15989-3.743 1.3355-4.8252 1.2108-0.99587 3.1869-1.0464 4.3304 0.07008 0.12212 0.99307-0.30691 2.1479-0.15335 3.2374-0.15933 1.0779 0.64504 2.0133 0.68458 3.0099-0.0987 0.22178-0.3329 0.37805-0.57739 0.37262zm-1.3965-4.6661c0.0861-0.77055 0.34961-1.8109-0.8197-1.5846-1.6279 0.1933-2.5964 2.0219-2.2408 3.5415 0.0244 1.2019 1.3521 1.7652 2.3601 1.3216 1.4197-0.319 0.6606-1.7655 0.71377-2.7795-8e-3 -0.16619-0.0133-0.33256-0.0135-0.49893z"/>
<path d="m-402.64-67.531c-0.0644 1.6837-0.0716 3.3716-0.17936 5.0518-0.47397 1.2143-1.738 0.02501-1.2806-0.89431 0.14762-3.0543 0.1549-6.1126 0.2148-9.1689 0.66106-1.3376 1.7124 0.20846 1.3059 1.1447-0.0163 1.289-0.0639 2.5776-0.0608 3.8668z"/>
<path d="m-398.07-61.858c-1.7276 0.04356-2.9503-1.7082-2.8937-3.3234-0.11892-1.8565 1.1557-3.9955 3.1769-4.0297 1.3201-0.17846 2.3745 0.95782 2.6137 2.1699 0.44919 1.7744 0.0145 4.0808-1.764 4.9545-0.35479 0.15908-0.7452 0.2295-1.1329 0.22874zm0.40283-5.9955c-1.5353 0.0069-2.1416 1.8524-1.9407 3.132 0.0593 1.1077 1.3552 1.9156 2.3346 1.3079 1.2388-0.72072 1.16-2.4186 0.78696-3.6124-0.17409-0.49295-0.64956-0.85296-1.1809-0.82754z"/>
<path d="m-388.87-67.974c-0.57242 0.04133-1.8431-0.29375-1.5597 0.63682 0.0222 1.5617 0.16373 3.1261 0.0895 4.686 0.0229 1.2973-1.7893 0.83962-1.3244-0.3101 0.0639-1.642-0.0722-3.2837-0.10538-4.9255-0.85075 0.10945-2.7999-0.18163-1.8098-1.3541 0.55725-0.22568 1.1868 0.01233 1.7762 0.0046 0.098-0.77503-0.5398-2.4403 0.75408-2.3814 0.75002 0.33896 0.55684 1.3355 0.56751 2.021-0.0144 0.82426 1.4999-0.16274 2.0397 0.4032 0.46304 0.37105 0.20127 1.2582-0.42781 1.2194z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 800 B

After

Width:  |  Height:  |  Size: 700 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB