1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2026-03-25 07:12:58 +00:00

2 Commits

Author SHA1 Message Date
fb626a3928 Covering tag-related util function with tests 2025-06-25 19:29:07 +04:00
4060e6c44b Covering utils with tests 2025-06-25 19:13:04 +04:00
174 changed files with 3399 additions and 5746 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

View File

@@ -1,63 +0,0 @@
name: Build Extensions
on:
push:
branches: [ master ]
jobs:
build-extensions:
runs-on: ubuntu-latest
strategy:
matrix:
site: [furbooru, derpibooru, tantabus]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build extension for ${{ matrix.site }}
run: |
if [ "${{ matrix.site }}" = "furbooru" ]; then
npm run build
else
npm run build:${{ matrix.site }}
fi
- name: Create extension zip
run: |
cd build
zip -r "../${{ matrix.site }}-tagging-assistant-extension.zip" .
- name: Upload extension artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.site }}-tagging-assistant-extension
path: ${{ matrix.site }}-tagging-assistant-extension.zip
retention-days: 30
create-release-artifacts:
runs-on: ubuntu-latest
needs: build-extensions
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create combined artifact
uses: actions/upload-artifact@v4
with:
name: all-extensions
path: artifacts/
retention-days: 90

View File

@@ -1,9 +1,7 @@
import { build } from "vite";
import { createHash } from "crypto";
import {build} from "vite";
import {createHash} from "crypto";
import path from "path";
import fs from "fs";
import { SwapDefinedVariablesPlugin } from "../plugins/swap-defined-variables.js";
import { ScssViteReadEnvVariableFunctionPlugin } from "../plugins/scss-read-env-variable-function.js";
/**
* Create the result base file name for the file.
@@ -51,7 +49,6 @@ function wrapScriptIntoIIFE() {
function makeAliases(rootDir) {
return {
"$config": path.resolve(rootDir, 'src/config'),
"$content": path.resolve(rootDir, 'src/content'),
"$lib": path.resolve(rootDir, 'src/lib'),
"$entities": path.resolve(rootDir, 'src/lib/extension/entities'),
"$styles": path.resolve(rootDir, 'src/styles'),
@@ -59,198 +56,69 @@ function makeAliases(rootDir) {
}
/**
* @param {import('rollup').OutputChunk} chunk
* @param {import('rollup').OutputBundle} bundle
* @param {Set<import('rollup').OutputChunk>} processedChunks
* @return string[]
* Build the selected script separately.
* @param {AssetBuildOptions} buildOptions Building options for the script.
* @return {Promise<string>} Result file path.
*/
function collectChunkDependencies(chunk, bundle, processedChunks = new Set()) {
if (processedChunks.has(chunk) || !chunk.imports) {
return [];
}
export async function buildScript(buildOptions) {
const outputBaseName = createOutputBaseName(buildOptions.input);
processedChunks.add(chunk);
return chunk.imports.concat(
chunk.imports
.map(importedChunkName => {
const module = bundle[importedChunkName];
if (module.type === 'chunk') {
return collectChunkDependencies(module, bundle, processedChunks);
}
return [];
})
.flat()
);
}
/**
* @param {(fileName: string, dependencies: string[]) => void} onDependencyResolvedCallback
* @returns {import('vite').Plugin}
*/
function collectDependenciesForManifestBuilding(onDependencyResolvedCallback) {
return {
name: 'extract-dependencies-for-content-scripts',
enforce: "post",
/**
* @param {any} options
* @param {import('rollup').OutputBundle} bundle
*/
writeBundle(options, bundle) {
Object.keys(bundle).forEach(fileName => {
const chunk = bundle[fileName];
if (chunk.type !== "chunk" || !chunk.facadeModuleId) {
return;
}
const dependencies = Array.from(
new Set(
collectChunkDependencies(chunk, bundle)
)
);
onDependencyResolvedCallback(fileName, dependencies);
});
}
}
}
/**
* Second revision of the building logic for the content scripts. This method tries to address duplication of
* dependencies generated with the previous method, where every single content script was built separately.
* @param {BatchBuildOptions} buildOptions
* @returns {Promise<Map<string, string[]>>}
*/
export async function buildScriptsAndStyles(buildOptions) {
/** @type {Map<string, string[]>} */
const pathsReplacement = new Map();
/** @type {Map<string, string[]>} */
const pathsReplacementByOutputPath = new Map();
const amdScriptsInput = {};
const libsAndStylesInput = {};
for (const inputPath of buildOptions.inputs) {
let outputExtension = path.extname(inputPath);
if (outputExtension === '.scss') {
outputExtension = '.css';
}
if (outputExtension === '.ts') {
outputExtension = '.js';
}
const outputPath = createOutputBaseName(inputPath);
const replacementsArray = [`${outputPath}${outputExtension}`];
pathsReplacement.set(inputPath, replacementsArray);
if (outputExtension === '.css' || inputPath.includes('/deps/')) {
libsAndStylesInput[outputPath] = inputPath;
continue;
}
pathsReplacementByOutputPath.set(outputPath + '.js', replacementsArray);
amdScriptsInput[outputPath] = inputPath;
}
const aliasesSettings = makeAliases(buildOptions.rootDir);
const defineConstants = {
__CURRENT_SITE__: JSON.stringify('furbooru'),
__CURRENT_SITE_NAME__: JSON.stringify('Furbooru'),
};
const derpibooruSwapPlugin = SwapDefinedVariablesPlugin({
envVariable: 'SITE',
expectedValue: 'derpibooru',
define: {
__CURRENT_SITE__: JSON.stringify('derpibooru'),
__CURRENT_SITE_NAME__: JSON.stringify('Derpibooru'),
}
});
const tantabusSwapPlugin = SwapDefinedVariablesPlugin({
envVariable: 'SITE',
expectedValue: 'tantabus',
define: {
__CURRENT_SITE__: JSON.stringify('tantabus'),
__CURRENT_SITE_NAME__: JSON.stringify('Tantabus'),
}
});
// Building all scripts together with AMD loader in mind
await build({
configFile: false,
publicDir: false,
build: {
rollupOptions: {
input: amdScriptsInput,
input: {
[outputBaseName]: buildOptions.input
},
output: {
dir: buildOptions.outputDir,
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name]-[hash].js',
// ManifestV3 doesn't allow to use modern ES modules syntax, so we build all content scripts as AMD modules.
format: "amd",
inlineDynamicImports: false,
amd: {
// amd-lite requires names even for the entry-point scripts, so we should make sure to add those.
autoId: true,
},
// All these modules are not intended to be used outside of extension anyway
minifyInternalExports: true,
entryFileNames: '[name].js'
}
},
emptyOutDir: false,
},
resolve: {
alias: aliasesSettings,
alias: makeAliases(buildOptions.rootDir)
},
plugins: [
wrapScriptIntoIIFE(),
collectDependenciesForManifestBuilding((fileName, dependencies) => {
pathsReplacementByOutputPath
.get(fileName)
?.push(...dependencies);
}),
derpibooruSwapPlugin,
tantabusSwapPlugin,
],
define: defineConstants,
wrapScriptIntoIIFE()
]
});
// Build styles separately because AMD converts styles to JS files.
return path.resolve(buildOptions.outputDir, `${outputBaseName}.js`);
}
/**
* Build the selected stylesheet.
* @param {AssetBuildOptions} buildOptions Build options for the stylesheet.
* @return {Promise<string>} Result file path.
*/
export async function buildStyle(buildOptions) {
const outputBaseName = createOutputBaseName(buildOptions.input);
await build({
configFile: false,
publicDir: false,
build: {
rollupOptions: {
input: libsAndStylesInput,
input: {
[outputBaseName]: buildOptions.input
},
output: {
dir: buildOptions.outputDir,
entryFileNames: '[name].js',
assetFileNames: '[name].[ext]',
}
},
emptyOutDir: false
emptyOutDir: false,
},
resolve: {
alias: aliasesSettings,
},
plugins: [
wrapScriptIntoIIFE(),
ScssViteReadEnvVariableFunctionPlugin(),
derpibooruSwapPlugin,
tantabusSwapPlugin,
],
define: defineConstants,
alias: makeAliases(buildOptions.rootDir)
}
});
return pathsReplacement;
return path.resolve(buildOptions.outputDir, `${outputBaseName}.css`);
}
/**
@@ -259,11 +127,3 @@ export async function buildScriptsAndStyles(buildOptions) {
* @property {string} outputDir Destination folder for the script.
* @property {string} rootDir Root directory of the repository.
*/
/**
* @typedef {Object} BatchBuildOptions
* @property {Set<string>} inputs Set of all scripts and styles to build.
* @property {string} outputDir Destination folder for the assets.
* @property {string} rootDir Root directory of the repository.
* @property {(fileName: string, dependencies: string[]) => void} onDependenciesResolved Callback for dependencies.
*/

View File

@@ -17,38 +17,6 @@ class ManifestProcessor {
this.#manifestObject = parsedManifest;
}
/**
* Collect all the content scripts & stylesheets for single build action.
*
* @returns {Set<string>}
*/
collectContentScripts() {
const contentScripts = this.#manifestObject.content_scripts;
if (!contentScripts) {
console.info('No content scripts to collect.');
return new Set();
}
const entryPoints = new Set();
for (let entry of contentScripts) {
if (entry.js) {
for (let jsPath of entry.js) {
entryPoints.add(jsPath);
}
}
if (entry.css) {
for (let cssPath of entry.css) {
entryPoints.add(cssPath);
}
}
}
return entryPoints;
}
/**
* Map over every content script defined in the manifest. If no content scripts defined, no calls will be made to the
* callback.
@@ -86,48 +54,6 @@ class ManifestProcessor {
}
}
/**
* Find all patterns in content scripts and host permissions and replace the hostname to the different one.
*
* @param {string|string[]} singleOrMultipleHostnames One or multiple hostnames to replace the original hostname with.
*/
replaceHostTo(singleOrMultipleHostnames) {
if (typeof singleOrMultipleHostnames === 'string') {
singleOrMultipleHostnames = [singleOrMultipleHostnames];
}
const matchPatterReplacer = ManifestProcessor.#createHostnameReplacementReduce(singleOrMultipleHostnames);
this.#manifestObject.host_permissions = singleOrMultipleHostnames.map(hostname => `*://*.${hostname}/`);
this.#manifestObject.content_scripts?.forEach(entry => {
entry.matches = entry.matches.reduce(matchPatterReplacer, []);
if (entry.exclude_matches) {
entry.exclude_matches = entry.exclude_matches.reduce(matchPatterReplacer, []);
}
})
}
/**
* Set different identifier for Gecko-based browsers (Firefox).
*
* @param {string} id ID of the extension to use.
*/
setGeckoIdentifier(id) {
this.#manifestObject.browser_specific_settings.gecko.id = id;
}
/**
* Set the different extension name.
*
* @param {string} booruName
*/
replaceBooruNameWith(booruName) {
this.#manifestObject.name = this.#manifestObject.name.replaceAll('Furbooru', booruName);
this.#manifestObject.description = this.#manifestObject.description.replaceAll('Furbooru', booruName);
}
/**
* Save the current state of the manifest into the selected file.
*
@@ -143,32 +69,6 @@ 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,28 +86,13 @@ export function loadManifest(filePath) {
/**
* @typedef {Object} Manifest
* @property {string} name
* @property {string} description
* @property {string} version
* @property {BrowserSpecificSettings} browser_specific_settings
* @property {string[]} host_permissions
* @property {ContentScriptsEntry[]|undefined} content_scripts
*/
/**
* @typedef {Object} BrowserSpecificSettings
* @property {GeckoSettings} gecko
*/
/**
* @typedef {Object} GeckoSettings
* @property {string} id
*/
/**
* @typedef {Object} ContentScriptsEntry
* @property {string[]} matches
* @property {string[]} exclude_matches
* @property {string[]} mathces
* @property {string[]|undefined} js
* @property {string[]|undefined} css
*/

View File

@@ -1,9 +1,8 @@
import { loadManifest } from "./lib/manifest.js";
import {loadManifest} from "./lib/manifest.js";
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";
import {buildScript, buildStyle} from "./lib/content-scripts.js";
import {normalizePath} from "vite";
import {extractInlineScriptsFromIndex} from "./lib/index-file.js";
/**
* Build addition assets required for the extension and pack it into the directory.
@@ -12,128 +11,49 @@ import fs from "fs";
export async function packExtension(settings) {
const manifest = loadManifest(path.resolve(settings.rootDir, 'manifest.json'));
const replacementMapping = await buildScriptsAndStyles({
inputs: manifest.collectContentScripts(),
outputDir: settings.contentScriptsDir,
rootDir: settings.rootDir,
});
await manifest.mapContentScripts(async entry => {
// Since we CAN'T really build all scripts and stylesheets in a single build entry, we will run build for every single
// one of them in a row. This way, no chunks will be generated. Thanks, ManifestV3!
await manifest.mapContentScripts(async (entry) => {
if (entry.js) {
entry.js = entry.js
.map(jsSourcePath => {
if (!replacementMapping.has(jsSourcePath)) {
return [];
}
return replacementMapping.get(jsSourcePath);
})
.flat(1)
.map(pathName => {
return normalizePath(
path.relative(
settings.exportDir,
path.join(
settings.contentScriptsDir,
pathName
)
)
)
for (let scriptIndex = 0; scriptIndex < entry.js.length; scriptIndex++) {
const builtScriptFilePath = await buildScript({
input: path.resolve(settings.rootDir, entry.js[scriptIndex]),
outputDir: settings.contentScriptsDir,
rootDir: settings.rootDir,
});
entry.js[scriptIndex] = normalizePath(
path.relative(
settings.exportDir,
builtScriptFilePath
)
);
}
}
if (entry.css) {
entry.css = entry.css
.map(jsSourcePath => {
if (!replacementMapping.has(jsSourcePath)) {
return [];
}
for (let styleIndex = 0; styleIndex < entry.css.length; styleIndex++) {
const builtStylesheetFilePath = await buildStyle({
input: path.resolve(settings.rootDir, entry.css[styleIndex]),
outputDir: settings.contentScriptsDir,
rootDir: settings.rootDir
});
return replacementMapping.get(jsSourcePath);
})
.flat(1)
.map(pathName => {
return normalizePath(
path.relative(
settings.exportDir,
path.join(
settings.contentScriptsDir,
pathName
)
)
entry.css[styleIndex] = normalizePath(
path.relative(
settings.exportDir,
builtStylesheetFilePath
)
})
);
}
}
return entry;
})
switch (process.env.SITE) {
case 'derpibooru':
manifest.replaceHostTo([
'derpibooru.org',
'trixiebooru.org'
]);
manifest.replaceBooruNameWith('Derpibooru');
manifest.setGeckoIdentifier('derpibooru-tagging-assistant@thecore.city');
break;
case 'tantabus':
manifest.replaceHostTo('tantabus.ai');
manifest.replaceBooruNameWith('Tantabus');
manifest.setGeckoIdentifier('tantabus-tagging-assistant@thecore.city');
break;
default:
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,46 +0,0 @@
import { SassString, Value } from "sass";
/**
* @return {import('vite').Plugin}
*/
export function ScssViteReadEnvVariableFunctionPlugin() {
return {
name: 'koloml:scss-read-env-variable-function',
apply: 'build',
enforce: 'post',
configResolved: config => {
config.css.preprocessorOptions ??= {};
config.css.preprocessorOptions.scss ??= {};
config.css.preprocessorOptions.scss.functions ??= {};
/**
* @param {Value[]} args
* @return {SassString}
*/
config.css.preprocessorOptions.scss.functions['vite-read-env-variable($constant-name)'] = (args) => {
const constName = args[0].assertString('constant-name').text;
if (config.define && config.define.hasOwnProperty(constName)) {
let returnedValue = config.define[constName];
try {
returnedValue = JSON.parse(returnedValue);
} catch {
returnedValue = null;
}
if (typeof returnedValue !== 'string') {
console.warn(`Attempting to read the constant with non-string type: ${constName}`);
return new SassString('');
}
return new SassString(returnedValue);
}
console.warn(`Constant does not exist: ${constName}`);
return new SassString('');
}
}
}
}

View File

@@ -1,28 +0,0 @@
/**
* @param {SwapDefinedVariablesSettings} settings
* @return {import('vite').Plugin}
*/
export function SwapDefinedVariablesPlugin(settings) {
return {
name: 'koloml:swap-defined-variables',
enforce: 'post',
configResolved: (config) => {
if (
config.define
&& process.env.hasOwnProperty(settings.envVariable)
&& process.env[settings.envVariable] === settings.expectedValue
) {
for (const [key, value] of Object.entries(settings.define)) {
config.define[key] = value;
}
}
}
}
}
/**
* @typedef {Object} SwapDefinedVariablesSettings
* @property {string} envVariable
* @property {string} expectedValue
* @property {Record<string, string>} define
*/

View File

@@ -1,53 +1,10 @@
# Philomena Tagging Assistant
This is a browser extension written for the [Furbooru](https://furbooru.org), [Derpibooru](https://derpibooru.org) and
[Tantabus](https://tantabus.ai) image-boards. It gives you the ability to manually go over the list of images and apply
tags to them without opening each individual image.
## Installation
This extension is available for both Chromium- and Firefox-based browsers. You can find the links to the extension pages
below.
### Furbooru
# Furbooru Tagging Assistant
[![Get the Add-on on Firefox](.github/assets/firefox.png)](https://addons.mozilla.org/en-US/firefox/addon/furbooru-tagging-assistant/)
[![Get the extension on Chrome](.github/assets/chrome.png)](https://chromewebstore.google.com/detail/kpgaphaooaaodgodmnkamhmoedjcnfkj)
### Derpibooru
[![Get the Add-on on Firefox](.github/assets/firefox.png)](https://addons.mozilla.org/en-US/firefox/addon/derpibooru-tagging-assistant/)
[![Get the extension on Chrome](.github/assets/chrome.png)](https://chromewebstore.google.com/detail/pnmbomcdbfcghgmegklfofncfigdielb)
### Tantabus
[![Get the Add-on on Firefox](.github/assets/firefox.png)](https://addons.mozilla.org/en-US/firefox/addon/tantabus-tagging-assistant/)
[![Get the extension on Chrome](.github/assets/chrome.png)](https://chromewebstore.google.com/detail/jpfkohpgdnpabpjafgagonghknaiecih)
## Features
### Tagging Profiles
Select a set of tags and add/remove them from images without opening them. Just hover over image, click on tags and
you're done!
![Tagging Profiles Showcase](.github/assets/profiles-showcase.png)
### Custom Tag Groups
Customize the list of tags with your own custom tag groups. Apply custom colors to different groups or even separate
them from each other with group titles.
![Tag Groups Showcase](.github/assets/groups-showcase.png)
### Fullscreen Viewer
Open up the specific image or video in fullscreen mode by clicking 🔍 icon in the bottom left corner of the image. This
feature is opt-in and should be enabled in the settings first.
![Fullscreen Viewer Icon](.github/assets/fullscreen-viewer-icon.png)
![Fullscreen Viewer Showcase](.github/assets/fullscreen-viewer-showcase.png)
This is a browser extension written for the [Furbooru](https://furbooru.org) image-board. It gives you the ability to
tag the images more easily and quickly.
## Building
@@ -62,21 +19,11 @@ npm install --save-dev
```
Second, you need to run the `build` command. It will first build the popup using SvelteKit and then build all the
content scripts/stylesheets and copy the manifest afterward.
Extension can currently be built for multiple different imageboards using one of the following commands:
content scripts/stylesheets and copy the manifest afterward. Simply run:
```shell
# Furbooru:
npm run build
# Derpibooru:
npm run build:derpibooru
# Tantabus:
npm run build:tantabus
```
When build is complete, extension files can be found in the `/build` directory. These files can be either used
directly in Chrome (via loading the extension as unpacked extension) or manually compressed into `*.zip` file and loaded
into Firefox.
When building is complete, resulting files can be found in the `/build` directory. These files can be either used
directly in Chrome (via loading the extension as unpacked extension) or manually compressed into `*.zip` file.

View File

@@ -1,15 +1,10 @@
{
"name": "Furbooru Tagging Assistant",
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
"version": "0.7.0",
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
"version": "0.4.5",
"browser_specific_settings": {
"gecko": {
"id": "furbooru-tagging-assistant@thecore.city",
"data_collection_permissions": {
"required": [
"none"
]
}
"id": "furbooru-tagging-assistant@thecore.city"
}
},
"icons": {
@@ -23,14 +18,6 @@
"*://*.furbooru.org/"
],
"content_scripts": [
{
"matches": [
"*://*.furbooru.org/*"
],
"js": [
"src/content/deps/amd.ts"
]
},
{
"matches": [
"*://*.furbooru.org/",
@@ -48,18 +35,21 @@
},
{
"matches": [
"*://*.furbooru.org/images/*"
"*://*.furbooru.org/*"
],
"exclude_matches": [
"*://*.furbooru.org/images/new",
"*://*.furbooru.org/images/new?*"
"js": [
"src/content/header.ts"
],
"css": [
"src/styles/content/header.scss"
]
},
{
"matches": [
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/tags-editor.ts"
],
"css": [
"src/styles/content/tags-editor.scss",
"src/styles/content/tag-presets.scss"
]
},
{
@@ -79,30 +69,6 @@
"js": [
"src/content/tags.ts"
]
},
{
"matches": [
"*://*.furbooru.org/posts",
"*://*.furbooru.org/posts?*",
"*://*.furbooru.org/forums/*/topics/*"
],
"js": [
"src/content/posts.ts"
],
"css": [
"src/styles/content/posts.scss"
]
},
{
"matches": [
"*://*.furbooru.org/images/new"
],
"js": [
"src/content/upload.ts"
],
"css": [
"src/styles/content/tag-presets.scss"
]
}
],
"action": {

2637
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,9 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.7.0",
"version": "0.4.5",
"private": true,
"type": "module",
"scripts": {
"build": "npm run build:popup && npm run build:extension",
"build:derpibooru": "cross-env SITE=derpibooru npm run build",
"build:tantabus": "cross-env SITE=tantabus npm run build",
"build:popup": "vite build",
"build:extension": "node build-extension.js",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@@ -14,26 +11,25 @@
"test": "vitest run --coverage",
"test:watch": "vitest watch --coverage"
},
"dependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.55.0",
"@fortawesome/fontawesome-free": "^7.2.0",
"amd-lite": "^1.0.1",
"lz-string": "^1.5.0",
"sass": "^1.98.0",
"svelte": "^5.53.12"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@types/chrome": "^0.1.37",
"@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.5",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.1.0"
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.21.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.326",
"@types/node": "^22.15.29",
"@vitest/coverage-v8": "^3.2.0",
"cheerio": "^1.0.0",
"jsdom": "^26.1.0",
"sass": "^1.89.1",
"svelte": "^5.33.14",
"svelte-check": "^4.2.1",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.2.0"
},
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"lz-string": "^1.5.0"
}
}

16
src/app.d.ts vendored
View File

@@ -1,20 +1,9 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
import type TaggingProfile from "$entities/TaggingProfile";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import type TagGroup from "$entities/TagGroup";
import type TagEditorPreset from "$entities/TagEditorPreset";
declare global {
/**
* Identifier of the current site this extension is built for.
*/
const __CURRENT_SITE__: string;
/**
* Name of the site.
*/
const __CURRENT_SITE_NAME__: string;
// Helper type to not deal with differences between the setTimeout of @types/node and usual web browser's type.
type Timeout = ReturnType<typeof setTimeout>;
@@ -38,9 +27,8 @@ declare global {
);
interface EntityNamesMap {
profiles: TaggingProfile;
profiles: MaintenanceProfile;
groups: TagGroup;
presets: TagEditorPreset;
}
interface ImageURIs {

View File

@@ -1,57 +0,0 @@
# 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

@@ -1,2 +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" 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>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,2 +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" 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>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,41 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,115 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -1,8 +1,6 @@
<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;
@@ -16,27 +14,60 @@
</script>
<DetailsBlock title="Group Name">
{group.settings.name}
</DetailsBlock>
<div class="block">
<strong>Group Name:</strong>
<div>{group.settings.name}</div>
</div>
{#if sortedTagsList.length}
<DetailsBlock title="Tags">
<div class="block">
<strong>Tags:</strong>
<TagsColorContainer targetCategory={group.settings.category}>
<TagsList tags={sortedTagsList} />
<div class="tags-list">
{#each sortedTagsList as tagName}
<span class="tag">{tagName}</span>
{/each}
</div>
</TagsColorContainer>
</DetailsBlock>
</div>
{/if}
{#if sortedPrefixes.length}
<DetailsBlock title="Prefixes">
<div class="block">
<strong>Prefixes:</strong>
<TagsColorContainer targetCategory={group.settings.category}>
<TagsList tags={sortedPrefixes} append="*" />
<div class="tags-list">
{#each sortedPrefixes as prefixName}
<span class="tag">{prefixName}*</span>
{/each}
</div>
</TagsColorContainer>
</DetailsBlock>
</div>
{/if}
{#if sortedSuffixes.length}
<DetailsBlock title="Suffixes">
<div class="block">
<strong>Suffixes:</strong>
<TagsColorContainer targetCategory={group.settings.category}>
<TagsList tags={sortedSuffixes} prepend="*" />
<div class="tags-list">
{#each sortedSuffixes as suffixName}
<span class="tag">*{suffixName}</span>
{/each}
</div>
</TagsColorContainer>
</DetailsBlock>
</div>
{/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

@@ -1,20 +0,0 @@
<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,20 +1,41 @@
<script lang="ts">
import type TaggingProfile from "$entities/TaggingProfile";
import DetailsBlock from "$components/ui/DetailsBlock.svelte";
import TagsList from "$components/tags/TagsList.svelte";
import type MaintenanceProfile from "$entities/MaintenanceProfile";
interface ProfileViewProps {
profile: TaggingProfile;
profile: MaintenanceProfile;
}
let { profile }: ProfileViewProps = $props();
const sortedTagsList = $derived(profile.settings.tags.sort((a, b) => a.localeCompare(b)));
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));
</script>
<DetailsBlock title="Profile">
{profile.settings.name}
</DetailsBlock>
<DetailsBlock title="Tags">
<TagsList tags={sortedTagsList} />
</DetailsBlock>
<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>

View File

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

View File

@@ -19,60 +19,50 @@
.tag-color-container:is(:global(.tag-color-container--rating)) :global(.tag) {
background-color: colors.$tag-rating-background;
color: colors.$tag-rating-text;
border-color: colors.$tag-rating-border;
}
.tag-color-container:is(:global(.tag-color-container--spoiler)) :global(.tag) {
background-color: colors.$tag-spoiler-background;
color: colors.$tag-spoiler-text;
border-color: colors.$tag-spoiler-border;
}
.tag-color-container:is(:global(.tag-color-container--origin)) :global(.tag) {
background-color: colors.$tag-origin-background;
color: colors.$tag-origin-text;
border-color: colors.$tag-origin-border;
}
.tag-color-container:is(:global(.tag-color-container--oc)) :global(.tag) {
background-color: colors.$tag-oc-background;
color: colors.$tag-oc-text;
border-color: colors.$tag-oc-border;
}
.tag-color-container:is(:global(.tag-color-container--error)) :global(.tag) {
background-color: colors.$tag-error-background;
color: colors.$tag-error-text;
border-color: colors.$tag-error-border;
}
.tag-color-container:is(:global(.tag-color-container--character)) :global(.tag) {
background-color: colors.$tag-character-background;
color: colors.$tag-character-text;
border-color: colors.$tag-character-border;
}
.tag-color-container:is(:global(.tag-color-container--content-official)) :global(.tag) {
background-color: colors.$tag-content-official-background;
color: colors.$tag-content-official-text;
border-color: colors.$tag-content-official-border;
}
.tag-color-container:is(:global(.tag-color-container--content-fanmade)) :global(.tag) {
background-color: colors.$tag-content-fanmade-background;
color: colors.$tag-content-fanmade-text;
border-color: colors.$tag-content-fanmade-border;
}
.tag-color-container:is(:global(.tag-color-container--species)) :global(.tag) {
background-color: colors.$tag-species-background;
color: colors.$tag-species-text;
border-color: colors.$tag-species-border;
}
.tag-color-container:is(:global(.tag-color-container--body-type)) :global(.tag) {
background-color: colors.$tag-body-type-background;
color: colors.$tag-body-type-text;
border-color: colors.$tag-body-type-border;
}
</style>

View File

@@ -1,23 +0,0 @@
<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

@@ -1,30 +0,0 @@
<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,32 +0,0 @@
<script lang="ts">
interface MessageProps {
children?: import('svelte').Snippet;
level: 'warning' | 'error';
}
let { children, level }: MessageProps = $props();
</script>
<p class="{level}">
{@render children?.()}
</p>
<style lang="scss">
@use '$styles/colors';
p {
padding: 5px 24px;
margin: {
left: -24px;
right: -24px;
}
}
.warning {
background: colors.$warning-background;
}
.error {
background: colors.$error-background;
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import SelectField from "$components/ui/forms/SelectField.svelte";
import { categories } from "$config/tags";
import { categories } from "$lib/booru/tag-categories";
interface TagCategorySelectFieldProps {
value?: string;

View File

@@ -9,19 +9,10 @@
value?: string;
href?: string;
children?: Snippet;
/**
* Click event received by the checkbox input element.
*/
onclick?: MouseEventHandler<HTMLInputElement>;
oninput?: FormEventHandler<HTMLInputElement>;
/**
* Click event received by the menu item instead of the checkbox element.
*/
onitemclick?: MouseEventHandler<HTMLElement>;
}
let checkboxElement: HTMLInputElement;
let {
checked = $bindable(),
name = undefined,
@@ -30,61 +21,16 @@
children,
onclick,
oninput,
onitemclick,
}: MenuCheckboxItemProps = $props();
/**
* Prevent clicks from getting sent to the menu link if user clicked directly on the checkbox.
* @param originalEvent
*/
function stopPropagationAndPassCallback(originalEvent: MouseEvent) {
originalEvent.stopPropagation();
onclick?.(originalEvent as MouseEvent & { currentTarget: HTMLInputElement });
}
/**
* Check and try to toggle checkbox if href was not provided for the menu item.
*/
function maybeToggleCheckboxOnOuterLinkClicked(event: MouseEvent) {
// Call the event handler if present.
if (onitemclick) {
onitemclick(event as MouseEvent & {currentTarget: HTMLElement});
// If it was prevented, then don't attempt to run checkbox toggling workaround.
if (event.defaultPrevented) {
return;
}
}
// When menu link does not contain any link, we should just treat clicks on it as toggle action on checkbox.
if (!href) {
checked = !checked;
// Since we've toggled it using the `checked` property and input does not trigger `onclick` when we do something
// programmatically, we should create valid event and send it back to the parent component so it will handle
// whatever it wants.
if (oninput) {
// Uhh, not sure if this is how it should be done, but we need `currentTarget` to point on the checkbox. Without
// dispatching the event, we can't fill it normally. Also, input element does not return us untrusted input
// events automatically. Probably should make the util function later in case I'd need something similar.
checkboxElement.addEventListener('input', (inputEvent: Event) => {
oninput(inputEvent as Event & { currentTarget: HTMLInputElement });
}, { once: true })
checkboxElement.dispatchEvent(new InputEvent('input'));
}
}
}
</script>
<MenuLink {href} onclick={maybeToggleCheckboxOnOuterLinkClicked}>
<input bind:this={checkboxElement}
bind:checked={checked}
{name}
onclick={stopPropagationAndPassCallback}
{oninput}
type="checkbox"
{value}>
<MenuLink {href}>
<input bind:checked={checked} {name} onclick={stopPropagationAndPassCallback} {oninput} type="checkbox" {value}>
{@render children?.()}
</MenuLink>

View File

@@ -1,51 +1,4 @@
/**
* List of categories defined by the sites.
*/
export const categories: string[] = [
'rating',
'spoiler',
'origin',
'oc',
'error',
'character',
'content-official',
'content-fanmade',
'species',
'body-type',
];
/**
* Mapping of namespaces to their respective categories. These namespaces are automatically assigned to them, so we can
* automatically assume categories of tags which start with them. Mapping is extracted from Philomena directly.
*
* This mapping may differ between boorus.
*
* @see https://github.com/philomena-dev/philomena/blob/6086757b654da8792ae52adb2a2f501ea6c30d12/lib/philomena/tags/tag.ex#L33-L45
*/
export const namespaceCategories: Map<string, string> = new Map([
['artist', 'origin'],
['art pack', 'content-fanmade'],
['colorist', 'origin'],
['comic', 'content-fanmade'],
['editor', 'origin'],
['fanfic', 'content-fanmade'],
['oc', 'oc'],
['photographer', 'origin'],
['series', 'content-fanmade'],
['spoiler', 'spoiler'],
['video', 'content-fanmade'],
...(__CURRENT_SITE__ === 'tantabus' ? <const> [
["prompter", "origin"],
["creator", "origin"],
["generator", "origin"]
] : [])
]);
/**
* List of tags which marked by the site as blacklisted. These tags are blocked from being added by the tag editor and
* should usually just be removed automatically.
*/
export const tagsBlacklist: string[] = (__CURRENT_SITE__ === 'furbooru' ? [
export const tagsBlacklist: string[] = [
"anthro art",
"anthro artist",
"anthro cute",
@@ -110,21 +63,4 @@ export const tagsBlacklist: string[] = (__CURRENT_SITE__ === 'furbooru' ? [
"tagme",
"upvotes galore",
"wall of faves"
] : [
"tagme",
"tag me",
"not tagged",
"no tag",
"notag",
"notags",
"upvotes galore",
"downvotes galore",
"wall of faves",
"drama in the comments",
"drama in comments",
"tag needed",
"paywall",
"cringeworthy",
"solo oc",
"tag your shit"
]);
];

View File

@@ -1,15 +0,0 @@
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

@@ -1,10 +0,0 @@
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,74 +0,0 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { TaggingProfilePopup } from "$content/components/extension/profiles/TaggingProfilePopup";
import { on } from "$content/components/events/comms";
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/maintenance-popup-events";
import type { MediaBox } from "$content/components/philomena/MediaBox";
import type TaggingProfile from "$entities/TaggingProfile";
export class MediaBoxTools extends BaseComponent {
#mediaBox: MediaBox | null = null;
#maintenancePopup: TaggingProfilePopup | null = null;
init() {
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
if (!mediaBoxElement) {
throw new Error('Toolbox element initialized outside of the media box!');
}
this.#mediaBox = getComponent(mediaBoxElement);
for (let childElement of this.container.children) {
if (!(childElement instanceof HTMLElement)) {
continue;
}
const component = getComponent(childElement);
if (!component) {
continue;
}
if (!component.isInitialized) {
component.initialize();
}
if (!this.#maintenancePopup && component instanceof TaggingProfilePopup) {
this.#maintenancePopup = component;
}
}
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
}
#onActiveProfileChanged(profileChangedEvent: CustomEvent<TaggingProfile | null>) {
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
}
get maintenancePopup(): TaggingProfilePopup | null {
return this.#maintenancePopup;
}
get mediaBox(): MediaBox | null {
return this.#mediaBox;
}
/**
* Create a maintenance popup element.
* @param childrenElements List of children elements to append to the component.
* @return The maintenance popup element.
*/
static create(...childrenElements: HTMLElement[]): HTMLElement {
const mediaBoxToolsContainer = document.createElement('div');
mediaBoxToolsContainer.classList.add('media-box-tools');
if (childrenElements.length) {
mediaBoxToolsContainer.append(...childrenElements);
}
new MediaBoxTools(mediaBoxToolsContainer);
return mediaBoxToolsContainer;
}
}

View File

@@ -1,102 +0,0 @@
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

@@ -1,129 +0,0 @@
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,133 +0,0 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import TagsPreferences from "$lib/extension/preferences/TagsPreferences";
import { getComponent } from "$content/components/base/component-utils";
import { resolveTagNameFromLink, resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils";
export class BlockCommunication extends BaseComponent {
#contentSection: HTMLElement | null = null;
#tagLinks: HTMLAnchorElement[] = [];
#tagLinksReplaced: boolean | null = null;
#linkTextReplaced: boolean | null = null;
protected build() {
this.#contentSection = this.container.querySelector('.communication__content');
this.#tagLinks = this.#findAllTagLinks();
}
protected init() {
Promise.all([
BlockCommunication.#preferences.replaceLinks.get(),
BlockCommunication.#preferences.replaceLinkText.get(),
]).then(([replaceLinks, replaceLinkText]) => {
this.#onReplaceLinkSettingResolved(
replaceLinks,
replaceLinkText
);
});
BlockCommunication.#preferences.subscribe(settings => {
this.#onReplaceLinkSettingResolved(
settings.replaceLinks ?? false,
settings.replaceLinkText ?? true
);
});
}
#onReplaceLinkSettingResolved(haveToReplaceLinks: boolean, shouldReplaceLinkText: boolean) {
if (
!this.#tagLinks.length
|| this.#tagLinksReplaced === haveToReplaceLinks
&& this.#linkTextReplaced === shouldReplaceLinkText
) {
return;
}
for (const linkElement of this.#tagLinks) {
linkElement.classList.toggle('tag', haveToReplaceLinks);
// Sometimes tags are being decorated with the code block inside. It should be fine to replace it right away.
if (linkElement.childElementCount === 1 && linkElement.children[0].tagName === 'CODE') {
linkElement.textContent = linkElement.children[0].textContent;
}
/**
* Resolved tag name. It should be stored for the text replacement.
*/
let tagName: string | undefined;
if (haveToReplaceLinks) {
tagName = resolveTagNameFromLink(new URL(linkElement.href)) ?? '';
linkElement.dataset.tagCategory = resolveTagCategoryFromTagName(tagName) ?? '';
} else {
linkElement.dataset.tagCategory = '';
}
this.#toggleTagLinkText(
linkElement,
haveToReplaceLinks && shouldReplaceLinkText,
tagName,
);
}
this.#tagLinksReplaced = haveToReplaceLinks;
this.#linkTextReplaced = shouldReplaceLinkText;
}
/**
* Swap the link text with the tag name or restore it back to original content. This function will only perform
* replacement on links without any additional tags inside. This will ensure link won't break original content.
* @param linkElement Element to swap the text on.
* @param shouldSwapToTagName Should we swap the text to tag name or retore it back from memory.
* @param tagName Tag name to swap the text to. If not provided, text will be swapped back.
* @private
*/
#toggleTagLinkText(linkElement: HTMLElement, shouldSwapToTagName: boolean, tagName?: string) {
if (linkElement.childElementCount) {
return;
}
// Make sure we save the original text to memory.
if (!BlockCommunication.#originalTagLinkTexts.has(linkElement)) {
BlockCommunication.#originalTagLinkTexts.set(linkElement, linkElement.textContent);
}
if (shouldSwapToTagName && tagName) {
linkElement.textContent = tagName;
} else {
linkElement.textContent = BlockCommunication.#originalTagLinkTexts.get(linkElement) ?? linkElement.textContent;
}
}
#findAllTagLinks(): HTMLAnchorElement[] {
return Array
.from(this.#contentSection?.querySelectorAll('a') || [])
.filter(
link =>
// Support links pointing to the tag page.
link.pathname.startsWith('/tags/')
// Also capture link which point to the search results with single tag.
|| link.pathname.startsWith('/search')
&& link.search.includes('q=')
);
}
static #preferences = new TagsPreferences();
/**
* Map of links to their original texts. These texts need to be stored here to make them restorable. Keys is a link
* element and value is a text.
* @private
*/
static #originalTagLinkTexts: WeakMap<HTMLElement, string> = new WeakMap();
static findAndInitializeAll() {
for (const container of document.querySelectorAll<HTMLElement>('.block.communication')) {
if (getComponent(container)) {
continue;
}
new BlockCommunication(container).initialize();
}
}
}

View File

@@ -1,103 +0,0 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { buildTagsAndAliasesMap } from "$lib/philomena/tag-utils";
import { on } from "$content/components/events/comms";
import { EVENT_TAGS_UPDATED } from "$content/components/events/maintenance-popup-events";
export class MediaBox extends BaseComponent {
#thumbnailContainer: HTMLElement | null = null;
#imageLinkElement: HTMLAnchorElement | null = null;
#tagsAndAliases: Map<string, string> | null = null;
init() {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
const updatedMap = tagsUpdatedEvent.detail;
if (!(updatedMap instanceof Map)) {
throw new TypeError("Tags and aliases should be stored as Map!");
}
this.#tagsAndAliases = updatedMap;
}
#calculateMediaBoxTags() {
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
return buildTagsAndAliasesMap(tagAliases, actualTags);
}
get tagsAndAliases(): Map<string, string> | null {
if (!this.#tagsAndAliases) {
this.#tagsAndAliases = this.#calculateMediaBoxTags();
}
return this.#tagsAndAliases;
}
get imageId(): number {
const imageId = this.container.dataset.imageId;
if (!imageId) {
throw new Error('Missing image ID');
}
return parseInt(imageId);
}
get imageLinks(): App.ImageURIs {
const jsonUris = this.#thumbnailContainer?.dataset.uris;
if (!jsonUris) {
throw new Error('Missing URIs!');
}
return JSON.parse(jsonUris);
}
/**
* Wrap the media box element into the special wrapper.
*/
static initialize(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
new MediaBox(mediaBoxContainer)
.initialize();
for (let childComponentElement of childComponentElements) {
mediaBoxContainer.appendChild(childComponentElement);
getComponent(childComponentElement)?.initialize();
}
}
static findElements(): NodeListOf<HTMLElement> {
return document.querySelectorAll('.media-box');
}
static initializePositionCalculation(mediaBoxesList: NodeListOf<HTMLElement>) {
window.addEventListener('resize', () => {
let lastMediaBox: HTMLElement | null = null;
let lastMediaBoxPosition: number | null = null;
for (const mediaBoxElement of mediaBoxesList) {
const yPosition = mediaBoxElement.getBoundingClientRect().y;
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
lastMediaBox = mediaBoxElement;
lastMediaBoxPosition = yPosition;
}
// Last-ever media box is checked separately
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
lastMediaBox.classList.add('media-box--last');
}
})
}
}

View File

@@ -1,291 +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, 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,23 +0,0 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { ImageListInfo } from "$content/components/philomena/listing/ImageListInfo";
export class ImageListContainer extends BaseComponent {
#info: ImageListInfo | null = null;
protected build() {
const imageListInfoContainer = this.container.querySelector<HTMLElement>('.js-imagelist-info');
if (imageListInfoContainer) {
this.#info = new ImageListInfo(imageListInfoContainer);
this.#info.initialize();
}
}
static findAndInitialize() {
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
if (imageListContainer) {
new ImageListContainer(imageListContainer).initialize();
}
}
}

View File

@@ -1,53 +0,0 @@
import { amdLite } from "amd-lite";
const originalDefine = amdLite.define;
/**
* Set of already defined modules. Used for deduplication.
*/
const definedModules = new Set<string>();
/**
* Throttle timer to make sure only one attempt at loading modules will run for a batch of loaded scripts.
*/
let throttledAutoRunTimer: NodeJS.Timeout | number | undefined;
/**
* Schedule the automatic resolving of all waiting modules on the next available frame.
*/
function scheduleModulesAutoRun() {
clearTimeout(throttledAutoRunTimer);
throttledAutoRunTimer = setTimeout(() => {
amdLite.resolveDependencies(Object.keys(amdLite.waitingModules));
});
}
amdLite.define = (name, dependencies, originalCallback) => {
// Chrome doesn't run the same content script multiple times, while Firefox does. Since each content script and their
// chunks are intended to be run only once, we should just ignore any attempts of running the same module more than
// once. Names of the modules are assumed to be unique.
if (definedModules.has(name)) {
return;
}
definedModules.add(name);
originalDefine(name, dependencies, function () {
const callbackResult = originalCallback(...arguments);
// Workaround for the entry script not returning anything causing AMD-Lite to send warning about modules not
// being loaded/not existing.
return typeof callbackResult !== 'undefined' ? callbackResult : {};
});
// Schedule the auto run on the next available frame. Firefox and Chromium have a lot of differences in how they
// decide to execute content scripts. For example, Firefox might decide to skip a frame before attempting to load
// different groups of them. Chromium on the other hand doesn't have that issue, but it doesn't allow us to, for
// example, schedule a microtask to run the modules.
scheduleModulesAutoRun();
}
amdLite.init({
publicScope: window
});

7
src/content/header.ts Normal file
View File

@@ -0,0 +1,7 @@
import { initializeSiteHeader } from "$lib/components/SiteHeaderWrapper";
const siteHeader = document.querySelector<HTMLElement>('.header');
if (siteHeader) {
initializeSiteHeader(siteHeader);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
/**
* Build the map containing both real tags and their aliases.
*
* @param realAndAliasedTags List combining aliases and tag names.
* @param realTags List of actual tag names, excluding aliases.
*
* @return Map where key is a tag or alias and value is an actual tag name.
*/
export function buildTagsAndAliasesMap(realAndAliasedTags: string[], realTags: string[]): Map<string, string> {
const tagsAndAliasesMap: Map<string, string> = new Map();
for (const tagName of realTags) {
tagsAndAliasesMap.set(tagName, tagName);
}
let realTagName: string | null = null;
for (const tagNameOrAlias of realAndAliasedTags) {
if (tagsAndAliasesMap.has(tagNameOrAlias)) {
realTagName = tagNameOrAlias;
continue;
}
if (!realTagName) {
console.warn('No real tag found for the alias:', tagNameOrAlias);
continue;
}
tagsAndAliasesMap.set(tagNameOrAlias, realTagName);
}
return tagsAndAliasesMap;
}

View File

@@ -1,7 +1,7 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import MiscPreferences, { type FullscreenViewerSize } from "$lib/extension/preferences/MiscPreferences";
import { emit, on } from "$content/components/events/comms";
import { EVENT_SIZE_LOADED } from "$content/components/events/fullscreen-viewer-events";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
import { emit, on } from "$lib/components/events/comms";
import { EVENT_SIZE_LOADED } from "$lib/components/events/fullscreen-viewer-events";
export class FullscreenViewer extends BaseComponent {
#videoElement: HTMLVideoElement = document.createElement('video');
@@ -53,8 +53,8 @@ export class FullscreenViewer extends BaseComponent {
this.#imageElement.addEventListener('load', this.#onLoaded.bind(this));
this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation());
FullscreenViewer.#preferences
.fullscreenViewerSize.get()
FullscreenViewer.#miscSettings
.resolveFullscreenViewerPreviewSize()
.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.#preferences.subscribe(settings => {
FullscreenViewer.#miscSettings.subscribe(settings => {
const targetSize = settings.fullscreenViewerSize;
if (!targetSize || lastActiveSize === targetSize) {
@@ -202,7 +202,7 @@ export class FullscreenViewer extends BaseComponent {
}
lastActiveSize = targetSize;
void FullscreenViewer.#preferences.fullscreenViewerSize.set(targetSize as FullscreenViewerSize);
void FullscreenViewer.#miscSettings.setFullscreenViewerPreviewSize(targetSize);
});
}
@@ -289,7 +289,7 @@ export class FullscreenViewer extends BaseComponent {
return url.endsWith('.mp4') || url.endsWith('.webm');
}
static #preferences = new MiscPreferences();
static #miscSettings = new MiscSettings();
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 MiscPreferences from "$lib/extension/preferences/MiscPreferences";
import { FullscreenViewer } from "$content/components/extension/FullscreenViewer";
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import MiscSettings from "$lib/extension/settings/MiscSettings";
import { FullscreenViewer } from "$lib/components/FullscreenViewer";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
export class ImageShowFullscreenButton extends BaseComponent {
#mediaBoxTools: MediaBoxTools | null = null;
@@ -10,6 +10,8 @@ export class ImageShowFullscreenButton extends BaseComponent {
protected build() {
this.container.innerText = '🔍';
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
}
protected init() {
@@ -25,14 +27,14 @@ export class ImageShowFullscreenButton extends BaseComponent {
this.on('click', this.#onButtonClicked.bind(this));
if (ImageShowFullscreenButton.#preferences) {
ImageShowFullscreenButton.#preferences.fullscreenViewer.get()
if (ImageShowFullscreenButton.#miscSettings) {
ImageShowFullscreenButton.#miscSettings.resolveFullscreenViewerEnabled()
.then(isEnabled => {
this.#isFullscreenButtonEnabled = isEnabled;
this.#updateFullscreenButtonVisibility();
})
.then(() => {
ImageShowFullscreenButton.#preferences?.subscribe(settings => {
ImageShowFullscreenButton.#miscSettings?.subscribe(settings => {
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
this.#updateFullscreenButtonVisibility();
})
@@ -56,15 +58,6 @@ 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 {
@@ -83,5 +76,14 @@ export class ImageShowFullscreenButton extends BaseComponent {
return viewer;
}
static #preferences = new MiscPreferences();
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;
}

View File

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

View File

@@ -1,10 +1,10 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { getComponent } from "$content/components/base/component-utils";
import { on } from "$content/components/events/comms";
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$content/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { on } from "$lib/components/events/comms";
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
export class TaggingProfileStatusIcon extends BaseComponent {
export class MaintenanceStatusIcon extends BaseComponent {
#mediaBoxTools: MediaBoxTools | null = null;
build() {
@@ -52,13 +52,13 @@ export class TaggingProfileStatusIcon extends BaseComponent {
this.container.innerText = '❓';
}
}
static create(): HTMLElement {
const element = document.createElement('div');
element.classList.add('maintenance-status-icon');
new TaggingProfileStatusIcon(element);
return element;
}
}
export function createMaintenanceStatusIcon() {
const element = document.createElement('div');
element.classList.add('maintenance-status-icon');
new MaintenanceStatusIcon(element);
return element;
}

View File

@@ -0,0 +1,74 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { MaintenancePopup } from "$lib/components/MaintenancePopup";
import { on } from "$lib/components/events/comms";
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxWrapper } from "$lib/components/MediaBoxWrapper";
import type MaintenanceProfile from "$entities/MaintenanceProfile";
export class MediaBoxTools extends BaseComponent {
#mediaBox: MediaBoxWrapper | null = null;
#maintenancePopup: MaintenancePopup | null = null;
init() {
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
if (!mediaBoxElement) {
throw new Error('Toolbox element initialized outside of the media box!');
}
this.#mediaBox = getComponent(mediaBoxElement);
for (let childElement of this.container.children) {
if (!(childElement instanceof HTMLElement)) {
continue;
}
const component = getComponent(childElement);
if (!component) {
continue;
}
if (!component.isInitialized) {
component.initialize();
}
if (!this.#maintenancePopup && component instanceof MaintenancePopup) {
this.#maintenancePopup = component;
}
}
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
}
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
}
get maintenancePopup(): MaintenancePopup | null {
return this.#maintenancePopup;
}
get mediaBox(): MediaBoxWrapper | null {
return this.#mediaBox;
}
}
/**
* Create a maintenance popup element.
* @param childrenElements List of children elements to append to the component.
* @return The maintenance popup element.
*/
export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement {
const mediaBoxToolsContainer = document.createElement('div');
mediaBoxToolsContainer.classList.add('media-box-tools');
if (childrenElements.length) {
mediaBoxToolsContainer.append(...childrenElements);
}
new MediaBoxTools(mediaBoxToolsContainer);
return mediaBoxToolsContainer;
}

View File

@@ -0,0 +1,99 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
import { on } from "$lib/components/events/comms";
import { EVENT_TAGS_UPDATED } from "$lib/components/events/maintenance-popup-events";
export class MediaBoxWrapper extends BaseComponent {
#thumbnailContainer: HTMLElement | null = null;
#imageLinkElement: HTMLAnchorElement | null = null;
#tagsAndAliases: Map<string, string> | null = null;
init() {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
const updatedMap = tagsUpdatedEvent.detail;
if (!(updatedMap instanceof Map)) {
throw new TypeError("Tags and aliases should be stored as Map!");
}
this.#tagsAndAliases = updatedMap;
}
#calculateMediaBoxTags() {
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
return buildTagsAndAliasesMap(tagAliases, actualTags);
}
get tagsAndAliases(): Map<string, string> | null {
if (!this.#tagsAndAliases) {
this.#tagsAndAliases = this.#calculateMediaBoxTags();
}
return this.#tagsAndAliases;
}
get imageId(): number {
const imageId = this.container.dataset.imageId;
if (!imageId) {
throw new Error('Missing image ID');
}
return parseInt(imageId);
}
get imageLinks(): App.ImageURIs {
const jsonUris = this.#thumbnailContainer?.dataset.uris;
if (!jsonUris) {
throw new Error('Missing URIs!');
}
return JSON.parse(jsonUris);
}
}
/**
* Wrap the media box element into the special wrapper.
*/
export function initializeMediaBox(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
new MediaBoxWrapper(mediaBoxContainer)
.initialize();
for (let childComponentElement of childComponentElements) {
mediaBoxContainer.appendChild(childComponentElement);
getComponent(childComponentElement)?.initialize();
}
}
export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf<HTMLElement>) {
window.addEventListener('resize', () => {
let lastMediaBox: HTMLElement | null = null;
let lastMediaBoxPosition: number | null = null;
for (const mediaBoxElement of mediaBoxesList) {
const yPosition = mediaBoxElement.getBoundingClientRect().y;
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
lastMediaBox = mediaBoxElement;
lastMediaBoxPosition = yPosition;
}
// Last-ever media box is checked separately
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
lastMediaBox.classList.add('media-box--last');
}
})
}

View File

@@ -0,0 +1,429 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { QueryLexer, QuotedTermToken, TermToken, Token } from "$lib/booru/search/QueryLexer";
import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings";
export class SearchWrapper extends BaseComponent {
#searchField: HTMLInputElement | null = null;
#lastParsedSearchValue: string | null = null;
#cachedParsedQuery: Token[] = [];
#searchSettings: SearchSettings = new SearchSettings();
#arePropertiesSuggestionsEnabled: boolean = false;
#propertiesSuggestionsPosition: SuggestionsPosition = "start";
#cachedAutocompleteContainer: HTMLElement | null = null;
#lastTermToken: TermToken | QuotedTermToken | null = null;
build() {
this.#searchField = this.container.querySelector('input[name=q]');
}
init() {
if (this.#searchField) {
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this))
}
this.#searchSettings.resolvePropertiesSuggestionsEnabled()
.then(isEnabled => this.#arePropertiesSuggestionsEnabled = isEnabled);
this.#searchSettings.resolvePropertiesSuggestionsPosition()
.then(position => this.#propertiesSuggestionsPosition = position);
this.#searchSettings.subscribe(settings => {
this.#arePropertiesSuggestionsEnabled = Boolean(settings.suggestProperties);
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition || "start";
});
}
/**
* Catch the user input and execute suggestions logic.
* @param event Source event to find the input element from.
*/
#onInputFindProperties(event: Event) {
// Ignore events until option is enabled.
if (!this.#arePropertiesSuggestionsEnabled || !(event.currentTarget instanceof HTMLInputElement)) {
return;
}
const currentFragment = this.#findCurrentTagFragment();
if (!currentFragment) {
return;
}
this.#renderSuggestions(
SearchWrapper.#resolveSuggestionsFromTerm(currentFragment),
event.currentTarget
);
}
/**
* Get the selection position in the search field.
*/
#getInputUserSelection(): number {
if (!this.#searchField) {
throw new Error('Missing search field!');
}
return Math.min(
this.#searchField.selectionStart ?? 0,
this.#searchField.selectionEnd ?? 0,
);
}
/**
* Parse the search query and return the list of parsed tokens. Result will be cached for current search query.
*/
#resolveQueryTokens(): Token[] {
if (!this.#searchField) {
throw new Error('Missing search field!');
}
const searchValue = this.#searchField.value;
if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) {
return this.#cachedParsedQuery;
}
this.#lastParsedSearchValue = searchValue;
this.#cachedParsedQuery = new QueryLexer(searchValue).parse();
return this.#cachedParsedQuery;
}
/**
* Find the currently selected term.
* @return Selected term or null if none found.
*/
#findCurrentTagFragment(): string | null {
if (!this.#searchField) {
return null;
}
let searchValue = this.#searchField.value;
if (!searchValue) {
this.#lastTermToken = null;
return null;
}
const token = SearchWrapper.#findActiveSearchTermPosition(
this.#resolveQueryTokens(),
this.#getInputUserSelection(),
);
if (token instanceof TermToken) {
this.#lastTermToken = token;
return token.value;
}
if (token instanceof QuotedTermToken) {
this.#lastTermToken = token;
return token.decodedValue;
}
this.#lastTermToken = null;
return searchValue;
}
/**
* Resolve the autocomplete container from the document. Once resolved, it can be safely reused without breaking
* anything. Assuming refactored autocomplete handler is still implemented the way it is.
*
* This means, that properties will only be suggested once actual autocomplete logic was activated.
*
* @return Resolved element or nothing.
*/
#resolveAutocompleteContainer(): HTMLElement | null {
if (this.#cachedAutocompleteContainer) {
return this.#cachedAutocompleteContainer;
}
this.#cachedAutocompleteContainer = document.querySelector('.autocomplete');
return this.#cachedAutocompleteContainer;
}
/**
* Render the list of suggestions into the existing popup or create and populate a new one.
* @param suggestions List of suggestion to render the popup from.
* @param targetInput Target input to attach the popup to.
*/
#renderSuggestions(suggestions: string[], targetInput: HTMLInputElement) {
const suggestedListItems = suggestions
.map(suggestedTerm => this.#renderTermSuggestion(suggestedTerm));
requestAnimationFrame(() => {
const autocompleteContainer = this.#resolveAutocompleteContainer();
if (!autocompleteContainer) {
return;
}
// Since the autocomplete popup was refactored to re-use the same element over and over again, we need to remove
// the options from the popup manually when autocomplete was removed from the DOM, since site is not doing that.
const termsToRemove = autocompleteContainer.isConnected
// Only removing properties when element is still connected to the DOM (popup is used by the website)
? autocompleteContainer.querySelectorAll('.autocomplete__item--property')
// Remove everything if popup was disconnected from the DOM.
: autocompleteContainer.querySelectorAll('.autocomplete__item')
for (let existingTerm of termsToRemove) {
existingTerm.remove();
}
const listContainer = autocompleteContainer.querySelector('ul');
if (!listContainer) {
return;
}
switch (this.#propertiesSuggestionsPosition) {
case "start":
listContainer.prepend(...suggestedListItems);
break;
case "end":
listContainer.append(...suggestedListItems);
break;
default:
console.warn("Invalid position for property suggestions!");
}
const parentScrollTop = targetInput.parentElement?.scrollTop ?? 0;
autocompleteContainer.style.position = 'absolute';
autocompleteContainer.style.left = `${targetInput.offsetLeft}px`;
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - parentScrollTop}px`;
document.body.append(autocompleteContainer);
})
}
/**
* Loosely estimate where current selected search term is located and return it if found.
* @param tokens Search value to find the actively selected term from.
* @param userSelectionIndex The index of the user selection.
* @return Search term object or NULL if nothing found.
*/
static #findActiveSearchTermPosition(tokens: Token[], userSelectionIndex: number): Token | null {
return tokens.find(
token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex
) ?? null;
}
/**
* Regular expression to search the properties' syntax.
*/
static #propertySearchTermHeadingRegExp = /^(?<name>[a-z\d_]+)(?<op_syntax>\.(?<op>[a-z]*))?(?<value_syntax>:(?<value>.*))?$/;
/**
* Create a list of suggested elements using the input received from the user.
* @param searchTermValue Original decoded term received from the user.
* @return {string[]} List of suggestions. Could be empty.
*/
static #resolveSuggestionsFromTerm(searchTermValue: string): string[] {
const suggestionsList: string[] = [];
this.#propertySearchTermHeadingRegExp.lastIndex = 0;
const parsedResult = this.#propertySearchTermHeadingRegExp.exec(searchTermValue);
if (!parsedResult) {
return suggestionsList;
}
const propertyName = parsedResult.groups?.name;
if (!propertyName) {
return suggestionsList;
}
const propertyType = this.#properties.get(propertyName);
const hasOperatorSyntax = Boolean(parsedResult.groups?.op_syntax);
const hasValueSyntax = Boolean(parsedResult.groups?.value_syntax);
// No suggestions for values for now, maybe could add suggestions for namespaces like my:*
if (hasValueSyntax && propertyType) {
if (this.#typeValues.has(propertyType)) {
const givenValue = parsedResult.groups?.value;
const candidateValues = this.#typeValues.get(propertyType) || [];
for (let candidateValue of candidateValues) {
if (givenValue && !candidateValue.startsWith(givenValue)) {
continue;
}
suggestionsList.push(`${propertyName}${parsedResult.groups?.op_syntax ?? ''}:${candidateValue}`);
}
}
return suggestionsList;
}
// If at least one dot placed, start suggesting operators
if (hasOperatorSyntax && propertyType) {
if (this.#typeOperators.has(propertyType)) {
const operatorName = parsedResult.groups?.op;
const candidateOperators = this.#typeOperators.get(propertyType) ?? [];
for (let candidateOperator of candidateOperators) {
if (operatorName && !candidateOperator.startsWith(operatorName)) {
continue;
}
suggestionsList.push(`${propertyName}.${candidateOperator}:`);
}
}
return suggestionsList;
}
// Otherwise, search for properties with names starting with the term
for (let [candidateProperty] of this.#properties) {
if (propertyName && !candidateProperty.startsWith(propertyName)) {
continue;
}
suggestionsList.push(candidateProperty);
}
return suggestionsList;
}
/**
* Render a single suggestion item and connect required events to interact with the user.
* @param suggestedTerm Term to use for suggestion item.
* @return Resulting element.
*/
#renderTermSuggestion(suggestedTerm: string): HTMLElement {
const suggestionItem = document.createElement('li');
suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property');
suggestionItem.dataset.value = suggestedTerm;
suggestionItem.innerText = suggestedTerm;
const propertyIcon = document.createElement('i');
propertyIcon.classList.add('fa', 'fa-info-circle');
suggestionItem.insertAdjacentElement('afterbegin', propertyIcon);
suggestionItem.addEventListener('mouseover', () => {
SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem);
suggestionItem.classList.add('autocomplete__item--selected');
});
suggestionItem.addEventListener('mouseout', () => {
SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem);
});
suggestionItem.addEventListener('click', () => {
this.#replaceLastActiveTokenWithSuggestion(suggestedTerm);
});
return suggestionItem;
}
/**
* Automatically replace the last active token stored in the variable with the new value.
* @param suggestedTerm Term to replace the value with.
*/
#replaceLastActiveTokenWithSuggestion(suggestedTerm: string) {
if (!this.#lastTermToken || !this.#searchField) {
return;
}
const searchQuery = this.#searchField.value;
const beforeToken = searchQuery.substring(0, this.#lastTermToken.index);
const afterToken = searchQuery.substring(this.#lastTermToken.index + this.#lastTermToken.value.length);
let replacementValue = suggestedTerm;
if (replacementValue.includes('"')) {
replacementValue = `"${QuotedTermToken.encode(replacementValue)}"`
}
this.#searchField.value = beforeToken + replacementValue + afterToken;
}
/**
* Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's
* front-end.
* @param suggestedElement Target element to search from. If element is disconnected from the DOM, search will be
* halted.
*/
static #findAndResetSelectedSuggestion(suggestedElement: HTMLElement) {
if (!suggestedElement.parentElement) {
return;
}
for (let selectedElement of suggestedElement.parentElement.querySelectorAll('.autocomplete__item--selected')) {
selectedElement.classList.remove('autocomplete__item--selected');
}
}
static #typeNumeric = Symbol();
static #typeDate = Symbol();
static #typeLiteral = Symbol();
static #typePersonal = Symbol();
static #typeBoolean = Symbol();
static #properties = new Map([
['animated', SearchWrapper.#typeBoolean],
['aspect_ratio', SearchWrapper.#typeNumeric],
['body_type_tag_count', SearchWrapper.#typeNumeric],
['character_tag_count', SearchWrapper.#typeNumeric],
['comment_count', SearchWrapper.#typeNumeric],
['content_fanmade_tag_count', SearchWrapper.#typeNumeric],
['content_official_tag_count', SearchWrapper.#typeNumeric],
['created_at', SearchWrapper.#typeDate],
['description', SearchWrapper.#typeLiteral],
['downvotes', SearchWrapper.#typeNumeric],
['duration', SearchWrapper.#typeNumeric],
['error_tag_count', SearchWrapper.#typeNumeric],
['faved_by', SearchWrapper.#typeLiteral],
['faved_by_id', SearchWrapper.#typeNumeric],
['faves', SearchWrapper.#typeNumeric],
['file_name', SearchWrapper.#typeLiteral],
['first_seen_at', SearchWrapper.#typeDate],
['height', SearchWrapper.#typeNumeric],
['id', SearchWrapper.#typeNumeric],
['oc_tag_count', SearchWrapper.#typeNumeric],
['orig_sha512_hash', SearchWrapper.#typeLiteral],
['original_format', SearchWrapper.#typeLiteral],
['pixels', SearchWrapper.#typeNumeric],
['rating_tag_count', SearchWrapper.#typeNumeric],
['score', SearchWrapper.#typeNumeric],
['sha512_hash', SearchWrapper.#typeLiteral],
['size', SearchWrapper.#typeNumeric],
['source_count', SearchWrapper.#typeNumeric],
['source_url', SearchWrapper.#typeLiteral],
['species_tag_count', SearchWrapper.#typeNumeric],
['spoiler_tag_count', SearchWrapper.#typeNumeric],
['tag_count', SearchWrapper.#typeNumeric],
['updated_at', SearchWrapper.#typeDate],
['uploader', SearchWrapper.#typeLiteral],
['uploader_id', SearchWrapper.#typeNumeric],
['upvotes', SearchWrapper.#typeNumeric],
['width', SearchWrapper.#typeNumeric],
['wilson_score', SearchWrapper.#typeNumeric],
['my', SearchWrapper.#typePersonal],
]);
static #comparisonOperators = ['gt', 'gte', 'lt', 'lte'];
static #typeOperators = new Map([
[SearchWrapper.#typeNumeric, SearchWrapper.#comparisonOperators],
[SearchWrapper.#typeDate, SearchWrapper.#comparisonOperators],
]);
static #typeValues = new Map([
[SearchWrapper.#typePersonal, [
'comments',
'faves',
'posts',
'uploads',
'upvotes',
'watched',
]],
[SearchWrapper.#typeBoolean, [
'true',
'false',
]]
]);
}

View File

@@ -0,0 +1,22 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { SearchWrapper } from "$lib/components/SearchWrapper";
class SiteHeaderWrapper extends BaseComponent {
#searchWrapper: SearchWrapper | null = null;
build() {
const searchForm = this.container.querySelector<HTMLElement>('.header__search');
this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null;
}
init() {
if (this.#searchWrapper) {
this.#searchWrapper.initialize();
}
}
}
export function initializeSiteHeader(siteHeaderElement: HTMLElement) {
new SiteHeaderWrapper(siteHeaderElement)
.initialize();
}

View File

@@ -1,14 +1,16 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import TaggingProfile from "$entities/TaggingProfile";
import TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences";
import { getComponent } from "$content/components/base/component-utils";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
import { getComponent } from "$lib/components/base/component-utils";
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
import { on } from "$content/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdown-events";
import { on } from "$lib/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
import type TagGroup from "$entities/TagGroup";
export class TagDropdown extends BaseComponent {
const categoriesResolver = new CustomCategoriesResolver();
export class TagDropdownWrapper extends BaseComponent {
/**
* Container with dropdown elements to insert options into.
*/
@@ -27,7 +29,7 @@ export class TagDropdown extends BaseComponent {
/**
* Local clone of the currently active profile used for updating the list of tags.
*/
#activeProfile: TaggingProfile | null = null;
#activeProfile: MaintenanceProfile | null = null;
/**
* Is cursor currently entered the dropdown.
@@ -44,7 +46,7 @@ export class TagDropdown extends BaseComponent {
this.on('mouseenter', this.#onDropdownEntered.bind(this));
this.on('mouseleave', this.#onDropdownLeft.bind(this));
TagDropdown.#watchActiveProfile(activeProfileOrNull => {
TagDropdownWrapper.#watchActiveProfile(activeProfileOrNull => {
this.#activeProfile = activeProfileOrNull;
if (this.#isEntered) {
@@ -120,7 +122,7 @@ export class TagDropdown extends BaseComponent {
#updateButtons() {
if (!this.#activeProfile) {
this.#addToNewButton ??= TagDropdown.#createDropdownLink(
this.#addToNewButton ??= TagDropdownWrapper.#createDropdownLink(
'Add to new tagging profile',
this.#onAddToNewClicked.bind(this)
);
@@ -133,7 +135,7 @@ export class TagDropdown extends BaseComponent {
}
if (this.#activeProfile) {
this.#toggleOnExistingButton ??= TagDropdown.#createDropdownLink(
this.#toggleOnExistingButton ??= TagDropdownWrapper.#createDropdownLink(
'Add to existing tagging profile',
this.#onToggleInExistingClicked.bind(this)
);
@@ -146,12 +148,7 @@ export class TagDropdown extends BaseComponent {
profileSpecificButtonText = `Remove from profile "${profileName}"`;
}
if (this.#toggleOnExistingButton.lastChild instanceof Text) {
this.#toggleOnExistingButton.lastChild.textContent = ` ${profileSpecificButtonText}`;
} else {
// Just in case last child is missing, then update the text on the full element.
this.#toggleOnExistingButton.textContent = profileSpecificButtonText;
}
this.#toggleOnExistingButton.innerText = profileSpecificButtonText;
if (!this.#toggleOnExistingButton.isConnected) {
this.#dropdownContainer?.append(this.#toggleOnExistingButton);
@@ -170,14 +167,14 @@ export class TagDropdown extends BaseComponent {
throw new Error('Missing tag name to create the profile!');
}
const profile = new TaggingProfile(crypto.randomUUID(), {
const profile = new MaintenanceProfile(crypto.randomUUID(), {
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
tags: [this.tagName],
temporary: true,
});
await profile.save();
await TagDropdown.#preferences.activeProfile.set(profile.id);
await TagDropdownWrapper.#maintenanceSettings.setActiveProfileId(profile.id);
}
async #onToggleInExistingClicked() {
@@ -203,25 +200,25 @@ export class TagDropdown extends BaseComponent {
await this.#activeProfile.save();
}
static #preferences = new TaggingProfilesPreferences();
static #maintenanceSettings = new MaintenanceSettings();
/**
* Watch for changes to active profile.
* @param onActiveProfileChange Callback to call when profile was
* changed.
*/
static #watchActiveProfile(onActiveProfileChange: (profile: TaggingProfile | null) => void) {
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile | null) => void) {
let lastActiveProfile: string | null = null;
this.#preferences.subscribe((settings) => {
this.#maintenanceSettings.subscribe((settings) => {
lastActiveProfile = settings.activeProfile ?? null;
this.#preferences
.activeProfile.asObject()
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(onActiveProfileChange);
});
TaggingProfile.subscribe(profiles => {
MaintenanceProfile.subscribe(profiles => {
const activeProfile = profiles
.find(profile => profile.id === lastActiveProfile);
@@ -229,8 +226,8 @@ export class TagDropdown extends BaseComponent {
);
});
this.#preferences
.activeProfile.asObject()
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(activeProfile => {
lastActiveProfile = activeProfile?.id ?? null;
onActiveProfileChange(activeProfile);
@@ -246,14 +243,9 @@ export class TagDropdown extends BaseComponent {
static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement {
const dropdownLink = document.createElement('a');
dropdownLink.href = '#';
dropdownLink.innerText = text;
dropdownLink.className = 'tag__dropdown__link';
const dropdownLinkIcon = document.createElement('i');
dropdownLinkIcon.classList.add('fa', 'fa-tags');
dropdownLink.textContent = ` ${text}`;
dropdownLink.insertAdjacentElement('afterbegin', dropdownLinkIcon);
dropdownLink.addEventListener('click', event => {
event.preventDefault();
onClickHandler(event);
@@ -261,65 +253,58 @@ export class TagDropdown extends BaseComponent {
return dropdownLink;
}
static #categoriesResolver = new CustomCategoriesResolver();
static #processedElements: WeakSet<HTMLElement> = new WeakSet();
static #findAll(parentNode: ParentNode = document): NodeListOf<HTMLElement> {
return parentNode.querySelectorAll('.tag.dropdown');
}
static #initialize(element: HTMLElement) {
// Skip initialization when tag component is already wrapped
if (getComponent(element)) {
return;
}
const tagDropdown = new TagDropdown(element);
tagDropdown.initialize();
this.#categoriesResolver.addElement(tagDropdown);
}
static findAllAndInitialize(parentNode: ParentNode = document) {
for (const element of this.#findAll(parentNode)) {
this.#initialize(element);
}
}
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;
}
document.body.addEventListener('mouseover', event => {
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) {
return;
}
if (this.#processedElements.has(targetElement)) {
return;
}
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
if (!closestTagEditor || this.#processedElements.has(closestTagEditor)) {
this.#processedElements.add(targetElement);
return;
}
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);
});
}
}
export function wrapTagDropdown(element: HTMLElement) {
// Skip initialization when tag component is already wrapped
if (getComponent(element)) {
return;
}
const tagDropdown = new TagDropdownWrapper(element);
tagDropdown.initialize();
categoriesResolver.addElement(tagDropdown);
}
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;
}
document.body.addEventListener('mouseover', event => {
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) {
return;
}
if (processedElementsSet.has(targetElement)) {
return;
}
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
if (!closestTagEditor || processedElementsSet.has(closestTagEditor)) {
processedElementsSet.add(targetElement);
return;
}
processedElementsSet.add(targetElement);
processedElementsSet.add(closestTagEditor);
for (const tagDropdownElement of closestTagEditor.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
});
// 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);
}
});
}

View File

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

View File

@@ -1,11 +1,11 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import type TagGroup from "$entities/TagGroup";
import type { TagDropdown } from "$content/components/philomena/TagDropdown";
import { on } from "$content/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
import { getComponent } from "$content/components/base/component-utils";
import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdown-events";
import TagsPreferences from "$lib/extension/preferences/TagsPreferences";
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
import { on } from "$lib/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
import { getComponent } from "$lib/components/base/component-utils";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
import TagSettings from "$lib/extension/settings/TagSettings";
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');
#preferences = new TagsPreferences();
#tagSettings = new TagSettings();
#shouldDisplaySeparation = false;
#separatedGroups = new Map<string, TagGroup>();
#separatedHeaders = new Map<string, HTMLElement>();
#groupsCount = new Map<string, number>();
#lastTagGroup = new WeakMap<TagDropdown, TagGroup | null>;
#lastTagGroup = new WeakMap<TagDropdownWrapper, TagGroup | null>;
#isReorderingPlanned = false;
@@ -44,8 +44,8 @@ export class TagsListBlock extends BaseComponent {
}
init() {
this.#preferences.groupSeparation.get().then(this.#onTagSeparationChange.bind(this));
this.#preferences.subscribe(settings => {
this.#tagSettings.resolveGroupSeparation().then(this.#onTagSeparationChange.bind(this));
this.#tagSettings.subscribe(settings => {
this.#onTagSeparationChange(Boolean(settings.groupSeparation))
});
@@ -80,7 +80,7 @@ export class TagsListBlock extends BaseComponent {
return;
}
const tagDropdown = getComponent<TagDropdown>(maybeDropdownElement);
const tagDropdown = getComponent<TagDropdownWrapper>(maybeDropdownElement);
if (!tagDropdown) {
return;
@@ -103,7 +103,7 @@ export class TagsListBlock extends BaseComponent {
#onToggleGroupingClicked(event: Event) {
event.preventDefault();
void this.#preferences.groupSeparation.set(!this.#shouldDisplaySeparation);
void this.#tagSettings.setGroupSeparation(!this.#shouldDisplaySeparation);
}
#handleTagGroupChanges(tagGroup: TagGroup) {
@@ -134,7 +134,6 @@ export class TagsListBlock extends BaseComponent {
heading.style.display = 'none';
heading.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(group.id)}, 0)`;
heading.style.flexBasis = '100%';
heading.classList.add('tag-category-headline');
// We're inserting heading to the top just to make sure that heading is always in front of the tags related to
// this category.
@@ -146,7 +145,7 @@ export class TagsListBlock extends BaseComponent {
heading.innerText = group.settings.name;
}
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdown) {
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdownWrapper) {
const previousGroupId = this.#lastTagGroup.get(tagComponent)?.id;
const currentGroupId = resolvedGroup?.id;
const isDifferentId = currentGroupId !== previousGroupId;
@@ -217,28 +216,28 @@ export class TagsListBlock extends BaseComponent {
static #iconGroupingDisabled = 'fa-folder';
static #iconGroupingEnabled = 'fa-folder-tree';
}
static initializeAll() {
for (let element of document.querySelectorAll<HTMLElement>('#image_tags_and_source')) {
if (getComponent(element)) {
return;
}
new TagsListBlock(element)
.initialize();
export function initializeAllTagsLists() {
for (let element of document.querySelectorAll<HTMLElement>('#image_tags_and_source')) {
if (getComponent(element)) {
return;
}
}
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();
})
new TagsListBlock(element)
.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,4 +1,4 @@
import { bindComponent } from "$content/components/base/component-utils";
import { bindComponent } from "$lib/components/base/component-utils";
type ComponentEventListener<EventName extends keyof HTMLElementEventMap> =
(this: HTMLElement, event: HTMLElementEventMap[EventName]) => void;

View File

@@ -1,4 +1,4 @@
import type { BaseComponent } from "$content/components/base/BaseComponent";
import type { BaseComponent } from "$lib/components/base/BaseComponent";
const instanceSymbol = Symbol.for('instance');

View File

@@ -0,0 +1,5 @@
export const EVENT_FETCH_COMPLETE = 'fetchcomplete';
export interface BooruEventsMap {
[EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation.
}

View File

@@ -1,18 +1,16 @@
import type { MaintenancePopupEventsMap } from "$content/components/events/maintenance-popup-events";
import { BaseComponent } from "$content/components/base/BaseComponent";
import type { FullscreenViewerEventsMap } from "$content/components/events/fullscreen-viewer-events";
import type { BooruEventsMap } from "$content/components/events/booru-events";
import type { TagsFormEventsMap } from "$content/components/events/tags-form-events";
import type { TagDropdownEvents } from "$content/components/events/tag-dropdown-events";
import type { PresetBlockEventsMap } from "$content/components/events/preset-block-events";
import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenance-popup-events";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events";
import type { BooruEventsMap } from "$lib/components/events/booru-events";
import type { TagsFormEventsMap } from "$lib/components/events/tags-form-events";
import type { TagDropdownEvents } from "$lib/components/events/tag-dropdown-events";
type EventsMapping =
MaintenancePopupEventsMap
& FullscreenViewerEventsMap
& BooruEventsMap
& TagsFormEventsMap
& TagDropdownEvents
& PresetBlockEventsMap;
& TagDropdownEvents;
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
export type UnsubscribeFunction = () => void;

View File

@@ -1,4 +1,4 @@
import type { FullscreenViewerSize } from "$lib/extension/preferences/MiscPreferences";
import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
export const EVENT_SIZE_LOADED = 'size-loaded';

View File

@@ -1,4 +1,4 @@
import type TaggingProfile from "$entities/TaggingProfile";
import type MaintenanceProfile from "$entities/MaintenanceProfile";
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]: TaggingProfile | null;
[EVENT_ACTIVE_PROFILE_CHANGED]: MaintenanceProfile | null;
[EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState;
[EVENT_TAGS_UPDATED]: Map<string, string> | null;
}

View File

@@ -0,0 +1,19 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { ImageListInfo } from "$lib/components/listing/ImageListInfo";
export class ImageListContainer extends BaseComponent {
#info: ImageListInfo | null = null;
protected build() {
const imageListInfoContainer = this.container.querySelector<HTMLElement>('.js-imagelist-info');
if (imageListInfoContainer) {
this.#info = new ImageListInfo(imageListInfoContainer);
this.#info.initialize();
}
}
}
export function initializeImageListContainer(element: HTMLElement) {
new ImageListContainer(element).initialize();
}

View File

@@ -1,4 +1,4 @@
import { BaseComponent } from "$content/components/base/BaseComponent";
import { BaseComponent } from "$lib/components/base/BaseComponent";
export class ImageListInfo extends BaseComponent {
#tagElement: HTMLElement | null = null;

View File

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

View File

@@ -1,11 +0,0 @@
/**
* 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

@@ -1,124 +0,0 @@
import type StorageEntity from "$lib/extension/base/StorageEntity";
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
import type { ImportableElementsList, ImportableEntityObject } from "$lib/extension/transporting/importables";
import EntitiesTransporter, { type SameSiteStatus } from "$lib/extension/EntitiesTransporter";
import TaggingProfile from "$entities/TaggingProfile";
import TagGroup from "$entities/TagGroup";
import TagEditorPreset from "$entities/TagEditorPreset";
type TransportersMapping = {
[EntityName in keyof App.EntityNamesMap]: EntitiesTransporter<App.EntityNamesMap[EntityName]>;
}
export default class BulkEntitiesTransporter {
#lastSameSiteStatus: SameSiteStatus = null;
get lastImportSameSiteStatus() {
return this.#lastSameSiteStatus;
}
parseAndImportFromJSON(jsonString: string): StorageEntity[] {
let parsedObject: any;
this.#lastSameSiteStatus = null;
try {
parsedObject = JSON.parse(jsonString);
} catch (e) {
throw new TypeError('Invalid JSON!', {cause: e});
}
if (!BulkEntitiesTransporter.isList(parsedObject)) {
throw new TypeError('Invalid or unsupported object!');
}
this.#lastSameSiteStatus = EntitiesTransporter.checkIsSameSiteImportedObject(parsedObject);
let hasDifferentStatuses = false;
const resultEntities = parsedObject.elements
.map(importableObject => {
if (!(importableObject.$type in BulkEntitiesTransporter.#transporters)) {
console.warn('Attempting to import unsupported entity: ' + importableObject.$type);
return null;
}
const transporter = BulkEntitiesTransporter.#transporters[importableObject.$type as keyof App.EntityNamesMap];
const resultEntity = transporter.importFromObject(importableObject);
if (transporter.lastImportSameSiteStatus !== this.#lastSameSiteStatus) {
hasDifferentStatuses = true;
}
return resultEntity;
})
.filter(maybeEntity => !!maybeEntity);
if (hasDifferentStatuses) {
this.#lastSameSiteStatus = 'unknown';
}
return resultEntities;
}
parseAndImportFromCompressedJSON(compressedJsonString: string): StorageEntity[] {
return this.parseAndImportFromJSON(
decompressFromEncodedURIComponent(compressedJsonString)
);
}
exportToJSON(entities: StorageEntity[]): string {
return JSON.stringify({
$type: 'list',
$site: __CURRENT_SITE__,
elements: entities
.map(entity => {
switch (true) {
case entity instanceof TaggingProfile:
return BulkEntitiesTransporter.#transporters.profiles.exportToObject(entity);
case entity instanceof TagGroup:
return BulkEntitiesTransporter.#transporters.groups.exportToObject(entity);
case entity instanceof TagEditorPreset:
return BulkEntitiesTransporter.#transporters.presets.exportToObject(entity);
}
return null;
})
.filter(value => !!value)
} as ImportableElementsList<ImportableEntityObject<StorageEntity>>, null, 2);
}
exportToCompressedJSON(entities: StorageEntity[]): string {
return compressToEncodedURIComponent(
this.exportToJSON(entities)
);
}
static isList(targetObject: any): targetObject is ImportableElementsList<ImportableEntityObject<StorageEntity>> {
return targetObject.$type
&& targetObject.$type === 'list'
&& targetObject.elements
&& Array.isArray(targetObject.elements);
}
static #transporters: TransportersMapping = {
profiles: new EntitiesTransporter(TaggingProfile),
groups: new EntitiesTransporter(TagGroup),
presets: new EntitiesTransporter(TagEditorPreset),
}
/**
* Check if the imported object is created for the same site extension or not.
* @param importedObject Object to check.
* @private
*/
static #checkIsSameSiteImportedObject(importedObject: Record<string, any>): SameSiteStatus {
if (!('$site' in importedObject)) {
return "unknown";
}
return importedObject.$site === __CURRENT_SITE__
? "same"
: "different";
}
}

View File

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

View File

@@ -2,46 +2,17 @@ import { validateImportedEntity } from "$lib/extension/transporting/validators";
import { exportEntityToObject } from "$lib/extension/transporting/exporters";
import StorageEntity from "$lib/extension/base/StorageEntity";
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
import type { ImportableElement } from "$lib/extension/transporting/importables";
/**
* Status of the last import.
*
* - `NULL` - no import was done yet or was unsuccessful.
* - `"unknown"` — imported object was created before v0.5, when extension started to be built for multiple sites.
* - `"same"` — imported object is marked as generated by the same type of extension.
* - `"different"` — imported object is marked as generated by some other type of extension.
*/
export type SameSiteStatus = null | "unknown" | "same" | "different";
export default class EntitiesTransporter<EntityType> {
readonly #targetEntityConstructor: new (...any: any[]) => EntityType;
#lastSameSiteStatus: SameSiteStatus = null;
/**
* Read the status of the last successful import. This flag could be used to determine if it was for the same site as
* the current extension or when it's generated before site identity was passed to the importable object.
*
* @see {SameSiteStatus} For the list of possible statuses.
*/
get lastImportSameSiteStatus() {
return this.#lastSameSiteStatus;
}
/**
* Name of the entity, exported directly from the constructor.
* @private
*/
get #entityName() {
// How the hell should I even do this?
const entityName = ((this.#targetEntityConstructor as any) as typeof StorageEntity)._entityName;
if (entityName === "entity") {
throw new Error("Generic entity name encountered!");
}
return entityName;
return ((this.#targetEntityConstructor as any) as typeof StorageEntity)._entityName;
}
/**
@@ -55,47 +26,32 @@ export default class EntitiesTransporter<EntityType> {
this.#targetEntityConstructor = entityConstructor;
}
isCorrectEntity(entityObject: unknown): entityObject is EntityType {
return entityObject instanceof this.#targetEntityConstructor;
}
importFromJSON(jsonString: string): EntityType {
const importedObject = this.#tryParsingAsJSON(jsonString);
importFromObject(importedObject: Record<string, any>): EntityType {
this.#lastSameSiteStatus = null;
if (!importedObject) {
throw new Error('Invalid JSON!');
}
// TODO: There should be an auto-upgrader somewhere before the validation. So if even the older version of schema
// was used, we still will will be able to pass the validation. For now we only have non-breaking changes.
validateImportedEntity(
this.#entityName,
importedObject,
this.#entityName
);
this.#lastSameSiteStatus = EntitiesTransporter.checkIsSameSiteImportedObject(importedObject);
return new this.#targetEntityConstructor(
importedObject.id,
importedObject
);
}
importFromJSON(jsonString: string): EntityType {
const importedObject = this.#tryParsingAsJSON(jsonString);
if (!importedObject) {
this.#lastSameSiteStatus = null;
throw new Error('Invalid JSON!');
}
return this.importFromObject(importedObject);
}
importFromCompressedJSON(compressedJsonString: string): EntityType {
return this.importFromJSON(
decompressFromEncodedURIComponent(compressedJsonString)
)
}
exportToObject(entityObject: EntityType) {
if (!this.isCorrectEntity(entityObject)) {
exportToJSON(entityObject: EntityType): string {
if (!(entityObject instanceof this.#targetEntityConstructor)) {
throw new TypeError('Transporter should be connected to the same entity to export!');
}
@@ -103,18 +59,12 @@ export default class EntitiesTransporter<EntityType> {
throw new TypeError('Only storage entities could be exported!');
}
return exportEntityToObject(
this.#entityName,
entityObject
const exportableObject = exportEntityToObject(
entityObject,
this.#entityName
);
}
exportToJSON(entityObject: EntityType): string {
return JSON.stringify(
this.exportToObject(entityObject),
null,
2
);
return JSON.stringify(exportableObject, null, 2);
}
exportToCompressedJSON(entityObject: EntityType): string {
@@ -136,18 +86,4 @@ export default class EntitiesTransporter<EntityType> {
return jsonObject
}
/**
* Check if the imported object is created for the same site extension or not.
* @param importedObject Object to check.
*/
static checkIsSameSiteImportedObject(importedObject: Record<string, any>): SameSiteStatus {
if (!('$site' in importedObject)) {
return "unknown";
}
return importedObject.$site === __CURRENT_SITE__
? "same"
: "different";
}
}

View File

@@ -1,179 +0,0 @@
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

@@ -0,0 +1,81 @@
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

@@ -24,22 +24,18 @@ export default abstract class StorageEntity<SettingsType extends Object = {}> {
return this.#settings;
}
get type() {
return (this.constructor as typeof StorageEntity)._entityName;
}
public static readonly _entityName: keyof App.EntityNamesMap | "entity" = "entity";
public static readonly _entityName: string = "entity";
async save() {
await EntitiesController.updateEntity(
this.type,
(this.constructor as typeof StorageEntity)._entityName,
this
);
}
async delete() {
await EntitiesController.deleteEntity(
this.type,
(this.constructor as typeof StorageEntity)._entityName,
this.id
);
}

View File

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

View File

@@ -1,17 +0,0 @@
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

@@ -21,5 +21,5 @@ export default class TagGroup extends StorageEntity<TagGroupSettings> {
});
}
public static readonly _entityName: keyof App.EntityNamesMap = "groups";
static _entityName = 'groups';
}

View File

@@ -1,27 +0,0 @@
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

@@ -1,40 +0,0 @@
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

@@ -1,28 +0,0 @@
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

@@ -0,0 +1,48 @@
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

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,30 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
export type SuggestionsPosition = "start" | "end";
interface SearchSettingsFields {
suggestProperties: boolean;
suggestPropertiesPosition: SuggestionsPosition;
}
export default class SearchSettings extends CacheableSettings<SearchSettingsFields> {
constructor() {
super("search");
}
async resolvePropertiesSuggestionsEnabled() {
return this._resolveSetting("suggestProperties", false);
}
async resolvePropertiesSuggestionsPosition() {
return this._resolveSetting("suggestPropertiesPosition", "start");
}
async setPropertiesSuggestions(isEnabled: boolean) {
return this._writeSetting("suggestProperties", isEnabled);
}
async setPropertiesSuggestionsPosition(position: "start" | "end") {
return this._writeSetting("suggestPropertiesPosition", position);
}
}

View File

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

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