Compare commits
217 Commits
0.4.2
...
f0083169f3
| Author | SHA1 | Date | |
|---|---|---|---|
| f0083169f3 | |||
| a416befcff | |||
| d840070a3e | |||
| 0bf2a35e9b | |||
| 8889c300a0 | |||
| 1a542e0fb7 | |||
| b973947070 | |||
| 7821cebb1b | |||
| 71fb565247 | |||
| 0ba81b1509 | |||
| f9fb2d66b8 | |||
| 2d7db61a76 | |||
| 4a3d7a1bb0 | |||
| 939b5fec20 | |||
| d7b7aa5b98 | |||
| 7f41a7e6f0 | |||
| 2a2a488592 | |||
| bda707b5ac | |||
| 74866949bb | |||
| 6c2ef795b3 | |||
| 58b620ef09 | |||
| 9445b1e862 | |||
| 9024883949 | |||
| dc29c6ca69 | |||
| 441091142c | |||
| 94733c9ff3 | |||
| d11cc2a9c5 | |||
| f4e30c60ad | |||
| 9031055ec9 | |||
| 8194a84ef7 | |||
| 2829ac022f | |||
| 5aac85dcaa | |||
| 9a14a5568d | |||
| a2ab0d4e7c | |||
| 5123b57320 | |||
| 2bdb789777 | |||
| 486ab9cafa | |||
| ba7b96d888 | |||
| b08937e47b | |||
| 1d332ea7d1 | |||
| 2920946015 | |||
| f879c45517 | |||
| 00083fdadb | |||
| 7ffee170c3 | |||
| db34b361b3 | |||
| bf81b7111f | |||
| dc79959b8f | |||
| dfdab180ee | |||
| b768f9072c | |||
| 72a731aaff | |||
| d181509d6f | |||
| 03b0763db4 | |||
| a7e0aefe6b | |||
| 687c12a8f4 | |||
| 9b7ba4a6e2 | |||
| 8d7b151911 | |||
| fccd79292d | |||
| 8041f2d2a1 | |||
| 3fac472ae0 | |||
| 44aca3120c | |||
| 3aee3defba | |||
| b7a9dc2a2b | |||
| 242dfc5972 | |||
| b6840996b6 | |||
| 4c5b796f1d | |||
| 7f2e06a1b1 | |||
| 31a33131cd | |||
| 7063459622 | |||
| 5a82b8751d | |||
| 9318bd51fa | |||
| ab625d0181 | |||
| c59d8f55f0 | |||
| 8dfc5f49f9 | |||
| 2ecd37512f | |||
| c8ff80d445 | |||
| 38cbd725d9 | |||
| 26f09c7c46 | |||
| 64be6a6e15 | |||
| cb22b2deab | |||
| 5c5e0812dc | |||
| 70129d7a0e | |||
| 5fd6dee999 | |||
| ec41ba5030 | |||
| 55624285e1 | |||
| b97255ccd6 | |||
| ef76560bfb | |||
| faa909a0db | |||
| 3955e3191e | |||
| 17dab5854c | |||
| a20632e58e | |||
| 5f4a1a6c00 | |||
| 48fc58f042 | |||
| 8356956b2e | |||
| 3833cada1e | |||
| f3d80b58b1 | |||
| d567ab4dec | |||
| e4322b3021 | |||
| 4907efdaab | |||
| c6b9250d71 | |||
| c330aa303a | |||
| 9ed3f6939d | |||
| 5584733b17 | |||
| 91947b8cc7 | |||
| df61c812fe | |||
| 65c420c36c | |||
| 79cd9bc44d | |||
| cf28d2d131 | |||
| 50238d8ef4 | |||
| 98b5311cfc | |||
| e60d20fd60 | |||
| 50a6d8ce32 | |||
| 7b532735ec | |||
| f139b76276 | |||
| eba81b72d4 | |||
| 5b93eb413b | |||
| 81247c1761 | |||
| 8d042a80a0 | |||
| 9c162bc2ce | |||
| 0deafb4a00 | |||
| 8822a2581b | |||
| 83d27cc966 | |||
| ae3c77031f | |||
| f39e060a6a | |||
| c811c13b70 | |||
| 234f80b992 | |||
| 4837184d40 | |||
| c37f680e9f | |||
| 2eefbf96ca | |||
| efd6522532 | |||
| b321c1049c | |||
| ce9a2b5f9b | |||
| 8fe1ca4914 | |||
| 48f278ae95 | |||
| 19ab302b54 | |||
| 189fda59c8 | |||
| 6bd7116df2 | |||
| 470021ee8c | |||
| e27257516d | |||
| 77293ba30c | |||
| b956b6f7bc | |||
| fcca26e128 | |||
| 69dc645de2 | |||
| 0781742dab | |||
| 71b067a77d | |||
| 6098a11115 | |||
| a87d8b94b8 | |||
| c283b96285 | |||
| 02478f0bf0 | |||
| 59c15f27eb | |||
| a58d8f0e15 | |||
| 2453bdf7b9 | |||
| 134e96bc4c | |||
| 1c05159ddf | |||
| 39c3f97846 | |||
| 9c19bd70c2 | |||
| d158b46dc6 | |||
| 966100d606 | |||
| 7cf2730402 | |||
| bdbe49b419 | |||
| ed779a8481 | |||
| bb14492578 | |||
| 30320e7283 | |||
| 8839373292 | |||
| 0e35d1d0ba | |||
| bca21da6d1 | |||
| 60491f57d4 | |||
| c26c4bcf62 | |||
| 1b4b646024 | |||
| 928fe5ddb0 | |||
| 6586141134 | |||
| d587bd2453 | |||
| e2eb8a0ca7 | |||
| 0876e5f001 | |||
| d5ad66d902 | |||
| cb6b5f4f9d | |||
| 193941b0ac | |||
| 562274b3d8 | |||
| 6faf5c8582 | |||
| e591751406 | |||
| c9347c375d | |||
| 68e134f2e4 | |||
| 338eb2bbb1 | |||
| 2933cd379e | |||
| 8fe2d718ff | |||
| b1ca67fc5b | |||
| 37095a2f22 | |||
| c1ed23dee5 | |||
| 8c51d2d482 | |||
| 16b72300a9 | |||
| 11af0f6484 | |||
| 4f302faf45 | |||
| bedb18a6aa | |||
| ea791838bf | |||
| ff16c62e26 | |||
| 45cc5b0eb3 | |||
| a2d884c969 | |||
| 74f987b5c9 | |||
| f687389516 | |||
| 92854f4d6b | |||
| 4ca9ff029b | |||
| 70e573ddc8 | |||
| 8e843c2b19 | |||
| 76e7bf1542 | |||
| d5ed86fb40 | |||
| dc0a9f0aa8 | |||
| 09edc44af8 | |||
| a9d53afdbe | |||
| ed263d2da4 | |||
| 9586d121e4 | |||
| 9d7f5c0f38 | |||
| f67a321a66 | |||
| 5dc41700b8 | |||
| c93c3c7bd5 | |||
| 459b1fa779 | |||
| 62dc38b35a | |||
| 07373e17d5 | |||
| 371bce133e |
BIN
.github/assets/colors-in-editor.png
vendored
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
.github/assets/derpibooru-colors-in-editor.png
vendored
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
.github/assets/derpibooru-groups-showcase-0.png
vendored
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
.github/assets/derpibooru-groups-showcase-1.png
vendored
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
.github/assets/derpibooru-preview-of-tag-link-replacement.png
vendored
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
.github/assets/fullscreen-viewer-icon.png
vendored
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
.github/assets/fullscreen-viewer-showcase.png
vendored
Normal file
|
After Width: | Height: | Size: 343 KiB |
BIN
.github/assets/groups-showcase-0.png
vendored
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
.github/assets/groups-showcase-1.png
vendored
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
.github/assets/groups-showcase.png
vendored
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
.github/assets/profiles-showcase.png
vendored
Normal file
|
After Width: | Height: | Size: 156 KiB |
63
.github/workflows/build-extensions.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Build Extensions
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
build-extensions:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
site: [furbooru, derpibooru, tantabus]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build extension for ${{ matrix.site }}
|
||||
run: |
|
||||
if [ "${{ matrix.site }}" = "furbooru" ]; then
|
||||
npm run build
|
||||
else
|
||||
npm run build:${{ matrix.site }}
|
||||
fi
|
||||
|
||||
- name: Create extension zip
|
||||
run: |
|
||||
cd build
|
||||
zip -r "../${{ matrix.site }}-tagging-assistant-extension.zip" .
|
||||
|
||||
- name: Upload extension artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.site }}-tagging-assistant-extension
|
||||
path: ${{ matrix.site }}-tagging-assistant-extension.zip
|
||||
retention-days: 30
|
||||
|
||||
create-release-artifacts:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-extensions
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create combined artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: all-extensions
|
||||
path: artifacts/
|
||||
retention-days: 90
|
||||
@@ -1,7 +1,9 @@
|
||||
import {build} from "vite";
|
||||
import {createHash} from "crypto";
|
||||
import { build } from "vite";
|
||||
import { createHash } from "crypto";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { SwapDefinedVariablesPlugin } from "../plugins/swap-defined-variables.js";
|
||||
import { ScssViteReadEnvVariableFunctionPlugin } from "../plugins/scss-read-env-variable-function.js";
|
||||
|
||||
/**
|
||||
* Create the result base file name for the file.
|
||||
@@ -49,6 +51,7 @@ function wrapScriptIntoIIFE() {
|
||||
function makeAliases(rootDir) {
|
||||
return {
|
||||
"$config": path.resolve(rootDir, 'src/config'),
|
||||
"$content": path.resolve(rootDir, 'src/content'),
|
||||
"$lib": path.resolve(rootDir, 'src/lib'),
|
||||
"$entities": path.resolve(rootDir, 'src/lib/extension/entities'),
|
||||
"$styles": path.resolve(rootDir, 'src/styles'),
|
||||
@@ -56,69 +59,198 @@ function makeAliases(rootDir) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the selected script separately.
|
||||
* @param {AssetBuildOptions} buildOptions Building options for the script.
|
||||
* @return {Promise<string>} Result file path.
|
||||
* @param {import('rollup').OutputChunk} chunk
|
||||
* @param {import('rollup').OutputBundle} bundle
|
||||
* @param {Set<import('rollup').OutputChunk>} processedChunks
|
||||
* @return string[]
|
||||
*/
|
||||
export async function buildScript(buildOptions) {
|
||||
const outputBaseName = createOutputBaseName(buildOptions.input);
|
||||
function collectChunkDependencies(chunk, bundle, processedChunks = new Set()) {
|
||||
if (processedChunks.has(chunk) || !chunk.imports) {
|
||||
return [];
|
||||
}
|
||||
|
||||
processedChunks.add(chunk);
|
||||
|
||||
return chunk.imports.concat(
|
||||
chunk.imports
|
||||
.map(importedChunkName => {
|
||||
const module = bundle[importedChunkName];
|
||||
|
||||
if (module.type === 'chunk') {
|
||||
return collectChunkDependencies(module, bundle, processedChunks);
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
.flat()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(fileName: string, dependencies: string[]) => void} onDependencyResolvedCallback
|
||||
* @returns {import('vite').Plugin}
|
||||
*/
|
||||
function collectDependenciesForManifestBuilding(onDependencyResolvedCallback) {
|
||||
return {
|
||||
name: 'extract-dependencies-for-content-scripts',
|
||||
enforce: "post",
|
||||
/**
|
||||
* @param {any} options
|
||||
* @param {import('rollup').OutputBundle} bundle
|
||||
*/
|
||||
writeBundle(options, bundle) {
|
||||
Object.keys(bundle).forEach(fileName => {
|
||||
const chunk = bundle[fileName];
|
||||
|
||||
if (chunk.type !== "chunk" || !chunk.facadeModuleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dependencies = Array.from(
|
||||
new Set(
|
||||
collectChunkDependencies(chunk, bundle)
|
||||
)
|
||||
);
|
||||
|
||||
onDependencyResolvedCallback(fileName, dependencies);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Second revision of the building logic for the content scripts. This method tries to address duplication of
|
||||
* dependencies generated with the previous method, where every single content script was built separately.
|
||||
* @param {BatchBuildOptions} buildOptions
|
||||
* @returns {Promise<Map<string, string[]>>}
|
||||
*/
|
||||
export async function buildScriptsAndStyles(buildOptions) {
|
||||
/** @type {Map<string, string[]>} */
|
||||
const pathsReplacement = new Map();
|
||||
/** @type {Map<string, string[]>} */
|
||||
const pathsReplacementByOutputPath = new Map();
|
||||
|
||||
const amdScriptsInput = {};
|
||||
const libsAndStylesInput = {};
|
||||
|
||||
for (const inputPath of buildOptions.inputs) {
|
||||
let outputExtension = path.extname(inputPath);
|
||||
|
||||
if (outputExtension === '.scss') {
|
||||
outputExtension = '.css';
|
||||
}
|
||||
|
||||
if (outputExtension === '.ts') {
|
||||
outputExtension = '.js';
|
||||
}
|
||||
|
||||
const outputPath = createOutputBaseName(inputPath);
|
||||
const replacementsArray = [`${outputPath}${outputExtension}`];
|
||||
|
||||
pathsReplacement.set(inputPath, replacementsArray);
|
||||
|
||||
if (outputExtension === '.css' || inputPath.includes('/deps/')) {
|
||||
libsAndStylesInput[outputPath] = inputPath;
|
||||
continue;
|
||||
}
|
||||
|
||||
pathsReplacementByOutputPath.set(outputPath + '.js', replacementsArray);
|
||||
|
||||
amdScriptsInput[outputPath] = inputPath;
|
||||
}
|
||||
|
||||
const aliasesSettings = makeAliases(buildOptions.rootDir);
|
||||
const defineConstants = {
|
||||
__CURRENT_SITE__: JSON.stringify('furbooru'),
|
||||
__CURRENT_SITE_NAME__: JSON.stringify('Furbooru'),
|
||||
};
|
||||
|
||||
const derpibooruSwapPlugin = SwapDefinedVariablesPlugin({
|
||||
envVariable: 'SITE',
|
||||
expectedValue: 'derpibooru',
|
||||
define: {
|
||||
__CURRENT_SITE__: JSON.stringify('derpibooru'),
|
||||
__CURRENT_SITE_NAME__: JSON.stringify('Derpibooru'),
|
||||
}
|
||||
});
|
||||
|
||||
const tantabusSwapPlugin = SwapDefinedVariablesPlugin({
|
||||
envVariable: 'SITE',
|
||||
expectedValue: 'tantabus',
|
||||
define: {
|
||||
__CURRENT_SITE__: JSON.stringify('tantabus'),
|
||||
__CURRENT_SITE_NAME__: JSON.stringify('Tantabus'),
|
||||
}
|
||||
});
|
||||
|
||||
// Building all scripts together with AMD loader in mind
|
||||
await build({
|
||||
configFile: false,
|
||||
publicDir: false,
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
[outputBaseName]: buildOptions.input
|
||||
},
|
||||
input: amdScriptsInput,
|
||||
output: {
|
||||
dir: buildOptions.outputDir,
|
||||
entryFileNames: '[name].js'
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: 'chunks/[name]-[hash].js',
|
||||
// ManifestV3 doesn't allow to use modern ES modules syntax, so we build all content scripts as AMD modules.
|
||||
format: "amd",
|
||||
inlineDynamicImports: false,
|
||||
amd: {
|
||||
// amd-lite requires names even for the entry-point scripts, so we should make sure to add those.
|
||||
autoId: true,
|
||||
},
|
||||
// All these modules are not intended to be used outside of extension anyway
|
||||
minifyInternalExports: true,
|
||||
}
|
||||
},
|
||||
emptyOutDir: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: makeAliases(buildOptions.rootDir)
|
||||
alias: aliasesSettings,
|
||||
},
|
||||
plugins: [
|
||||
wrapScriptIntoIIFE()
|
||||
]
|
||||
wrapScriptIntoIIFE(),
|
||||
collectDependenciesForManifestBuilding((fileName, dependencies) => {
|
||||
pathsReplacementByOutputPath
|
||||
.get(fileName)
|
||||
?.push(...dependencies);
|
||||
}),
|
||||
derpibooruSwapPlugin,
|
||||
tantabusSwapPlugin,
|
||||
],
|
||||
define: defineConstants,
|
||||
});
|
||||
|
||||
return path.resolve(buildOptions.outputDir, `${outputBaseName}.js`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the selected stylesheet.
|
||||
* @param {AssetBuildOptions} buildOptions Build options for the stylesheet.
|
||||
* @return {Promise<string>} Result file path.
|
||||
*/
|
||||
export async function buildStyle(buildOptions) {
|
||||
const outputBaseName = createOutputBaseName(buildOptions.input);
|
||||
|
||||
// Build styles separately because AMD converts styles to JS files.
|
||||
await build({
|
||||
configFile: false,
|
||||
publicDir: false,
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
[outputBaseName]: buildOptions.input
|
||||
},
|
||||
input: libsAndStylesInput,
|
||||
output: {
|
||||
dir: buildOptions.outputDir,
|
||||
entryFileNames: '[name].js',
|
||||
assetFileNames: '[name].[ext]',
|
||||
}
|
||||
},
|
||||
emptyOutDir: false,
|
||||
emptyOutDir: false
|
||||
},
|
||||
resolve: {
|
||||
alias: makeAliases(buildOptions.rootDir)
|
||||
}
|
||||
alias: aliasesSettings,
|
||||
},
|
||||
plugins: [
|
||||
wrapScriptIntoIIFE(),
|
||||
ScssViteReadEnvVariableFunctionPlugin(),
|
||||
derpibooruSwapPlugin,
|
||||
tantabusSwapPlugin,
|
||||
],
|
||||
define: defineConstants,
|
||||
});
|
||||
|
||||
return path.resolve(buildOptions.outputDir, `${outputBaseName}.css`);
|
||||
return pathsReplacement;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,3 +259,11 @@ export async function buildStyle(buildOptions) {
|
||||
* @property {string} outputDir Destination folder for the script.
|
||||
* @property {string} rootDir Root directory of the repository.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BatchBuildOptions
|
||||
* @property {Set<string>} inputs Set of all scripts and styles to build.
|
||||
* @property {string} outputDir Destination folder for the assets.
|
||||
* @property {string} rootDir Root directory of the repository.
|
||||
* @property {(fileName: string, dependencies: string[]) => void} onDependenciesResolved Callback for dependencies.
|
||||
*/
|
||||
|
||||
@@ -17,6 +17,38 @@ class ManifestProcessor {
|
||||
this.#manifestObject = parsedManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all the content scripts & stylesheets for single build action.
|
||||
*
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
collectContentScripts() {
|
||||
const contentScripts = this.#manifestObject.content_scripts;
|
||||
|
||||
if (!contentScripts) {
|
||||
console.info('No content scripts to collect.');
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const entryPoints = new Set();
|
||||
|
||||
for (let entry of contentScripts) {
|
||||
if (entry.js) {
|
||||
for (let jsPath of entry.js) {
|
||||
entryPoints.add(jsPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.css) {
|
||||
for (let cssPath of entry.css) {
|
||||
entryPoints.add(cssPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entryPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map over every content script defined in the manifest. If no content scripts defined, no calls will be made to the
|
||||
* callback.
|
||||
@@ -54,6 +86,48 @@ class ManifestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all patterns in content scripts and host permissions and replace the hostname to the different one.
|
||||
*
|
||||
* @param {string|string[]} singleOrMultipleHostnames One or multiple hostnames to replace the original hostname with.
|
||||
*/
|
||||
replaceHostTo(singleOrMultipleHostnames) {
|
||||
if (typeof singleOrMultipleHostnames === 'string') {
|
||||
singleOrMultipleHostnames = [singleOrMultipleHostnames];
|
||||
}
|
||||
|
||||
const matchPatterReplacer = ManifestProcessor.#createHostnameReplacementReduce(singleOrMultipleHostnames);
|
||||
|
||||
this.#manifestObject.host_permissions = singleOrMultipleHostnames.map(hostname => `*://*.${hostname}/`);
|
||||
|
||||
this.#manifestObject.content_scripts?.forEach(entry => {
|
||||
entry.matches = entry.matches.reduce(matchPatterReplacer, []);
|
||||
|
||||
if (entry.exclude_matches) {
|
||||
entry.exclude_matches = entry.exclude_matches.reduce(matchPatterReplacer, []);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set different identifier for Gecko-based browsers (Firefox).
|
||||
*
|
||||
* @param {string} id ID of the extension to use.
|
||||
*/
|
||||
setGeckoIdentifier(id) {
|
||||
this.#manifestObject.browser_specific_settings.gecko.id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the different extension name.
|
||||
*
|
||||
* @param {string} booruName
|
||||
*/
|
||||
replaceBooruNameWith(booruName) {
|
||||
this.#manifestObject.name = this.#manifestObject.name.replaceAll('Furbooru', booruName);
|
||||
this.#manifestObject.description = this.#manifestObject.description.replaceAll('Furbooru', booruName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current state of the manifest into the selected file.
|
||||
*
|
||||
@@ -69,6 +143,32 @@ class ManifestProcessor {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|(string[])} singleOrMultipleHostnames
|
||||
* @return {function(string[], string): string[]}
|
||||
*/
|
||||
static #createHostnameReplacementReduce(singleOrMultipleHostnames) {
|
||||
return (
|
||||
/**
|
||||
* @param {string[]} resultMatches
|
||||
* @param {string} originalMatchPattern
|
||||
* @return {string[]}
|
||||
*/
|
||||
(resultMatches, originalMatchPattern) => {
|
||||
for (const updatedHostname of singleOrMultipleHostnames) {
|
||||
resultMatches.push(
|
||||
originalMatchPattern.replace(
|
||||
/\*:\/\/\*\.[a-z]+\.[a-z]+\//,
|
||||
`*://*.${updatedHostname}/`
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return resultMatches;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,13 +186,28 @@ export function loadManifest(filePath) {
|
||||
|
||||
/**
|
||||
* @typedef {Object} Manifest
|
||||
* @property {string} name
|
||||
* @property {string} description
|
||||
* @property {string} version
|
||||
* @property {BrowserSpecificSettings} browser_specific_settings
|
||||
* @property {string[]} host_permissions
|
||||
* @property {ContentScriptsEntry[]|undefined} content_scripts
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BrowserSpecificSettings
|
||||
* @property {GeckoSettings} gecko
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} GeckoSettings
|
||||
* @property {string} id
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentScriptsEntry
|
||||
* @property {string[]} mathces
|
||||
* @property {string[]} matches
|
||||
* @property {string[]} exclude_matches
|
||||
* @property {string[]|undefined} js
|
||||
* @property {string[]|undefined} css
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {loadManifest} from "./lib/manifest.js";
|
||||
import { loadManifest } from "./lib/manifest.js";
|
||||
import path from "path";
|
||||
import {buildScript, buildStyle} from "./lib/content-scripts.js";
|
||||
import {normalizePath} from "vite";
|
||||
import {extractInlineScriptsFromIndex} from "./lib/index-file.js";
|
||||
import { buildScriptsAndStyles } from "./lib/content-scripts.js";
|
||||
import { extractInlineScriptsFromIndex } from "./lib/index-file.js";
|
||||
import { normalizePath } from "vite";
|
||||
import fs from "fs";
|
||||
|
||||
/**
|
||||
* Build addition assets required for the extension and pack it into the directory.
|
||||
@@ -11,49 +12,128 @@ import {extractInlineScriptsFromIndex} from "./lib/index-file.js";
|
||||
export async function packExtension(settings) {
|
||||
const manifest = loadManifest(path.resolve(settings.rootDir, 'manifest.json'));
|
||||
|
||||
// Since we CAN'T really build all scripts and stylesheets in a single build entry, we will run build for every single
|
||||
// one of them in a row. This way, no chunks will be generated. Thanks, ManifestV3!
|
||||
await manifest.mapContentScripts(async (entry) => {
|
||||
if (entry.js) {
|
||||
for (let scriptIndex = 0; scriptIndex < entry.js.length; scriptIndex++) {
|
||||
const builtScriptFilePath = await buildScript({
|
||||
input: path.resolve(settings.rootDir, entry.js[scriptIndex]),
|
||||
outputDir: settings.contentScriptsDir,
|
||||
rootDir: settings.rootDir,
|
||||
});
|
||||
const replacementMapping = await buildScriptsAndStyles({
|
||||
inputs: manifest.collectContentScripts(),
|
||||
outputDir: settings.contentScriptsDir,
|
||||
rootDir: settings.rootDir,
|
||||
});
|
||||
|
||||
entry.js[scriptIndex] = normalizePath(
|
||||
path.relative(
|
||||
settings.exportDir,
|
||||
builtScriptFilePath
|
||||
await manifest.mapContentScripts(async entry => {
|
||||
if (entry.js) {
|
||||
entry.js = entry.js
|
||||
.map(jsSourcePath => {
|
||||
if (!replacementMapping.has(jsSourcePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return replacementMapping.get(jsSourcePath);
|
||||
})
|
||||
.flat(1)
|
||||
.map(pathName => {
|
||||
return normalizePath(
|
||||
path.relative(
|
||||
settings.exportDir,
|
||||
path.join(
|
||||
settings.contentScriptsDir,
|
||||
pathName
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.css) {
|
||||
for (let styleIndex = 0; styleIndex < entry.css.length; styleIndex++) {
|
||||
const builtStylesheetFilePath = await buildStyle({
|
||||
input: path.resolve(settings.rootDir, entry.css[styleIndex]),
|
||||
outputDir: settings.contentScriptsDir,
|
||||
rootDir: settings.rootDir
|
||||
});
|
||||
entry.css = entry.css
|
||||
.map(jsSourcePath => {
|
||||
if (!replacementMapping.has(jsSourcePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
entry.css[styleIndex] = normalizePath(
|
||||
path.relative(
|
||||
settings.exportDir,
|
||||
builtStylesheetFilePath
|
||||
return replacementMapping.get(jsSourcePath);
|
||||
})
|
||||
.flat(1)
|
||||
.map(pathName => {
|
||||
return normalizePath(
|
||||
path.relative(
|
||||
settings.exportDir,
|
||||
path.join(
|
||||
settings.contentScriptsDir,
|
||||
pathName
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return entry;
|
||||
});
|
||||
})
|
||||
|
||||
switch (process.env.SITE) {
|
||||
case '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'));
|
||||
}
|
||||
|
||||
|
||||
46
.vite/plugins/scss-read-env-variable-function.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { SassString, Value } from "sass";
|
||||
|
||||
/**
|
||||
* @return {import('vite').Plugin}
|
||||
*/
|
||||
export function ScssViteReadEnvVariableFunctionPlugin() {
|
||||
return {
|
||||
name: 'koloml:scss-read-env-variable-function',
|
||||
apply: 'build',
|
||||
enforce: 'post',
|
||||
|
||||
configResolved: config => {
|
||||
config.css.preprocessorOptions ??= {};
|
||||
config.css.preprocessorOptions.scss ??= {};
|
||||
config.css.preprocessorOptions.scss.functions ??= {};
|
||||
|
||||
/**
|
||||
* @param {Value[]} args
|
||||
* @return {SassString}
|
||||
*/
|
||||
config.css.preprocessorOptions.scss.functions['vite-read-env-variable($constant-name)'] = (args) => {
|
||||
const constName = args[0].assertString('constant-name').text;
|
||||
|
||||
if (config.define && config.define.hasOwnProperty(constName)) {
|
||||
let returnedValue = config.define[constName];
|
||||
|
||||
try {
|
||||
returnedValue = JSON.parse(returnedValue);
|
||||
} catch {
|
||||
returnedValue = null;
|
||||
}
|
||||
|
||||
if (typeof returnedValue !== 'string') {
|
||||
console.warn(`Attempting to read the constant with non-string type: ${constName}`);
|
||||
return new SassString('');
|
||||
}
|
||||
|
||||
return new SassString(returnedValue);
|
||||
}
|
||||
|
||||
console.warn(`Constant does not exist: ${constName}`);
|
||||
return new SassString('');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
.vite/plugins/swap-defined-variables.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @param {SwapDefinedVariablesSettings} settings
|
||||
* @return {import('vite').Plugin}
|
||||
*/
|
||||
export function SwapDefinedVariablesPlugin(settings) {
|
||||
return {
|
||||
name: 'koloml:swap-defined-variables',
|
||||
enforce: 'post',
|
||||
configResolved: (config) => {
|
||||
if (
|
||||
config.define
|
||||
&& process.env.hasOwnProperty(settings.envVariable)
|
||||
&& process.env[settings.envVariable] === settings.expectedValue
|
||||
) {
|
||||
for (const [key, value] of Object.entries(settings.define)) {
|
||||
config.define[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} SwapDefinedVariablesSettings
|
||||
* @property {string} envVariable
|
||||
* @property {string} expectedValue
|
||||
* @property {Record<string, string>} define
|
||||
*/
|
||||
65
README.md
@@ -1,10 +1,53 @@
|
||||
# Furbooru Tagging Assistant
|
||||
# Philomena Tagging Assistant
|
||||
|
||||
This is a browser extension written for the [Furbooru](https://furbooru.org), [Derpibooru](https://derpibooru.org) and
|
||||
[Tantabus](https://tantabus.ai) image-boards. It gives you the ability to manually go over the list of images and apply
|
||||
tags to them without opening each individual image.
|
||||
|
||||
## Installation
|
||||
|
||||
This extension is available for both Chromium- and Firefox-based browsers. You can find the links to the extension pages
|
||||
below.
|
||||
|
||||
### Furbooru
|
||||
|
||||
[](https://addons.mozilla.org/en-US/firefox/addon/furbooru-tagging-assistant/)
|
||||
[](https://chromewebstore.google.com/detail/kpgaphaooaaodgodmnkamhmoedjcnfkj)
|
||||
|
||||
This is a browser extension written for the [Furbooru](https://furbooru.org) image-board. It gives you the ability to
|
||||
tag the images more easily and quickly.
|
||||
### Derpibooru
|
||||
|
||||
[](https://addons.mozilla.org/en-US/firefox/addon/derpibooru-tagging-assistant/)
|
||||
[](https://chromewebstore.google.com/detail/pnmbomcdbfcghgmegklfofncfigdielb)
|
||||
|
||||
### Tantabus
|
||||
|
||||
[](https://addons.mozilla.org/en-US/firefox/addon/tantabus-tagging-assistant/)
|
||||
[](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!
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Building
|
||||
|
||||
@@ -19,11 +62,21 @@ npm install --save-dev
|
||||
```
|
||||
|
||||
Second, you need to run the `build` command. It will first build the popup using SvelteKit and then build all the
|
||||
content scripts/stylesheets and copy the manifest afterward. Simply run:
|
||||
content scripts/stylesheets and copy the manifest afterward.
|
||||
|
||||
Extension can currently be built for multiple different imageboards using one of the following commands:
|
||||
|
||||
```shell
|
||||
# Furbooru:
|
||||
npm run build
|
||||
|
||||
# Derpibooru:
|
||||
npm run build:derpibooru
|
||||
|
||||
# Tantabus:
|
||||
npm run build:tantabus
|
||||
```
|
||||
|
||||
When building is complete, resulting files can be found in the `/build` directory. These files can be either used
|
||||
directly in Chrome (via loading the extension as unpacked extension) or manually compressed into `*.zip` file.
|
||||
When build is complete, extension files can be found in the `/build` directory. These files can be either used
|
||||
directly in Chrome (via loading the extension as unpacked extension) or manually compressed into `*.zip` file and loaded
|
||||
into Firefox.
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"name": "Furbooru Tagging Assistant",
|
||||
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
|
||||
"version": "0.4.2",
|
||||
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
|
||||
"version": "0.7.0",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "furbooru-tagging-assistant@thecore.city"
|
||||
"id": "furbooru-tagging-assistant@thecore.city",
|
||||
"data_collection_permissions": {
|
||||
"required": [
|
||||
"none"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
@@ -18,6 +23,14 @@
|
||||
"*://*.furbooru.org/"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/deps/amd.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/",
|
||||
@@ -35,13 +48,18 @@
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/*"
|
||||
"*://*.furbooru.org/images/*"
|
||||
],
|
||||
"exclude_matches": [
|
||||
"*://*.furbooru.org/images/new",
|
||||
"*://*.furbooru.org/images/new?*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/header.ts"
|
||||
"src/content/tags-editor.ts"
|
||||
],
|
||||
"css": [
|
||||
"src/styles/content/header.scss"
|
||||
"src/styles/content/tags-editor.scss",
|
||||
"src/styles/content/tag-presets.scss"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -64,10 +82,26 @@
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/images/*"
|
||||
"*://*.furbooru.org/posts",
|
||||
"*://*.furbooru.org/posts?*",
|
||||
"*://*.furbooru.org/forums/*/topics/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/tags-editor.ts"
|
||||
"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"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
3127
package-lock.json
generated
44
package.json
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.4.2",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:popup && npm run build:extension",
|
||||
"build:derpibooru": "cross-env SITE=derpibooru npm run build",
|
||||
"build:tantabus": "cross-env SITE=tantabus npm run build",
|
||||
"build:popup": "vite build",
|
||||
"build:extension": "node build-extension.js",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
@@ -11,25 +14,26 @@
|
||||
"test": "vitest run --coverage",
|
||||
"test:watch": "vitest watch --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^4.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.17.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/chrome": "^0.0.304",
|
||||
"@vitest/coverage-v8": "^3.0.6",
|
||||
"cheerio": "^1.0.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"sass": "^1.85.0",
|
||||
"svelte": "^5.20.1",
|
||||
"svelte-check": "^4.1.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.0.6"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"lz-string": "^1.5.0"
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
19
src/app.d.ts
vendored
@@ -1,9 +1,23 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import type TaggingProfile from "$entities/TaggingProfile";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
import type TagEditorPreset from "$entities/TagEditorPreset";
|
||||
|
||||
declare global {
|
||||
/**
|
||||
* Identifier of the current site this extension is built for.
|
||||
*/
|
||||
const __CURRENT_SITE__: string;
|
||||
|
||||
/**
|
||||
* Name of the site.
|
||||
*/
|
||||
const __CURRENT_SITE_NAME__: string;
|
||||
|
||||
// Helper type to not deal with differences between the setTimeout of @types/node and usual web browser's type.
|
||||
type Timeout = ReturnType<typeof setTimeout>;
|
||||
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
@@ -24,8 +38,9 @@ declare global {
|
||||
);
|
||||
|
||||
interface EntityNamesMap {
|
||||
profiles: MaintenanceProfile;
|
||||
profiles: TaggingProfile;
|
||||
groups: TagGroup;
|
||||
presets: TagEditorPreset;
|
||||
}
|
||||
|
||||
interface ImageURIs {
|
||||
|
||||
57
src/assets/icon/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Extension Icon
|
||||
|
||||
This folder contains original resources used to make an icon for the extension. Since I'm not really an icon designer, I
|
||||
ended up just composing the icon from sites logos + the shorthand name of the extension with fancy font. Nothing
|
||||
special.
|
||||
|
||||
## Sources
|
||||
|
||||
All resources used for composing an icon are stored here as copies just to not lose anything. Original assets are
|
||||
sourced from the following places:
|
||||
|
||||
- [Derpibooru Logo](https://github.com/derpibooru/philomena/blob/40ffb1b75bd0d96db24fa7c84bce36fcb7f2935f/assets/static/favicon.svg)
|
||||
- [Furbooru Logo](https://github.com/furbooru/philomena/blob/cbfde406de34734403c06952bcaca51db6df1390/assets/static/favicon.svg)
|
||||
- [Tantabus Logo](https://github.com/tantabus-ai/philomena/blob/285a7666ae4be46ac4da36bbc9ac8fda9e5c0fc3/assets/static/favicon.svg)
|
||||
- [RoundFeather Font](https://drive.google.com/file/d/18ggNplAZNYtO4eNtMUpv3XpkeOAxSkxm/view?usp=sharing)
|
||||
- Made by [allorus162](https://bsky.app/profile/allorus162.bsky.social)
|
||||
- [Original Bluesky post](https://bsky.app/profile/allorus162.bsky.social/post/3mfqntff4j22i)
|
||||
|
||||
## Rendering
|
||||
|
||||
**Note:** You don't need to do anything to pack current version of icon to the extension. All icons are already pre-rendered and
|
||||
placed into the `static` directory.
|
||||
|
||||
For now, any change to the icons will require manual re-rendering of PNG versions of the logos used when packing
|
||||
extension for the release. All you need is to open `/src/assets/icon/icon.svg` in software like Inskape, hide the
|
||||
currently opened logo and toggle the required one and save it into `icon256.png`, `icon128.png`, `icon48.png` and
|
||||
`icon16.png`.
|
||||
|
||||
For the font on the bottom-right to work, you will need to install it from the file
|
||||
`src/assets/icon/fonts/roundfeather-regular-1.001.ttf` (or you can download and install it from the source link).
|
||||
|
||||
You should render them into `/static` directory in the following structure:
|
||||
|
||||
- Place Furbooru icons into `/static` directory
|
||||
- Then add same icons for Derpibooru and Tantabus into `/static/icons/depribooru` and `/static/icons/tantabus`
|
||||
respectively.
|
||||
|
||||
Resulting structure will look like this:
|
||||
|
||||
```
|
||||
static/
|
||||
icons/
|
||||
derpibooru/
|
||||
icon16.png
|
||||
icon48.png
|
||||
icon128.png
|
||||
icon256.png
|
||||
tantabus/
|
||||
icon16.png
|
||||
icon48.png
|
||||
icon128.png
|
||||
icon256.png
|
||||
icon16.png
|
||||
icon48.png
|
||||
icon128.png
|
||||
icon256.png
|
||||
```
|
||||
2
src/assets/icon/favicons/derpibooru.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="132.29mm" height="132.29mm" version="1.1" viewBox="0 0 132.29 132.29" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(1.0427 0 0 1.0427 -.017147 -1.1711)"><path d="m103.79 1.5c2.226 55.884-2.592 66.082-26.774 81.11-10.46 6.495-24.44-7.2571-36.56-5.1501-14.529 2.53-33.119 12.655-39.169 20.603l0.00607-0.0012c56.82 1.164 44.414 29.924 80.7 29.64 21.39-0.17 35.89-18.483 38.62-27.615 10.456-34.942 3.1379-65.073-16.823-98.587zm11.375 57.714c-1.6412 10.692-0.54281 11.478 6.66 16.938-8.649-2.0114-10.977 0.7243-14.702 8.2179 1.5999-8.243 0.4741-11.62-5.8526-15.099 8.3149 0.88078 9.4155 0.05081 13.895-10.057zm-34.884 33.692c-3.7887 11.898-1.8578 13.462 6.4355 18.092-12.032-2.4927-11.44 1.5364-16.965 7.6122 2.9076-9.5873 1.2336-12.084-5.6426-16.122 5.6959 0.20558 11.418 1.8392 16.172-9.5819z" style="fill:#73d6ed"/><path d="m69.863 33.123-42.793 78.941 5.4648 2.9629 42.791-78.943z" style="-inkscape-stroke:none;color:#000000;fill:#73d6ed"/><g style="fill:#73d6ed"><path d="m64.894 48.218 7.18-13.796" style="-inkscape-stroke:none;color:#000000;fill:#73d6ed;stroke-width:6.557"/></g><path d="m89.844 3.2499-14.504 13.697-16.245-10.04 7.31 17.535-16.03 14.152 21.282-2.9352 8.782 19.4 2.8299-22.61 19.26-1.614-17.67-8.8749zm-7.235 13.028-1.5585 8.3965 7.2681 3.9461-8.1864 1.1558-1.6952 9.6819-3.8619-8.8428-9.5827 1.4895 7.3746-6.5192-3.4781-7.6725 7.4099 4.1481z" style="fill:#73d6ed"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
2
src/assets/icon/favicons/furbooru.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="132.29mm" height="132.29mm" version="1.1" viewBox="0 0 132.29 132.29" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.13264 0 0 .13264 -.54013 .21654)"><path d="m396.42 189.81c-51.874-0.0769-99.544 21.731-131.29 68.784 3.6141-17.709 12.731-35.245 20.313-51.85-64.951 23.01-110.98 58.779-153.95 130.96 31.861 78.452 47.007 130.73 32.991 180.63-12.789 45.531-57.874 81.129-120.66 54.031 11.733 79.157 138.9 169.44 265.13 95.148-15.402 34.788-33.651 44.104-58.67 59.609 109.87 18.28 240.46-16.733 327.04-89.364 80.473-67.509 145.76-176.62 150.49-271.1-16.925 5.2436-48.396 10.423-61.314 8.6977-4.9416-13.958-6.5445-47.664-6.6378-65.502-78.741 1.4559-134.21-82.398-225.29-56.079 24.483-19.73 56.853-19.667 84.992-25.658-39.916-24.957-82.805-38.256-123.15-38.315zm46.019 201.23c33.445 0.29408 70.846 13.949 108.14 44.486-71.827-12.193-167.68 9.8684-228.78 100.49-3.4843-86.066 49.678-145.6 120.65-144.98z" style="fill:#9a91d9"/><path d="m70.559 99.836c-11.606 103.12 31.76 196.05 73.743 312.81 47.028 130.79-54.846 190.58-100.97 125.58-46.123-65.005-59.672-280.22 27.226-438.39z" style="fill:#9a91d9"/><path d="m126.15 320.07s-84.457-177.47-13.898-310.03c12.18 95.304 37.741 170.41 85.526 228.25-34.102 31.125-51.147 53.357-71.628 81.784z" style="fill:#9a91d9"/><path d="m54.523 683.55c36.454 189.97 245.77 300.87 372.04 295.07-11.047-9.7005-22.094-18.617-33.141-31.003 95.291 43.85 187.43 17.122 251.23-12.829-16.164-4.6041-32.272-9.3803-47.039-18.174 21.351-4.409 43.671-15.588 59.868-33.141-52.566-1.4772-102.82-10.573-151.86-54.928 57.575-28.86 90.002-66.925 102.7-97.767-13.158 6.0202-27.475 9.3163-40.636 10.507 12.007-23.538 20.064-48.835 23.52-78.043-232.6 178.14-441.6 75.628-536.68 20.312z" style="fill:#9a91d9"/><path d="m611.51 653.89c-3.4653 21.491-9.3328 46.627-17.472 65.294 25.751-12.728 37.33-30.294 47.406-48.456 0 0-10.691 113.32-106.91 158.22 0 0 57.784 50.56 163.62 32.385-12.482 20.32-26.396 37.375-64.404 55.584 57.955 10.976 153.12-24.053 185.6-80.951-28.742 9.1492-48.987 9.8028-69.933 11.156 160.72-63.788 92.562-249.91 248.03-342.1-61.514-30.693-156.71-52.064-253.37 42.763 9.4346-13.824 22.374-34.745 43.832-56.661-72.65 21.206-114.21 112.97-176.4 162.77z" style="fill:#9a91d9"/><path d="m676.96 308.22c-2.2133 13.699-3.9692 34.942 1.8899 51.493 15.962 2.1315 36.687-1.0078 49.243-8.4218-0.26672-23.09-4.5591-41.009-10.829-57.596-12.902 6.3961-22.273 10.895-40.304 14.525z" style="fill:#9a91d9"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
41
src/assets/icon/favicons/tantabus.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="250mm"
|
||||
height="250mm"
|
||||
version="1.1"
|
||||
viewBox="0 0 250 250"
|
||||
xml:space="preserve"
|
||||
id="svg10"
|
||||
sodipodi:docname="favicon.svg"
|
||||
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs14" /><sodipodi:namedview
|
||||
id="namedview12"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.46847003"
|
||||
inkscape:cx="662.79587"
|
||||
inkscape:cy="691.61308"
|
||||
inkscape:window-width="2048"
|
||||
inkscape:window-height="1403"
|
||||
inkscape:window-x="1966"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg10" /><path
|
||||
id="circle4"
|
||||
style="fill:#b189d7;stroke-width:1.0041"
|
||||
d="M 43.466348 113.64503 A 112.33 112.33 0 0 1 40.650026 88.648915 A 112.33 112.33 0 0 1 40.918168 81.481475 A 81.915001 81.915001 0 0 0 121.44996 148.71379 A 81.915001 81.915001 0 0 0 203.36481 66.799192 A 81.915001 81.915001 0 0 0 121.45008 -15.116154 A 81.915001 81.915001 0 0 0 107.06707 -13.789539 A 112.33 112.33 0 0 1 152.97993 -23.681174 A 112.33 112.33 0 0 1 265.31002 88.648731 A 112.33 112.33 0 0 1 152.98012 200.97882 A 112.33 112.33 0 0 1 43.466348 113.64503 z "
|
||||
transform="matrix(.25882 .96593 .96593 -.25882 0 0)" /><path
|
||||
d="m120.78 137.49c-13.186 22.457-39.753 18.697-46.615-10.102-3.3594-14.101 17.903-33.046 13.75-51.609-2.9261-13.079 16.12-43.432 15.115-40.727-5.9218 15.937-6.5312 30.238 1.25 42.264 16.946 26.186 24.595 46.387 16.499 60.175z"
|
||||
style="fill:#b189d7;stroke-linecap:round;stroke-linejoin:round;stroke-width:2.0644"
|
||||
id="path8" /></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/assets/icon/fonts/roundfeather-regular-1.001.ttf
Normal file
115
src/assets/icon/icon.svg
Normal file
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="500"
|
||||
height="500"
|
||||
version="1.1"
|
||||
viewBox="0 0 132.29167 132.29167"
|
||||
xml:space="preserve"
|
||||
id="svg6"
|
||||
sodipodi:docname="icon.svg"
|
||||
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs6" /><sodipodi:namedview
|
||||
id="namedview6"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="179"
|
||||
inkscape:cy="388.49999"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1009"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg6"
|
||||
showguides="false" /><g
|
||||
id="g19"
|
||||
transform="matrix(0.58884997,0,0,0.58884997,-7.6052124,-7.356684)"
|
||||
inkscape:label="tantabus"
|
||||
style="display:none"><path
|
||||
id="circle4"
|
||||
style="fill:#b189d7;stroke-width:1.0041"
|
||||
d="m 43.466348,113.64503 a 112.33,112.33 0 0 1 -2.816322,-24.996115 112.33,112.33 0 0 1 0.268142,-7.16744 81.915001,81.915001 0 0 0 80.531792,67.232315 81.915001,81.915001 0 0 0 81.91485,-81.914598 81.915001,81.915001 0 0 0 -81.91473,-81.915346 81.915001,81.915001 0 0 0 -14.38301,1.326615 112.33,112.33 0 0 1 45.91286,-9.891635 A 112.33,112.33 0 0 1 265.31002,88.648731 112.33,112.33 0 0 1 152.98012,200.97882 112.33,112.33 0 0 1 43.466348,113.64503 Z"
|
||||
transform="matrix(0.25882,0.96593,0.96593,-0.25882,0,0)" /><path
|
||||
d="M 120.78,137.49 C 107.594,159.947 81.027,156.187 74.165,127.388 70.8056,113.287 92.068,94.342 87.915,75.779 84.9889,62.7 104.035,32.347 103.03,35.052 c -5.9218,15.937 -6.5312,30.238 1.25,42.264 16.946,26.186 24.595,46.387 16.499,60.175 z"
|
||||
style="fill:#b189d7;stroke-width:2.0644;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="path8" /></g><g
|
||||
transform="matrix(1.048236,0,0,1.048236,-0.23527111,-1.5720411)"
|
||||
id="g4"
|
||||
inkscape:label="derpibooru"
|
||||
style="display:none"><path
|
||||
d="m 103.79,1.5 c 2.226,55.884 -2.592,66.082 -26.774,81.11 -10.46,6.495 -24.44,-7.2571 -36.56,-5.1501 -14.529,2.53 -33.119,12.655 -39.169,20.603 l 0.00607,-0.0012 c 56.82,1.164 44.414,29.924 80.7,29.64 21.39,-0.17 35.89,-18.483 38.62,-27.615 10.456,-34.942 3.1379,-65.073 -16.823,-98.587 z m 11.375,57.714 c -1.6412,10.692 -0.54281,11.478 6.66,16.938 -8.649,-2.0114 -10.977,0.7243 -14.702,8.2179 1.5999,-8.243 0.4741,-11.62 -5.8526,-15.099 8.3149,0.88078 9.4155,0.05081 13.895,-10.057 z M 80.281,92.906 c -3.7887,11.898 -1.8578,13.462 6.4355,18.092 -12.032,-2.4927 -11.44,1.5364 -16.965,7.6122 2.9076,-9.5873 1.2336,-12.084 -5.6426,-16.122 5.6959,0.20558 11.418,1.8392 16.172,-9.5819 z"
|
||||
style="fill:#73d6ed"
|
||||
id="path1-0" /><path
|
||||
d="m 69.863,33.123 -42.793,78.941 5.4648,2.9629 42.791,-78.943 z"
|
||||
style="color:#000000;fill:#73d6ed;-inkscape-stroke:none"
|
||||
id="path2-9" /><g
|
||||
style="fill:#73d6ed"
|
||||
id="g3"><path
|
||||
d="m 64.894,48.218 7.18,-13.796"
|
||||
style="color:#000000;fill:#73d6ed;stroke-width:6.557;-inkscape-stroke:none"
|
||||
id="path3-4" /></g><path
|
||||
d="m 89.844,3.2499 -14.504,13.697 -16.245,-10.04 7.31,17.535 -16.03,14.152 21.282,-2.9352 8.782,19.4 2.8299,-22.61 19.26,-1.614 -17.67,-8.8749 z m -7.235,13.028 -1.5585,8.3965 7.2681,3.9461 -8.1864,1.1558 -1.6952,9.6819 -3.8619,-8.8428 -9.5827,1.4895 7.3746,-6.5192 -3.4781,-7.6725 7.4099,4.1481 z"
|
||||
style="fill:#73d6ed"
|
||||
id="path4-8" /></g><g
|
||||
transform="matrix(0.13355808,0,0,0.13355808,-0.92543932,0.1095915)"
|
||||
id="g6"
|
||||
inkscape:label="furbooru"
|
||||
style="display:inline"><path
|
||||
d="m 396.42,189.81 c -51.874,-0.0769 -99.544,21.731 -131.29,68.784 3.6141,-17.709 12.731,-35.245 20.313,-51.85 -64.951,23.01 -110.98,58.779 -153.95,130.96 31.861,78.452 47.007,130.73 32.991,180.63 -12.789,45.531 -57.874,81.129 -120.66,54.031 11.733,79.157 138.9,169.44 265.13,95.148 -15.402,34.788 -33.651,44.104 -58.67,59.609 109.87,18.28 240.46,-16.733 327.04,-89.364 80.473,-67.509 145.76,-176.62 150.49,-271.1 -16.925,5.2436 -48.396,10.423 -61.314,8.6977 -4.9416,-13.958 -6.5445,-47.664 -6.6378,-65.502 -78.741,1.4559 -134.21,-82.398 -225.29,-56.079 24.483,-19.73 56.853,-19.667 84.992,-25.658 -39.916,-24.957 -82.805,-38.256 -123.15,-38.315 z m 46.019,201.23 c 33.445,0.29408 70.846,13.949 108.14,44.486 -71.827,-12.193 -167.68,9.8684 -228.78,100.49 -3.4843,-86.066 49.678,-145.6 120.65,-144.98 z"
|
||||
style="fill:#9a91d9"
|
||||
id="path1"
|
||||
inkscape:label="head" /><path
|
||||
d="m 70.559,99.836 c -11.606,103.12 31.76,196.05 73.743,312.81 47.028,130.79 -54.846,190.58 -100.97,125.58 C -2.791,473.221 -16.34,258.006 70.558,99.836 Z"
|
||||
style="fill:#9a91d9"
|
||||
id="path2"
|
||||
inkscape:label="rightear" /><path
|
||||
d="m 126.15,320.07 c 0,0 -84.457,-177.47 -13.898,-310.03 12.18,95.304 37.741,170.41 85.526,228.25 -34.102,31.125 -51.147,53.357 -71.628,81.784 z"
|
||||
style="fill:#9a91d9"
|
||||
id="path3"
|
||||
inkscape:label="leftear" /><path
|
||||
d="m 54.523,683.55 c 36.454,189.97 245.77,300.87 372.04,295.07 -11.047,-9.7005 -22.094,-18.617 -33.141,-31.003 95.291,43.85 187.43,17.122 251.23,-12.829 -16.164,-4.6041 -32.272,-9.3803 -47.039,-18.174 21.351,-4.409 43.671,-15.588 59.868,-33.141 -52.566,-1.4772 -102.82,-10.573 -151.86,-54.928 57.575,-28.86 90.002,-66.925 102.7,-97.767 -13.158,6.0202 -27.475,9.3163 -40.636,10.507 12.007,-23.538 20.064,-48.835 23.52,-78.043 -232.6,178.14 -441.6,75.628 -536.68,20.312 z"
|
||||
style="fill:#9a91d9"
|
||||
id="path4"
|
||||
inkscape:label="tail" /><path
|
||||
d="m 611.51,653.89 c -3.4653,21.491 -9.3328,46.627 -17.472,65.294 25.751,-12.728 37.33,-30.294 47.406,-48.456 0,0 -10.691,113.32 -106.91,158.22 0,0 57.784,50.56 163.62,32.385 -12.482,20.32 -26.396,37.375 -64.404,55.584 57.955,10.976 153.12,-24.053 185.6,-80.951 -28.742,9.1492 -48.987,9.8028 -69.933,11.156 160.72,-63.788 92.562,-249.91 248.03,-342.1 -61.514,-30.693 -156.71,-52.064 -253.37,42.763 9.4346,-13.824 22.374,-34.745 43.832,-56.661 -72.65,21.206 -114.21,112.97 -176.4,162.77 z"
|
||||
style="fill:#9a91d9"
|
||||
id="path5"
|
||||
inkscape:label="tailend" /><path
|
||||
d="m 676.96,308.22 c -2.2133,13.699 -3.9692,34.942 1.8899,51.493 15.962,2.1315 36.687,-1.0078 49.243,-8.4218 -0.26672,-23.09 -4.5591,-41.009 -10.829,-57.596 -12.902,6.3961 -22.273,10.895 -40.304,14.525 z"
|
||||
style="fill:#9a91d9"
|
||||
id="path6"
|
||||
inkscape:label="nose" /></g><g
|
||||
id="g18"
|
||||
inkscape:label="badge"
|
||||
style="display:inline"
|
||||
transform="matrix(0.9615385,0,0,0.9615385,66.145836,5.0881347)"><rect
|
||||
style="opacity:1;fill:#1b3c21;fill-opacity:1;stroke:none;stroke-width:1.42664;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="rect6"
|
||||
width="68.791664"
|
||||
height="44.450001"
|
||||
x="-2.5431316e-06"
|
||||
y="87.841667"
|
||||
ry="5.2916665"
|
||||
inkscape:label="bg" /><text
|
||||
xml:space="preserve"
|
||||
style="font-size:34.0036px;line-height:0.85;font-family:sans-serif;text-align:center;text-anchor:middle;fill:#4aa158;fill-opacity:1;stroke-width:0.261214"
|
||||
x="34.024727"
|
||||
y="128.18593"
|
||||
id="text11"
|
||||
transform="scale(1.0109068,0.98921086)"
|
||||
inkscape:label="text"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:34.0036px;line-height:0.85;font-family:Roundfeather;-inkscape-font-specification:Roundfeather;fill:#4aa158;fill-opacity:1;stroke-width:0.261214"
|
||||
x="34.024727"
|
||||
y="128.18593"
|
||||
id="tspan12"
|
||||
sodipodi:role="line">PTA</tspan></text></g></svg>
|
||||
|
After Width: | Height: | Size: 7.8 KiB |
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
import DetailsBlock from "$components/ui/DetailsBlock.svelte";
|
||||
import TagsList from "$components/tags/TagsList.svelte";
|
||||
|
||||
interface GroupViewProps {
|
||||
group: TagGroup;
|
||||
@@ -9,52 +11,32 @@
|
||||
let { group }: GroupViewProps = $props();
|
||||
|
||||
let sortedTagsList = $derived<string[]>(group.settings.tags.sort((a, b) => a.localeCompare(b))),
|
||||
sortedPrefixes = $derived<string[]>(group.settings.prefixes.sort((a, b) => a.localeCompare(b)));
|
||||
sortedPrefixes = $derived<string[]>(group.settings.prefixes.sort((a, b) => a.localeCompare(b))),
|
||||
sortedSuffixes = $derived<string[]>(group.settings.suffixes.sort((a, b) => a.localeCompare(b)));
|
||||
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
<strong>Group Name:</strong>
|
||||
<div>{group.settings.name}</div>
|
||||
</div>
|
||||
<DetailsBlock title="Group Name">
|
||||
{group.settings.name}
|
||||
</DetailsBlock>
|
||||
{#if sortedTagsList.length}
|
||||
<div class="block">
|
||||
<strong>Tags:</strong>
|
||||
<DetailsBlock title="Tags">
|
||||
<TagsColorContainer targetCategory={group.settings.category}>
|
||||
<div class="tags-list">
|
||||
{#each sortedTagsList as tagName}
|
||||
<span class="tag">{tagName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<TagsList tags={sortedTagsList} />
|
||||
</TagsColorContainer>
|
||||
</div>
|
||||
</DetailsBlock>
|
||||
{/if}
|
||||
{#if sortedPrefixes.length}
|
||||
<div class="block">
|
||||
<strong>Prefixes:</strong>
|
||||
<DetailsBlock title="Prefixes">
|
||||
<TagsColorContainer targetCategory={group.settings.category}>
|
||||
<div class="tags-list">
|
||||
{#each sortedPrefixes as prefixName}
|
||||
<span class="tag">{prefixName}*</span>
|
||||
{/each}
|
||||
</div>
|
||||
<TagsList tags={sortedPrefixes} append="*" />
|
||||
</TagsColorContainer>
|
||||
</div>
|
||||
</DetailsBlock>
|
||||
{/if}
|
||||
{#if sortedSuffixes.length}
|
||||
<DetailsBlock title="Suffixes">
|
||||
<TagsColorContainer targetCategory={group.settings.category}>
|
||||
<TagsList tags={sortedSuffixes} prepend="*" />
|
||||
</TagsColorContainer>
|
||||
</DetailsBlock>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.block + .block {
|
||||
margin-top: .5em;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
20
src/components/features/PresetView.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type TagEditorPreset from "$entities/TagEditorPreset";
|
||||
import DetailsBlock from "$components/ui/DetailsBlock.svelte";
|
||||
import TagsList from "$components/tags/TagsList.svelte";
|
||||
|
||||
interface PresetViewProps {
|
||||
preset: TagEditorPreset;
|
||||
}
|
||||
|
||||
let { preset }: PresetViewProps = $props();
|
||||
|
||||
const sortedTagsList = $derived(preset.settings.tags.toSorted((a, b) => a.localeCompare(b)));
|
||||
</script>
|
||||
|
||||
<DetailsBlock title="Preset Name">
|
||||
{preset.settings.name}
|
||||
</DetailsBlock>
|
||||
<DetailsBlock title="Tags">
|
||||
<TagsList tags={sortedTagsList}></TagsList>
|
||||
</DetailsBlock>
|
||||
@@ -1,41 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import type TaggingProfile from "$entities/TaggingProfile";
|
||||
import DetailsBlock from "$components/ui/DetailsBlock.svelte";
|
||||
import TagsList from "$components/tags/TagsList.svelte";
|
||||
|
||||
interface ProfileViewProps {
|
||||
profile: MaintenanceProfile;
|
||||
profile: TaggingProfile;
|
||||
}
|
||||
|
||||
let { profile }: ProfileViewProps = $props();
|
||||
|
||||
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));
|
||||
const sortedTagsList = $derived(profile.settings.tags.sort((a, b) => a.localeCompare(b)));
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
<strong>Profile:</strong>
|
||||
<div>{profile.settings.name}</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<strong>Tags:</strong>
|
||||
<div class="tags-list">
|
||||
{#each sortedTagsList as tagName}
|
||||
<span class="tag">{tagName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.block + .block {
|
||||
margin-top: .5em;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<DetailsBlock title="Profile">
|
||||
{profile.settings.name}
|
||||
</DetailsBlock>
|
||||
<DetailsBlock title="Tags">
|
||||
<TagsList tags={sortedTagsList} />
|
||||
</DetailsBlock>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { PLUGIN_NAME } from "$lib/constants";
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<a href="/">Furbooru Tagging Assistant</a>
|
||||
<a href="/">{PLUGIN_NAME}</a>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -13,6 +17,7 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
|
||||
a {
|
||||
color: colors.$text;
|
||||
|
||||
@@ -19,50 +19,60 @@
|
||||
.tag-color-container:is(:global(.tag-color-container--rating)) :global(.tag) {
|
||||
background-color: colors.$tag-rating-background;
|
||||
color: colors.$tag-rating-text;
|
||||
border-color: colors.$tag-rating-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--spoiler)) :global(.tag) {
|
||||
background-color: colors.$tag-spoiler-background;
|
||||
color: colors.$tag-spoiler-text;
|
||||
border-color: colors.$tag-spoiler-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--origin)) :global(.tag) {
|
||||
background-color: colors.$tag-origin-background;
|
||||
color: colors.$tag-origin-text;
|
||||
border-color: colors.$tag-origin-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--oc)) :global(.tag) {
|
||||
background-color: colors.$tag-oc-background;
|
||||
color: colors.$tag-oc-text;
|
||||
border-color: colors.$tag-oc-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--error)) :global(.tag) {
|
||||
background-color: colors.$tag-error-background;
|
||||
color: colors.$tag-error-text;
|
||||
border-color: colors.$tag-error-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--character)) :global(.tag) {
|
||||
background-color: colors.$tag-character-background;
|
||||
color: colors.$tag-character-text;
|
||||
border-color: colors.$tag-character-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--content-official)) :global(.tag) {
|
||||
background-color: colors.$tag-content-official-background;
|
||||
color: colors.$tag-content-official-text;
|
||||
border-color: colors.$tag-content-official-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--content-fanmade)) :global(.tag) {
|
||||
background-color: colors.$tag-content-fanmade-background;
|
||||
color: colors.$tag-content-fanmade-text;
|
||||
border-color: colors.$tag-content-fanmade-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--species)) :global(.tag) {
|
||||
background-color: colors.$tag-species-background;
|
||||
color: colors.$tag-species-text;
|
||||
border-color: colors.$tag-species-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--body-type)) :global(.tag) {
|
||||
background-color: colors.$tag-body-type-background;
|
||||
color: colors.$tag-body-type-text;
|
||||
border-color: colors.$tag-body-type-border;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
interface TagEditorProps {
|
||||
// List of tags to edit. Any duplicated tags present in the array will be removed on the first edit.
|
||||
tags?: string[];
|
||||
mapTagNames?: (tagName: string) => string;
|
||||
}
|
||||
|
||||
let {
|
||||
tags = $bindable([])
|
||||
tags = $bindable([]),
|
||||
mapTagNames,
|
||||
}: TagEditorProps = $props();
|
||||
|
||||
let uniqueTags = $state<Set<string>>(new Set());
|
||||
@@ -87,7 +89,7 @@
|
||||
<div class="tags-editor">
|
||||
{#each uniqueTags.values() as tagName}
|
||||
<div class="tag">
|
||||
{tagName}
|
||||
{mapTagNames?.(tagName) ?? tagName}
|
||||
<span class="remove" onclick={createTagRemoveHandler(tagName)}
|
||||
onkeydown={createTagRemoveHandler(tagName)}
|
||||
role="button" tabindex="0">x</span>
|
||||
|
||||
23
src/components/tags/TagsList.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
interface TagsListProps {
|
||||
tags: string[];
|
||||
prepend?: string;
|
||||
append?: string;
|
||||
}
|
||||
|
||||
let { tags, prepend, append }: TagsListProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="tags-list">
|
||||
{#each tags as tagName}
|
||||
<div class="tag">{prepend || ''}{tagName}{append || ''}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
30
src/components/ui/DetailsBlock.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface DetailsBlockProps {
|
||||
title?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { title, children }: DetailsBlockProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
{#if title?.length}
|
||||
<strong>{title}:</strong>
|
||||
{/if}
|
||||
<div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.block strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
|
||||
.block + :global(.block) {
|
||||
margin-top: .5em;
|
||||
}
|
||||
</style>
|
||||
32
src/components/ui/Notice.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
interface MessageProps {
|
||||
children?: import('svelte').Snippet;
|
||||
level: 'warning' | 'error';
|
||||
}
|
||||
|
||||
let { children, level }: MessageProps = $props();
|
||||
</script>
|
||||
|
||||
<p class="{level}">
|
||||
{@render children?.()}
|
||||
</p>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
|
||||
p {
|
||||
padding: 5px 24px;
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: colors.$warning-background;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: colors.$error-background;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import SelectField from "$components/ui/forms/SelectField.svelte";
|
||||
import { categories } from "$lib/booru/tag-categories";
|
||||
import { categories } from "$config/tags";
|
||||
|
||||
interface TagCategorySelectFieldProps {
|
||||
value?: string;
|
||||
|
||||
@@ -9,10 +9,19 @@
|
||||
value?: string;
|
||||
href?: string;
|
||||
children?: Snippet;
|
||||
/**
|
||||
* Click event received by the checkbox input element.
|
||||
*/
|
||||
onclick?: MouseEventHandler<HTMLInputElement>;
|
||||
oninput?: FormEventHandler<HTMLInputElement>;
|
||||
/**
|
||||
* Click event received by the menu item instead of the checkbox element.
|
||||
*/
|
||||
onitemclick?: MouseEventHandler<HTMLElement>;
|
||||
}
|
||||
|
||||
let checkboxElement: HTMLInputElement;
|
||||
|
||||
let {
|
||||
checked = $bindable(),
|
||||
name = undefined,
|
||||
@@ -21,16 +30,61 @@
|
||||
children,
|
||||
onclick,
|
||||
oninput,
|
||||
onitemclick,
|
||||
}: MenuCheckboxItemProps = $props();
|
||||
|
||||
/**
|
||||
* Prevent clicks from getting sent to the menu link if user clicked directly on the checkbox.
|
||||
* @param originalEvent
|
||||
*/
|
||||
function stopPropagationAndPassCallback(originalEvent: MouseEvent) {
|
||||
originalEvent.stopPropagation();
|
||||
onclick?.(originalEvent as MouseEvent & { currentTarget: HTMLInputElement });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and try to toggle checkbox if href was not provided for the menu item.
|
||||
*/
|
||||
function maybeToggleCheckboxOnOuterLinkClicked(event: MouseEvent) {
|
||||
// Call the event handler if present.
|
||||
if (onitemclick) {
|
||||
onitemclick(event as MouseEvent & {currentTarget: HTMLElement});
|
||||
|
||||
// If it was prevented, then don't attempt to run checkbox toggling workaround.
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// When menu link does not contain any link, we should just treat clicks on it as toggle action on checkbox.
|
||||
if (!href) {
|
||||
checked = !checked;
|
||||
|
||||
// Since we've toggled it using the `checked` property and input does not trigger `onclick` when we do something
|
||||
// programmatically, we should create valid event and send it back to the parent component so it will handle
|
||||
// whatever it wants.
|
||||
if (oninput) {
|
||||
// Uhh, not sure if this is how it should be done, but we need `currentTarget` to point on the checkbox. Without
|
||||
// dispatching the event, we can't fill it normally. Also, input element does not return us untrusted input
|
||||
// events automatically. Probably should make the util function later in case I'd need something similar.
|
||||
checkboxElement.addEventListener('input', (inputEvent: Event) => {
|
||||
oninput(inputEvent as Event & { currentTarget: HTMLInputElement });
|
||||
}, { once: true })
|
||||
|
||||
checkboxElement.dispatchEvent(new InputEvent('input'));
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<MenuLink {href}>
|
||||
<input bind:checked={checked} {name} onclick={stopPropagationAndPassCallback} {oninput} type="checkbox" {value}>
|
||||
<MenuLink {href} onclick={maybeToggleCheckboxOnOuterLinkClicked}>
|
||||
<input bind:this={checkboxElement}
|
||||
bind:checked={checked}
|
||||
{name}
|
||||
onclick={stopPropagationAndPassCallback}
|
||||
{oninput}
|
||||
type="checkbox"
|
||||
{value}>
|
||||
{@render children?.()}
|
||||
</MenuLink>
|
||||
|
||||
|
||||
@@ -1,4 +1,51 @@
|
||||
export const tagsBlacklist: string[] = [
|
||||
/**
|
||||
* List of categories defined by the sites.
|
||||
*/
|
||||
export const categories: string[] = [
|
||||
'rating',
|
||||
'spoiler',
|
||||
'origin',
|
||||
'oc',
|
||||
'error',
|
||||
'character',
|
||||
'content-official',
|
||||
'content-fanmade',
|
||||
'species',
|
||||
'body-type',
|
||||
];
|
||||
|
||||
/**
|
||||
* Mapping of namespaces to their respective categories. These namespaces are automatically assigned to them, so we can
|
||||
* automatically assume categories of tags which start with them. Mapping is extracted from Philomena directly.
|
||||
*
|
||||
* This mapping may differ between boorus.
|
||||
*
|
||||
* @see https://github.com/philomena-dev/philomena/blob/6086757b654da8792ae52adb2a2f501ea6c30d12/lib/philomena/tags/tag.ex#L33-L45
|
||||
*/
|
||||
export const namespaceCategories: Map<string, string> = new Map([
|
||||
['artist', 'origin'],
|
||||
['art pack', 'content-fanmade'],
|
||||
['colorist', 'origin'],
|
||||
['comic', 'content-fanmade'],
|
||||
['editor', 'origin'],
|
||||
['fanfic', 'content-fanmade'],
|
||||
['oc', 'oc'],
|
||||
['photographer', 'origin'],
|
||||
['series', 'content-fanmade'],
|
||||
['spoiler', 'spoiler'],
|
||||
['video', 'content-fanmade'],
|
||||
...(__CURRENT_SITE__ === 'tantabus' ? <const> [
|
||||
["prompter", "origin"],
|
||||
["creator", "origin"],
|
||||
["generator", "origin"]
|
||||
] : [])
|
||||
]);
|
||||
|
||||
/**
|
||||
* List of tags which marked by the site as blacklisted. These tags are blocked from being added by the tag editor and
|
||||
* should usually just be removed automatically.
|
||||
*/
|
||||
export const tagsBlacklist: string[] = (__CURRENT_SITE__ === 'furbooru' ? [
|
||||
"anthro art",
|
||||
"anthro artist",
|
||||
"anthro cute",
|
||||
@@ -63,4 +110,21 @@ export const tagsBlacklist: string[] = [
|
||||
"tagme",
|
||||
"upvotes galore",
|
||||
"wall of faves"
|
||||
];
|
||||
] : [
|
||||
"tagme",
|
||||
"tag me",
|
||||
"not tagged",
|
||||
"no tag",
|
||||
"notag",
|
||||
"notags",
|
||||
"upvotes galore",
|
||||
"downvotes galore",
|
||||
"wall of faves",
|
||||
"drama in the comments",
|
||||
"drama in comments",
|
||||
"tag needed",
|
||||
"paywall",
|
||||
"cringeworthy",
|
||||
"solo oc",
|
||||
"tag your shit"
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { bindComponent } from "$lib/components/base/component-utils";
|
||||
import { bindComponent } from "$content/components/base/component-utils";
|
||||
|
||||
type ComponentEventListener<EventName extends keyof HTMLElementEventMap> =
|
||||
(this: HTMLElement, event: HTMLElementEventMap[EventName]) => void;
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import type { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
|
||||
const instanceSymbol = Symbol('instance');
|
||||
const instanceSymbol = Symbol.for('instance');
|
||||
|
||||
interface ElementWithComponent<T> extends HTMLElement {
|
||||
[instanceSymbol]?: T;
|
||||
15
src/content/components/events/booru-events.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const EVENT_FETCH_COMPLETE = 'fetchcomplete';
|
||||
export const EVENT_RELOAD = 'reload';
|
||||
|
||||
/**
|
||||
* Custom data for the reload event on plain editor textarea. Philomena doesn't send anything on this event.
|
||||
*/
|
||||
export interface ReloadCustomOptions {
|
||||
skipTagColorRefresh?: boolean;
|
||||
skipTagRefresh?: boolean;
|
||||
}
|
||||
|
||||
export interface BooruEventsMap {
|
||||
[EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation.
|
||||
[EVENT_RELOAD]: ReloadCustomOptions|null;
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
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 { 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";
|
||||
|
||||
type EventsMapping =
|
||||
MaintenancePopupEventsMap
|
||||
& FullscreenViewerEventsMap
|
||||
& BooruEventsMap
|
||||
& TagsFormEventsMap;
|
||||
& TagsFormEventsMap
|
||||
& TagDropdownEvents
|
||||
& PresetBlockEventsMap;
|
||||
|
||||
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
|
||||
export type UnsubscribeFunction = () => void;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { FullscreenViewerSize } from "$lib/extension/preferences/MiscPreferences";
|
||||
|
||||
export const EVENT_SIZE_LOADED = 'size-loaded';
|
||||
|
||||
export interface FullscreenViewerEventsMap {
|
||||
[EVENT_SIZE_LOADED]: FullscreenViewerSize;
|
||||
}
|
||||
13
src/content/components/events/maintenance-popup-events.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type TaggingProfile from "$entities/TaggingProfile";
|
||||
|
||||
export const EVENT_ACTIVE_PROFILE_CHANGED = 'active-profile-changed';
|
||||
export const EVENT_MAINTENANCE_STATE_CHANGED = 'maintenance-state-change';
|
||||
export const EVENT_TAGS_UPDATED = 'tags-updated';
|
||||
|
||||
type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting';
|
||||
|
||||
export interface MaintenancePopupEventsMap {
|
||||
[EVENT_ACTIVE_PROFILE_CHANGED]: TaggingProfile | null;
|
||||
[EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState;
|
||||
[EVENT_TAGS_UPDATED]: Map<string, string> | null;
|
||||
}
|
||||
10
src/content/components/events/preset-block-events.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const EVENT_PRESET_TAG_CHANGE_APPLIED = 'preset-tag-changed';
|
||||
|
||||
export interface PresetTagChange {
|
||||
addedTags?: Set<string>;
|
||||
removedTags?: Set<string>;
|
||||
}
|
||||
|
||||
export interface PresetBlockEventsMap {
|
||||
[EVENT_PRESET_TAG_CHANGE_APPLIED]: PresetTagChange;
|
||||
}
|
||||
7
src/content/components/events/tag-dropdown-events.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
export const EVENT_TAG_GROUP_RESOLVED = 'tag-group-resolved';
|
||||
|
||||
export interface TagDropdownEvents {
|
||||
[EVENT_TAG_GROUP_RESOLVED]: TagGroup | null;
|
||||
}
|
||||
5
src/content/components/events/tags-form-events.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const EVENT_FORM_EDITOR_UPDATED = 'tags-form-updated';
|
||||
|
||||
export interface TagsFormEventsMap {
|
||||
[EVENT_FORM_EDITOR_UPDATED]: HTMLElement;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
|
||||
import { emit, on } from "$lib/components/events/comms";
|
||||
import { eventSizeLoaded } from "$lib/components/events/fullscreen-viewer-events";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import MiscPreferences, { type FullscreenViewerSize } from "$lib/extension/preferences/MiscPreferences";
|
||||
import { emit, on } from "$content/components/events/comms";
|
||||
import { EVENT_SIZE_LOADED } from "$content/components/events/fullscreen-viewer-events";
|
||||
|
||||
export class FullscreenViewer extends BaseComponent {
|
||||
#videoElement: HTMLVideoElement = document.createElement('video');
|
||||
@@ -53,8 +53,8 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.#imageElement.addEventListener('load', this.#onLoaded.bind(this));
|
||||
this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation());
|
||||
|
||||
FullscreenViewer.#miscSettings
|
||||
.resolveFullscreenViewerPreviewSize()
|
||||
FullscreenViewer.#preferences
|
||||
.fullscreenViewerSize.get()
|
||||
.then(this.#onSizeResolved.bind(this))
|
||||
.then(this.#watchForSizeSelectionChanges.bind(this));
|
||||
}
|
||||
@@ -173,13 +173,13 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.#sizeSelectorElement.value = size;
|
||||
this.#isSizeFetched = true;
|
||||
|
||||
emit(this.container, eventSizeLoaded, size);
|
||||
emit(this.container, EVENT_SIZE_LOADED, size);
|
||||
}
|
||||
|
||||
#watchForSizeSelectionChanges() {
|
||||
let lastActiveSize = this.#sizeSelectorElement.value;
|
||||
|
||||
FullscreenViewer.#miscSettings.subscribe(settings => {
|
||||
FullscreenViewer.#preferences.subscribe(settings => {
|
||||
const targetSize = settings.fullscreenViewerSize;
|
||||
|
||||
if (!targetSize || lastActiveSize === targetSize) {
|
||||
@@ -202,7 +202,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
}
|
||||
|
||||
lastActiveSize = targetSize;
|
||||
void FullscreenViewer.#miscSettings.setFullscreenViewerPreviewSize(targetSize);
|
||||
void FullscreenViewer.#preferences.fullscreenViewerSize.set(targetSize as FullscreenViewerSize);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
await new Promise(
|
||||
resolve => on(
|
||||
this.container,
|
||||
eventSizeLoaded,
|
||||
EVENT_SIZE_LOADED,
|
||||
resolve
|
||||
),
|
||||
);
|
||||
@@ -289,7 +289,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
return url.endsWith('.mp4') || url.endsWith('.webm');
|
||||
}
|
||||
|
||||
static #miscSettings = new MiscSettings();
|
||||
static #preferences = new MiscPreferences();
|
||||
|
||||
static #offsetProperty = '--offset';
|
||||
static #opacityProperty = '--opacity';
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings";
|
||||
import { FullscreenViewer } from "$lib/components/FullscreenViewer";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import MiscPreferences from "$lib/extension/preferences/MiscPreferences";
|
||||
import { FullscreenViewer } from "$content/components/extension/FullscreenViewer";
|
||||
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
|
||||
|
||||
export class ImageShowFullscreenButton extends BaseComponent {
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
@@ -10,8 +10,6 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
|
||||
protected build() {
|
||||
this.container.innerText = '🔍';
|
||||
|
||||
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
|
||||
}
|
||||
|
||||
protected init() {
|
||||
@@ -27,14 +25,14 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
|
||||
this.on('click', this.#onButtonClicked.bind(this));
|
||||
|
||||
if (ImageShowFullscreenButton.#miscSettings) {
|
||||
ImageShowFullscreenButton.#miscSettings.resolveFullscreenViewerEnabled()
|
||||
if (ImageShowFullscreenButton.#preferences) {
|
||||
ImageShowFullscreenButton.#preferences.fullscreenViewer.get()
|
||||
.then(isEnabled => {
|
||||
this.#isFullscreenButtonEnabled = isEnabled;
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
.then(() => {
|
||||
ImageShowFullscreenButton.#miscSettings?.subscribe(settings => {
|
||||
ImageShowFullscreenButton.#preferences?.subscribe(settings => {
|
||||
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
@@ -47,7 +45,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
}
|
||||
|
||||
#onButtonClicked() {
|
||||
const imageLinks = this.#mediaBoxTools?.mediaBox.imageLinks;
|
||||
const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks;
|
||||
|
||||
if (!imageLinks) {
|
||||
throw new Error('Failed to resolve image links from media box tools!');
|
||||
@@ -58,6 +56,15 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
?.show(imageLinks);
|
||||
}
|
||||
|
||||
static create(): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('media-box-show-fullscreen');
|
||||
|
||||
new ImageShowFullscreenButton(element);
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
static #viewer: FullscreenViewer | null = null;
|
||||
|
||||
static #resolveViewer(): FullscreenViewer {
|
||||
@@ -76,14 +83,5 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
return viewer;
|
||||
}
|
||||
|
||||
static #miscSettings: MiscSettings | null = null;
|
||||
}
|
||||
|
||||
export function createImageShowFullscreenButton() {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('media-box-show-fullscreen');
|
||||
|
||||
new ImageShowFullscreenButton(element);
|
||||
|
||||
return element;
|
||||
static #preferences = new MiscPreferences();
|
||||
}
|
||||
74
src/content/components/extension/MediaBoxTools.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { TaggingProfilePopup } from "$content/components/extension/profiles/TaggingProfilePopup";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/maintenance-popup-events";
|
||||
import type { MediaBox } from "$content/components/philomena/MediaBox";
|
||||
import type TaggingProfile from "$entities/TaggingProfile";
|
||||
|
||||
export class MediaBoxTools extends BaseComponent {
|
||||
#mediaBox: MediaBox | null = null;
|
||||
#maintenancePopup: TaggingProfilePopup | null = null;
|
||||
|
||||
init() {
|
||||
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
|
||||
|
||||
if (!mediaBoxElement) {
|
||||
throw new Error('Toolbox element initialized outside of the media box!');
|
||||
}
|
||||
|
||||
this.#mediaBox = getComponent(mediaBoxElement);
|
||||
|
||||
for (let childElement of this.container.children) {
|
||||
if (!(childElement instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const component = getComponent(childElement);
|
||||
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!component.isInitialized) {
|
||||
component.initialize();
|
||||
}
|
||||
|
||||
if (!this.#maintenancePopup && component instanceof TaggingProfilePopup) {
|
||||
this.#maintenancePopup = component;
|
||||
}
|
||||
}
|
||||
|
||||
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
|
||||
}
|
||||
|
||||
#onActiveProfileChanged(profileChangedEvent: CustomEvent<TaggingProfile | null>) {
|
||||
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
|
||||
}
|
||||
|
||||
get maintenancePopup(): TaggingProfilePopup | null {
|
||||
return this.#maintenancePopup;
|
||||
}
|
||||
|
||||
get mediaBox(): MediaBox | null {
|
||||
return this.#mediaBox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a maintenance popup element.
|
||||
* @param childrenElements List of children elements to append to the component.
|
||||
* @return The maintenance popup element.
|
||||
*/
|
||||
static create(...childrenElements: HTMLElement[]): HTMLElement {
|
||||
const mediaBoxToolsContainer = document.createElement('div');
|
||||
mediaBoxToolsContainer.classList.add('media-box-tools');
|
||||
|
||||
if (childrenElements.length) {
|
||||
mediaBoxToolsContainer.append(...childrenElements);
|
||||
}
|
||||
|
||||
new MediaBoxTools(mediaBoxToolsContainer);
|
||||
|
||||
return mediaBoxToolsContainer;
|
||||
}
|
||||
}
|
||||
102
src/content/components/extension/presets/EditorPresetsBlock.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import TagEditorPreset from "$entities/TagEditorPreset";
|
||||
import PresetTableRow from "$content/components/extension/presets/PresetTableRow";
|
||||
import { createFontAwesomeIcon } from "$lib/dom-utils";
|
||||
import { sortEntitiesByField } from "$lib/utils";
|
||||
|
||||
export default class EditorPresetsBlock extends BaseComponent {
|
||||
#presetsTable = document.createElement('table');
|
||||
#presetBlocks: PresetTableRow[] = [];
|
||||
#tags: Set<string> = new Set();
|
||||
|
||||
protected build() {
|
||||
this.container.classList.add('block', 'hidden', 'tag-presets');
|
||||
this.container.style.marginTop = 'var(--block-spacing)';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.classList.add('block__header');
|
||||
|
||||
const headerTitle = document.createElement('div');
|
||||
headerTitle.classList.add('block__header__title')
|
||||
headerTitle.textContent = ' Presets';
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.classList.add('block__content');
|
||||
|
||||
this.#presetsTable.append(
|
||||
document.createElement('thead'),
|
||||
document.createElement('tbody'),
|
||||
);
|
||||
|
||||
this.#presetsTable.tHead?.append(
|
||||
EditorPresetsBlock.#createRowWithTableHeads([
|
||||
'Name',
|
||||
'Tags',
|
||||
'Actions'
|
||||
]),
|
||||
);
|
||||
|
||||
headerTitle.prepend(createFontAwesomeIcon('layer-group'));
|
||||
header.append(headerTitle);
|
||||
content.append(this.#presetsTable);
|
||||
|
||||
this.container.append(
|
||||
header,
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
protected init() {
|
||||
TagEditorPreset.readAll()
|
||||
.then(this.#refreshPresets.bind(this))
|
||||
.then(() => TagEditorPreset.subscribe(this.#refreshPresets.bind(this)));
|
||||
}
|
||||
|
||||
toggleVisibility(shouldBeVisible: boolean | undefined = undefined) {
|
||||
this.container.classList.toggle('hidden', shouldBeVisible);
|
||||
}
|
||||
|
||||
updateTags(tags: Set<string>) {
|
||||
this.#tags = tags;
|
||||
|
||||
for (const presetBlock of this.#presetBlocks) {
|
||||
presetBlock.updateTags(tags);
|
||||
}
|
||||
}
|
||||
|
||||
#refreshPresets(presetsList: TagEditorPreset[]) {
|
||||
if (this.#presetBlocks.length) {
|
||||
for (const block of this.#presetBlocks) {
|
||||
block.remove();
|
||||
}
|
||||
}
|
||||
|
||||
for (const preset of sortEntitiesByField(presetsList, "name")) {
|
||||
const block = PresetTableRow.create(preset);
|
||||
this.#presetsTable.tBodies[0]?.append(block.container);
|
||||
block.initialize();
|
||||
block.updateTags(this.#tags);
|
||||
|
||||
this.#presetBlocks.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
static create(): EditorPresetsBlock {
|
||||
return new EditorPresetsBlock(
|
||||
document.createElement('div')
|
||||
);
|
||||
}
|
||||
|
||||
static #createRowWithTableHeads(columnNames: string[]): HTMLTableRowElement {
|
||||
const rowElement = document.createElement('tr');
|
||||
|
||||
for (const columnName of columnNames) {
|
||||
const columnHeadElement = document.createElement('th');
|
||||
columnHeadElement.textContent = columnName;
|
||||
|
||||
rowElement.append(columnHeadElement);
|
||||
}
|
||||
|
||||
return rowElement;
|
||||
}
|
||||
}
|
||||
129
src/content/components/extension/presets/PresetTableRow.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import type TagEditorPreset from "$entities/TagEditorPreset";
|
||||
import { emit } from "$content/components/events/comms";
|
||||
import { EVENT_PRESET_TAG_CHANGE_APPLIED } from "$content/components/events/preset-block-events";
|
||||
import { createFontAwesomeIcon } from "$lib/dom-utils";
|
||||
|
||||
export default class PresetTableRow extends BaseComponent {
|
||||
#preset: TagEditorPreset;
|
||||
#tagsList: HTMLElement[] = [];
|
||||
#applyAllButton = document.createElement('button');
|
||||
#removeAllButton = document.createElement('button');
|
||||
|
||||
constructor(container: HTMLElement, preset: TagEditorPreset) {
|
||||
super(container);
|
||||
|
||||
this.#preset = preset;
|
||||
}
|
||||
|
||||
protected build() {
|
||||
this.#tagsList = this.#preset.settings.tags
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.map(tagName => {
|
||||
const tagElement = document.createElement('span');
|
||||
tagElement.classList.add('tag');
|
||||
tagElement.textContent = tagName;
|
||||
tagElement.dataset.tagName = tagName;
|
||||
|
||||
return tagElement;
|
||||
});
|
||||
|
||||
const nameCell = document.createElement('td');
|
||||
nameCell.textContent = this.#preset.settings.name;
|
||||
|
||||
const tagsCell = document.createElement('td');
|
||||
|
||||
const tagsListContainer = document.createElement('div');
|
||||
tagsListContainer.classList.add('tag-list');
|
||||
tagsListContainer.append(...this.#tagsList);
|
||||
|
||||
tagsCell.append(tagsListContainer);
|
||||
|
||||
const actionsCell = document.createElement('td');
|
||||
|
||||
const actionsContainer = document.createElement('div');
|
||||
actionsContainer.classList.add('flex', 'flex--gap-small');
|
||||
|
||||
this.#applyAllButton.classList.add('button', 'button--state-success', 'button--bold');
|
||||
this.#applyAllButton.append(createFontAwesomeIcon('circle-plus'));
|
||||
this.#applyAllButton.title = 'Add all tags from this preset into the editor';
|
||||
|
||||
this.#removeAllButton.classList.add('button', 'button--state-danger', 'button--bold');
|
||||
this.#removeAllButton.append(createFontAwesomeIcon('circle-minus'));
|
||||
this.#removeAllButton.title = 'Remove all tags from this preset from the editor';
|
||||
|
||||
actionsContainer.append(
|
||||
this.#applyAllButton,
|
||||
this.#removeAllButton,
|
||||
);
|
||||
|
||||
actionsCell.append(actionsContainer);
|
||||
|
||||
this.container.append(
|
||||
nameCell,
|
||||
tagsCell,
|
||||
actionsCell,
|
||||
);
|
||||
}
|
||||
|
||||
protected init() {
|
||||
for (const tagElement of this.#tagsList) {
|
||||
tagElement.addEventListener('click', this.#onTagClicked.bind(this));
|
||||
}
|
||||
|
||||
this.#applyAllButton.addEventListener('click', this.#onApplyAllClicked.bind(this));
|
||||
this.#removeAllButton.addEventListener('click', this.#onRemoveAllClicked.bind(this));
|
||||
}
|
||||
|
||||
#onTagClicked(event: Event) {
|
||||
const targetElement = event.currentTarget;
|
||||
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagName = targetElement.dataset.tagName;
|
||||
const isMissing = targetElement.classList.contains(PresetTableRow.#tagMissingClassName);
|
||||
|
||||
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
|
||||
[isMissing ? 'addedTags' : 'removedTags']: new Set([tagName])
|
||||
});
|
||||
}
|
||||
|
||||
#onApplyAllClicked(event: Event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
|
||||
addedTags: new Set(this.#preset.settings.tags),
|
||||
});
|
||||
}
|
||||
|
||||
#onRemoveAllClicked(event: Event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
|
||||
removedTags: new Set(this.#preset.settings.tags),
|
||||
});
|
||||
}
|
||||
|
||||
updateTags(tags: Set<string>) {
|
||||
for (const tagElement of this.#tagsList) {
|
||||
tagElement.classList.toggle(
|
||||
PresetTableRow.#tagMissingClassName,
|
||||
!tags.has(tagElement.dataset.tagName || ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.container.remove();
|
||||
}
|
||||
|
||||
static create(preset: TagEditorPreset) {
|
||||
return new this(document.createElement('tr'), preset);
|
||||
}
|
||||
|
||||
static #tagMissingClassName = 'is-missing';
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI";
|
||||
import TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import ScrapedAPI from "$lib/philomena/scraping/ScrapedAPI";
|
||||
import { tagsBlacklist } from "$config/tags";
|
||||
import { emitterAt } from "$lib/components/events/comms";
|
||||
import { emitterAt } from "$content/components/events/comms";
|
||||
import {
|
||||
eventActiveProfileChanged,
|
||||
eventMaintenanceStateChanged,
|
||||
eventTagsUpdated
|
||||
} from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
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";
|
||||
|
||||
class BlackListedTagsEncounteredError extends Error {
|
||||
constructor(tagName: string) {
|
||||
@@ -20,17 +21,17 @@ class BlackListedTagsEncounteredError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class MaintenancePopup extends BaseComponent {
|
||||
export class TaggingProfilePopup extends BaseComponent {
|
||||
#tagsListElement: HTMLElement = document.createElement('div');
|
||||
#tagsList: HTMLElement[] = [];
|
||||
#suggestedInvalidTags: Map<string, HTMLElement> = new Map();
|
||||
#activeProfile: MaintenanceProfile | null = null;
|
||||
#activeProfile: TaggingProfile | null = null;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
#tagsToRemove: Set<string> = new Set();
|
||||
#tagsToAdd: Set<string> = new Set();
|
||||
#isPlanningToSubmit: boolean = false;
|
||||
#isSubmitting: boolean = false;
|
||||
#tagsSubmissionTimer: number | null = null;
|
||||
#tagsSubmissionTimer: Timeout | null = null;
|
||||
#emitter = emitterAt(this);
|
||||
|
||||
/**
|
||||
@@ -65,25 +66,29 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
this.#mediaBoxTools = mediaBoxTools;
|
||||
|
||||
MaintenancePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
|
||||
TaggingProfilePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
|
||||
this.#tagsListElement.addEventListener('click', this.#handleTagClick.bind(this));
|
||||
|
||||
const mediaBox = this.#mediaBoxTools.mediaBox;
|
||||
|
||||
if (!mediaBox) {
|
||||
throw new Error('Media box component not found!');
|
||||
}
|
||||
|
||||
mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this));
|
||||
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
|
||||
}
|
||||
|
||||
#onActiveProfileChanged(activeProfile: MaintenanceProfile | null) {
|
||||
#onActiveProfileChanged(activeProfile: TaggingProfile | null) {
|
||||
this.#activeProfile = activeProfile;
|
||||
this.container.classList.toggle('is-active', activeProfile !== null);
|
||||
this.#refreshTagsList();
|
||||
|
||||
this.#emitter.emit(eventActiveProfileChanged, activeProfile);
|
||||
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
|
||||
}
|
||||
|
||||
#refreshTagsList() {
|
||||
if (!this.#mediaBoxTools) {
|
||||
if (!this.#mediaBoxTools?.mediaBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,20 +110,25 @@ export class MaintenancePopup extends BaseComponent {
|
||||
activeProfileTagsList
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.forEach((tagName, index) => {
|
||||
const tagElement = MaintenancePopup.#buildTagElement(tagName);
|
||||
const tagElement = TaggingProfilePopup.#buildTagElement(tagName);
|
||||
this.#tagsList[index] = tagElement;
|
||||
this.#tagsListElement.appendChild(tagElement);
|
||||
|
||||
const isPresent = currentPostTags.has(tagName);
|
||||
const isPresent = currentPostTags?.has(tagName);
|
||||
|
||||
tagElement.classList.toggle('is-present', isPresent);
|
||||
tagElement.classList.toggle('is-missing', !isPresent);
|
||||
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags.get(tagName) !== tagName);
|
||||
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags?.get(tagName) !== tagName);
|
||||
|
||||
// Just to prevent duplication, we need to include this tag to the map of suggested invalid tags
|
||||
if (tagsBlacklist.includes(tagName)) {
|
||||
MaintenancePopup.#markTagAsInvalid(tagElement);
|
||||
TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error');
|
||||
this.#suggestedInvalidTags.set(tagName, tagElement);
|
||||
} else {
|
||||
TaggingProfilePopup.#markTagElementWithCategory(
|
||||
tagElement,
|
||||
resolveTagCategoryFromTagName(tagName) ?? '',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -169,11 +179,11 @@ export class MaintenancePopup extends BaseComponent {
|
||||
if (this.#tagsToAdd.size || this.#tagsToRemove.size) {
|
||||
// Notify only once, when first planning to submit
|
||||
if (!this.#isPlanningToSubmit) {
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(true);
|
||||
TaggingProfilePopup.#notifyAboutPendingSubmission(true);
|
||||
}
|
||||
|
||||
this.#isPlanningToSubmit = true;
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'waiting');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,27 +197,27 @@ export class MaintenancePopup extends BaseComponent {
|
||||
if (this.#isPlanningToSubmit && !this.#isSubmitting) {
|
||||
this.#tagsSubmissionTimer = setTimeout(
|
||||
this.#onSubmissionTimerPassed.bind(this),
|
||||
MaintenancePopup.#delayBeforeSubmissionMs
|
||||
TaggingProfilePopup.#delayBeforeSubmissionMs
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #onSubmissionTimerPassed() {
|
||||
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools) {
|
||||
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#isPlanningToSubmit = false;
|
||||
this.#isSubmitting = true;
|
||||
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'processing');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing');
|
||||
|
||||
let maybeTagsAndAliasesAfterUpdate;
|
||||
|
||||
const shouldAutoRemove = await MaintenancePopup.#maintenanceSettings.resolveStripBlacklistedTags();
|
||||
const shouldAutoRemove = await TaggingProfilePopup.#preferences.stripBlacklistedTags.get();
|
||||
|
||||
try {
|
||||
maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
|
||||
maybeTagsAndAliasesAfterUpdate = await TaggingProfilePopup.#scrapedAPI.updateImageTags(
|
||||
this.#mediaBoxTools.mediaBox.imageId,
|
||||
tagsList => {
|
||||
for (let tagName of this.#tagsToRemove) {
|
||||
@@ -240,31 +250,31 @@ export class MaintenancePopup extends BaseComponent {
|
||||
console.warn('Tags submission failed:', e);
|
||||
}
|
||||
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'failed');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
|
||||
this.#isSubmitting = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (maybeTagsAndAliasesAfterUpdate) {
|
||||
this.#emitter.emit(eventTagsUpdated, maybeTagsAndAliasesAfterUpdate);
|
||||
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
|
||||
}
|
||||
|
||||
this.#emitter.emit(eventMaintenanceStateChanged, 'complete');
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete');
|
||||
|
||||
this.#tagsToAdd.clear();
|
||||
this.#tagsToRemove.clear();
|
||||
|
||||
this.#refreshTagsList();
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
this.#isSubmitting = false;
|
||||
}
|
||||
|
||||
#revealInvalidTags() {
|
||||
if (!this.#mediaBoxTools) {
|
||||
if (!this.#mediaBoxTools?.mediaBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -282,8 +292,8 @@ export class MaintenancePopup extends BaseComponent {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagElement = MaintenancePopup.#buildTagElement(tagName);
|
||||
MaintenancePopup.#markTagAsInvalid(tagElement);
|
||||
const tagElement = TaggingProfilePopup.#buildTagElement(tagName);
|
||||
TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error');
|
||||
tagElement.classList.add('is-present');
|
||||
|
||||
this.#suggestedInvalidTags.set(tagName, tagElement);
|
||||
@@ -301,6 +311,14 @@ export class MaintenancePopup extends BaseComponent {
|
||||
return this.container.classList.contains('is-active');
|
||||
}
|
||||
|
||||
static create(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
|
||||
new this(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
static #buildTagElement(tagName: string): HTMLElement {
|
||||
const tagElement = document.createElement('span');
|
||||
tagElement.classList.add('tag');
|
||||
@@ -311,18 +329,19 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the tag with red color.
|
||||
* Mark the tag element with specified category.
|
||||
* @param tagElement Element to mark.
|
||||
* @param category Code name of category to mark.
|
||||
*/
|
||||
static #markTagAsInvalid(tagElement: HTMLElement) {
|
||||
tagElement.dataset.tagCategory = 'error';
|
||||
tagElement.setAttribute('data-tag-category', 'error');
|
||||
static #markTagElementWithCategory(tagElement: HTMLElement, category: string) {
|
||||
tagElement.dataset.tagCategory = category;
|
||||
tagElement.setAttribute('data-tag-category', category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller with maintenance settings.
|
||||
*/
|
||||
static #maintenanceSettings = new MaintenanceSettings();
|
||||
static #preferences = new TaggingProfilesPreferences();
|
||||
|
||||
/**
|
||||
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
|
||||
@@ -330,10 +349,10 @@ export class MaintenancePopup extends BaseComponent {
|
||||
* @param callback Callback to execute whenever selection of active profile or profile itself has been changed.
|
||||
* @return Unsubscribe function. Call it to stop watching for changes.
|
||||
*/
|
||||
static #watchActiveProfile(callback: (profile: MaintenanceProfile | null) => void): () => void {
|
||||
static #watchActiveProfile(callback: (profile: TaggingProfile | null) => void): () => void {
|
||||
let lastActiveProfileId: string | null | undefined = null;
|
||||
|
||||
const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => {
|
||||
const unsubscribeFromProfilesChanges = TaggingProfile.subscribe(profiles => {
|
||||
if (lastActiveProfileId) {
|
||||
callback(
|
||||
profiles.find(profile => profile.id === lastActiveProfileId) || null
|
||||
@@ -341,20 +360,18 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeFromMaintenanceSettings = this.#maintenanceSettings.subscribe(settings => {
|
||||
const unsubscribeFromMaintenanceSettings = this.#preferences.subscribe(settings => {
|
||||
if (settings.activeProfile === lastActiveProfileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastActiveProfileId = settings.activeProfile;
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
this.#preferences.activeProfile.asObject()
|
||||
.then(callback);
|
||||
});
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
this.#preferences.activeProfile.asObject()
|
||||
.then(profileOrNull => {
|
||||
if (profileOrNull) {
|
||||
lastActiveProfileId = profileOrNull.id;
|
||||
@@ -405,11 +422,3 @@ export class MaintenancePopup extends BaseComponent {
|
||||
*/
|
||||
static #pendingSubmissionCount: number|null = null;
|
||||
}
|
||||
|
||||
export function createMaintenancePopup() {
|
||||
const container = document.createElement('div');
|
||||
|
||||
new MaintenancePopup(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventMaintenanceStateChanged } from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$content/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
|
||||
|
||||
export class MaintenanceStatusIcon extends BaseComponent {
|
||||
export class TaggingProfileStatusIcon extends BaseComponent {
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
|
||||
build() {
|
||||
@@ -22,7 +22,7 @@ export class MaintenanceStatusIcon extends BaseComponent {
|
||||
throw new Error('Status icon element initialized outside of the media box!');
|
||||
}
|
||||
|
||||
on(this.#mediaBoxTools, eventMaintenanceStateChanged, this.#onMaintenanceStateChanged.bind(this));
|
||||
on(this.#mediaBoxTools, EVENT_MAINTENANCE_STATE_CHANGED, this.#onMaintenanceStateChanged.bind(this));
|
||||
}
|
||||
|
||||
#onMaintenanceStateChanged(stateChangeEvent: CustomEvent<string>) {
|
||||
@@ -52,13 +52,13 @@ export class MaintenanceStatusIcon extends BaseComponent {
|
||||
this.container.innerText = '❓';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createMaintenanceStatusIcon() {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('maintenance-status-icon');
|
||||
|
||||
new MaintenanceStatusIcon(element);
|
||||
|
||||
return element;
|
||||
|
||||
static create(): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('maintenance-status-icon');
|
||||
|
||||
new TaggingProfileStatusIcon(element);
|
||||
|
||||
return element;
|
||||
}
|
||||
}
|
||||
133
src/content/components/philomena/BlockCommunication.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import TagsPreferences from "$lib/extension/preferences/TagsPreferences";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { resolveTagNameFromLink, resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils";
|
||||
|
||||
export class BlockCommunication extends BaseComponent {
|
||||
#contentSection: HTMLElement | null = null;
|
||||
#tagLinks: HTMLAnchorElement[] = [];
|
||||
|
||||
#tagLinksReplaced: boolean | null = null;
|
||||
#linkTextReplaced: boolean | null = null;
|
||||
|
||||
protected build() {
|
||||
this.#contentSection = this.container.querySelector('.communication__content');
|
||||
this.#tagLinks = this.#findAllTagLinks();
|
||||
}
|
||||
|
||||
protected init() {
|
||||
Promise.all([
|
||||
BlockCommunication.#preferences.replaceLinks.get(),
|
||||
BlockCommunication.#preferences.replaceLinkText.get(),
|
||||
]).then(([replaceLinks, replaceLinkText]) => {
|
||||
this.#onReplaceLinkSettingResolved(
|
||||
replaceLinks,
|
||||
replaceLinkText
|
||||
);
|
||||
});
|
||||
|
||||
BlockCommunication.#preferences.subscribe(settings => {
|
||||
this.#onReplaceLinkSettingResolved(
|
||||
settings.replaceLinks ?? false,
|
||||
settings.replaceLinkText ?? true
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#onReplaceLinkSettingResolved(haveToReplaceLinks: boolean, shouldReplaceLinkText: boolean) {
|
||||
if (
|
||||
!this.#tagLinks.length
|
||||
|| this.#tagLinksReplaced === haveToReplaceLinks
|
||||
&& this.#linkTextReplaced === shouldReplaceLinkText
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const linkElement of this.#tagLinks) {
|
||||
linkElement.classList.toggle('tag', haveToReplaceLinks);
|
||||
|
||||
// Sometimes tags are being decorated with the code block inside. It should be fine to replace it right away.
|
||||
if (linkElement.childElementCount === 1 && linkElement.children[0].tagName === 'CODE') {
|
||||
linkElement.textContent = linkElement.children[0].textContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved tag name. It should be stored for the text replacement.
|
||||
*/
|
||||
let tagName: string | undefined;
|
||||
|
||||
if (haveToReplaceLinks) {
|
||||
tagName = resolveTagNameFromLink(new URL(linkElement.href)) ?? '';
|
||||
linkElement.dataset.tagCategory = resolveTagCategoryFromTagName(tagName) ?? '';
|
||||
} else {
|
||||
linkElement.dataset.tagCategory = '';
|
||||
}
|
||||
|
||||
this.#toggleTagLinkText(
|
||||
linkElement,
|
||||
haveToReplaceLinks && shouldReplaceLinkText,
|
||||
tagName,
|
||||
);
|
||||
}
|
||||
|
||||
this.#tagLinksReplaced = haveToReplaceLinks;
|
||||
this.#linkTextReplaced = shouldReplaceLinkText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the link text with the tag name or restore it back to original content. This function will only perform
|
||||
* replacement on links without any additional tags inside. This will ensure link won't break original content.
|
||||
* @param linkElement Element to swap the text on.
|
||||
* @param shouldSwapToTagName Should we swap the text to tag name or retore it back from memory.
|
||||
* @param tagName Tag name to swap the text to. If not provided, text will be swapped back.
|
||||
* @private
|
||||
*/
|
||||
#toggleTagLinkText(linkElement: HTMLElement, shouldSwapToTagName: boolean, tagName?: string) {
|
||||
if (linkElement.childElementCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure we save the original text to memory.
|
||||
if (!BlockCommunication.#originalTagLinkTexts.has(linkElement)) {
|
||||
BlockCommunication.#originalTagLinkTexts.set(linkElement, linkElement.textContent);
|
||||
}
|
||||
|
||||
if (shouldSwapToTagName && tagName) {
|
||||
linkElement.textContent = tagName;
|
||||
} else {
|
||||
linkElement.textContent = BlockCommunication.#originalTagLinkTexts.get(linkElement) ?? linkElement.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
#findAllTagLinks(): HTMLAnchorElement[] {
|
||||
return Array
|
||||
.from(this.#contentSection?.querySelectorAll('a') || [])
|
||||
.filter(
|
||||
link =>
|
||||
// Support links pointing to the tag page.
|
||||
link.pathname.startsWith('/tags/')
|
||||
// Also capture link which point to the search results with single tag.
|
||||
|| link.pathname.startsWith('/search')
|
||||
&& link.search.includes('q=')
|
||||
);
|
||||
}
|
||||
|
||||
static #preferences = new TagsPreferences();
|
||||
|
||||
/**
|
||||
* Map of links to their original texts. These texts need to be stored here to make them restorable. Keys is a link
|
||||
* element and value is a text.
|
||||
* @private
|
||||
*/
|
||||
static #originalTagLinkTexts: WeakMap<HTMLElement, string> = new WeakMap();
|
||||
|
||||
static findAndInitializeAll() {
|
||||
for (const container of document.querySelectorAll<HTMLElement>('.block.communication')) {
|
||||
if (getComponent(container)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
new BlockCommunication(container).initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/content/components/philomena/MediaBox.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { buildTagsAndAliasesMap } from "$lib/philomena/tag-utils";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_TAGS_UPDATED } from "$content/components/events/maintenance-popup-events";
|
||||
|
||||
export class MediaBox extends BaseComponent {
|
||||
#thumbnailContainer: HTMLElement | null = null;
|
||||
#imageLinkElement: HTMLAnchorElement | null = null;
|
||||
#tagsAndAliases: Map<string, string> | null = null;
|
||||
|
||||
init() {
|
||||
this.#thumbnailContainer = this.container.querySelector('.image-container');
|
||||
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
|
||||
|
||||
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
|
||||
}
|
||||
|
||||
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
|
||||
const updatedMap = tagsUpdatedEvent.detail;
|
||||
|
||||
if (!(updatedMap instanceof Map)) {
|
||||
throw new TypeError("Tags and aliases should be stored as Map!");
|
||||
}
|
||||
|
||||
this.#tagsAndAliases = updatedMap;
|
||||
}
|
||||
|
||||
#calculateMediaBoxTags() {
|
||||
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
|
||||
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
|
||||
return buildTagsAndAliasesMap(tagAliases, actualTags);
|
||||
}
|
||||
|
||||
get tagsAndAliases(): Map<string, string> | null {
|
||||
if (!this.#tagsAndAliases) {
|
||||
this.#tagsAndAliases = this.#calculateMediaBoxTags();
|
||||
}
|
||||
|
||||
return this.#tagsAndAliases;
|
||||
}
|
||||
|
||||
get imageId(): number {
|
||||
const imageId = this.container.dataset.imageId;
|
||||
|
||||
if (!imageId) {
|
||||
throw new Error('Missing image ID');
|
||||
}
|
||||
|
||||
return parseInt(imageId);
|
||||
}
|
||||
|
||||
get imageLinks(): App.ImageURIs {
|
||||
const jsonUris = this.#thumbnailContainer?.dataset.uris;
|
||||
|
||||
if (!jsonUris) {
|
||||
throw new Error('Missing URIs!');
|
||||
}
|
||||
|
||||
return JSON.parse(jsonUris);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the media box element into the special wrapper.
|
||||
*/
|
||||
static initialize(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
|
||||
new MediaBox(mediaBoxContainer)
|
||||
.initialize();
|
||||
|
||||
for (let childComponentElement of childComponentElements) {
|
||||
mediaBoxContainer.appendChild(childComponentElement);
|
||||
getComponent(childComponentElement)?.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
static findElements(): NodeListOf<HTMLElement> {
|
||||
return document.querySelectorAll('.media-box');
|
||||
}
|
||||
|
||||
static initializePositionCalculation(mediaBoxesList: NodeListOf<HTMLElement>) {
|
||||
window.addEventListener('resize', () => {
|
||||
let lastMediaBox: HTMLElement | null = null;
|
||||
let lastMediaBoxPosition: number | null = null;
|
||||
|
||||
for (const mediaBoxElement of mediaBoxesList) {
|
||||
const yPosition = mediaBoxElement.getBoundingClientRect().y;
|
||||
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
|
||||
|
||||
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
|
||||
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
|
||||
|
||||
lastMediaBox = mediaBoxElement;
|
||||
lastMediaBoxPosition = yPosition;
|
||||
}
|
||||
|
||||
// Last-ever media box is checked separately
|
||||
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
|
||||
lastMediaBox.classList.add('media-box--last');
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventFormEditorUpdated } from "$lib/components/events/tags-form-events";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdown-events";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
const categoriesResolver = new CustomCategoriesResolver();
|
||||
|
||||
export class TagDropdownWrapper extends BaseComponent {
|
||||
export class TagDropdown extends BaseComponent {
|
||||
/**
|
||||
* Container with dropdown elements to insert options into.
|
||||
*/
|
||||
@@ -27,7 +27,7 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
/**
|
||||
* Local clone of the currently active profile used for updating the list of tags.
|
||||
*/
|
||||
#activeProfile: MaintenanceProfile | null = null;
|
||||
#activeProfile: TaggingProfile | null = null;
|
||||
|
||||
/**
|
||||
* Is cursor currently entered the dropdown.
|
||||
@@ -44,13 +44,30 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
this.on('mouseenter', this.#onDropdownEntered.bind(this));
|
||||
this.on('mouseleave', this.#onDropdownLeft.bind(this));
|
||||
|
||||
TagDropdownWrapper.#watchActiveProfile(activeProfileOrNull => {
|
||||
TagDropdown.#watchActiveProfile(activeProfileOrNull => {
|
||||
this.#activeProfile = activeProfileOrNull;
|
||||
|
||||
if (this.#isEntered) {
|
||||
this.#updateButtons();
|
||||
}
|
||||
});
|
||||
|
||||
on(this, EVENT_TAG_GROUP_RESOLVED, this.#onTagGroupResolved.bind(this));
|
||||
}
|
||||
|
||||
#onTagGroupResolved(resolvedGroupEvent: CustomEvent<TagGroup | null>) {
|
||||
if (this.originalCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeTagGroup = resolvedGroupEvent.detail;
|
||||
|
||||
if (!maybeTagGroup) {
|
||||
this.tagCategory = this.originalCategory;
|
||||
return;
|
||||
}
|
||||
|
||||
this.tagCategory = maybeTagGroup.settings.category;
|
||||
}
|
||||
|
||||
get tagName() {
|
||||
@@ -103,7 +120,7 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
|
||||
#updateButtons() {
|
||||
if (!this.#activeProfile) {
|
||||
this.#addToNewButton ??= TagDropdownWrapper.#createDropdownLink(
|
||||
this.#addToNewButton ??= TagDropdown.#createDropdownLink(
|
||||
'Add to new tagging profile',
|
||||
this.#onAddToNewClicked.bind(this)
|
||||
);
|
||||
@@ -116,7 +133,7 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
}
|
||||
|
||||
if (this.#activeProfile) {
|
||||
this.#toggleOnExistingButton ??= TagDropdownWrapper.#createDropdownLink(
|
||||
this.#toggleOnExistingButton ??= TagDropdown.#createDropdownLink(
|
||||
'Add to existing tagging profile',
|
||||
this.#onToggleInExistingClicked.bind(this)
|
||||
);
|
||||
@@ -129,7 +146,12 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
profileSpecificButtonText = `Remove from profile "${profileName}"`;
|
||||
}
|
||||
|
||||
this.#toggleOnExistingButton.innerText = profileSpecificButtonText;
|
||||
if (this.#toggleOnExistingButton.lastChild instanceof Text) {
|
||||
this.#toggleOnExistingButton.lastChild.textContent = ` ${profileSpecificButtonText}`;
|
||||
} else {
|
||||
// Just in case last child is missing, then update the text on the full element.
|
||||
this.#toggleOnExistingButton.textContent = profileSpecificButtonText;
|
||||
}
|
||||
|
||||
if (!this.#toggleOnExistingButton.isConnected) {
|
||||
this.#dropdownContainer?.append(this.#toggleOnExistingButton);
|
||||
@@ -148,14 +170,14 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
throw new Error('Missing tag name to create the profile!');
|
||||
}
|
||||
|
||||
const profile = new MaintenanceProfile(crypto.randomUUID(), {
|
||||
const profile = new TaggingProfile(crypto.randomUUID(), {
|
||||
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
|
||||
tags: [this.tagName],
|
||||
temporary: true,
|
||||
});
|
||||
|
||||
await profile.save();
|
||||
await TagDropdownWrapper.#maintenanceSettings.setActiveProfileId(profile.id);
|
||||
await TagDropdown.#preferences.activeProfile.set(profile.id);
|
||||
}
|
||||
|
||||
async #onToggleInExistingClicked() {
|
||||
@@ -181,25 +203,25 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
await this.#activeProfile.save();
|
||||
}
|
||||
|
||||
static #maintenanceSettings = new MaintenanceSettings();
|
||||
static #preferences = new TaggingProfilesPreferences();
|
||||
|
||||
/**
|
||||
* Watch for changes to active profile.
|
||||
* @param onActiveProfileChange Callback to call when profile was
|
||||
* changed.
|
||||
*/
|
||||
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile|null) => void) {
|
||||
static #watchActiveProfile(onActiveProfileChange: (profile: TaggingProfile | null) => void) {
|
||||
let lastActiveProfile: string | null = null;
|
||||
|
||||
this.#maintenanceSettings.subscribe((settings) => {
|
||||
this.#preferences.subscribe((settings) => {
|
||||
lastActiveProfile = settings.activeProfile ?? null;
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
this.#preferences
|
||||
.activeProfile.asObject()
|
||||
.then(onActiveProfileChange);
|
||||
});
|
||||
|
||||
MaintenanceProfile.subscribe(profiles => {
|
||||
TaggingProfile.subscribe(profiles => {
|
||||
const activeProfile = profiles
|
||||
.find(profile => profile.id === lastActiveProfile);
|
||||
|
||||
@@ -207,8 +229,8 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
);
|
||||
});
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
this.#preferences
|
||||
.activeProfile.asObject()
|
||||
.then(activeProfile => {
|
||||
lastActiveProfile = activeProfile?.id ?? null;
|
||||
onActiveProfileChange(activeProfile);
|
||||
@@ -224,9 +246,14 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement {
|
||||
const dropdownLink = document.createElement('a');
|
||||
dropdownLink.href = '#';
|
||||
dropdownLink.innerText = text;
|
||||
dropdownLink.className = 'tag__dropdown__link';
|
||||
|
||||
const dropdownLinkIcon = document.createElement('i');
|
||||
dropdownLinkIcon.classList.add('fa', 'fa-tags');
|
||||
|
||||
dropdownLink.textContent = ` ${text}`;
|
||||
dropdownLink.insertAdjacentElement('afterbegin', dropdownLinkIcon);
|
||||
|
||||
dropdownLink.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
onClickHandler(event);
|
||||
@@ -234,58 +261,65 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
|
||||
return dropdownLink;
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapTagDropdown(element: HTMLElement) {
|
||||
// Skip initialization when tag component is already wrapped
|
||||
if (getComponent(element)) {
|
||||
return;
|
||||
static #categoriesResolver = new CustomCategoriesResolver();
|
||||
static #processedElements: WeakSet<HTMLElement> = new WeakSet();
|
||||
|
||||
static #findAll(parentNode: ParentNode = document): NodeListOf<HTMLElement> {
|
||||
return parentNode.querySelectorAll('.tag.dropdown');
|
||||
}
|
||||
|
||||
const tagDropdown = new TagDropdownWrapper(element);
|
||||
tagDropdown.initialize();
|
||||
static #initialize(element: HTMLElement) {
|
||||
// Skip initialization when tag component is already wrapped
|
||||
if (getComponent(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
categoriesResolver.addElement(tagDropdown);
|
||||
}
|
||||
const tagDropdown = new TagDropdown(element);
|
||||
tagDropdown.initialize();
|
||||
|
||||
const processedElementsSet = new WeakSet<HTMLElement>();
|
||||
|
||||
export function watchTagDropdownsInTagsEditor() {
|
||||
// We only need to watch for new editor elements if there is a tag editor present on the page
|
||||
if (!document.querySelector('#image_tags_and_source')) {
|
||||
return;
|
||||
this.#categoriesResolver.addElement(tagDropdown);
|
||||
}
|
||||
|
||||
document.body.addEventListener('mouseover', event => {
|
||||
const targetElement = event.target;
|
||||
static findAllAndInitialize(parentNode: ParentNode = document) {
|
||||
for (const element of this.#findAll(parentNode)) {
|
||||
this.#initialize(element);
|
||||
}
|
||||
}
|
||||
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
static watch() {
|
||||
// We only need to watch for new editor elements if there is a tag editor present on the page
|
||||
if (!document.querySelector('#image_tags_and_source')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (processedElementsSet.has(targetElement)) {
|
||||
return;
|
||||
}
|
||||
document.body.addEventListener('mouseover', event => {
|
||||
const targetElement = event.target;
|
||||
|
||||
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!closestTagEditor || processedElementsSet.has(closestTagEditor)) {
|
||||
processedElementsSet.add(targetElement);
|
||||
return;
|
||||
}
|
||||
if (this.#processedElements.has(targetElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
processedElementsSet.add(targetElement);
|
||||
processedElementsSet.add(closestTagEditor);
|
||||
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
|
||||
|
||||
for (const tagDropdownElement of closestTagEditor.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
});
|
||||
if (!closestTagEditor || this.#processedElements.has(closestTagEditor)) {
|
||||
this.#processedElements.add(targetElement);
|
||||
return;
|
||||
}
|
||||
|
||||
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
|
||||
on(document.body, eventFormEditorUpdated, event => {
|
||||
for (const tagDropdownElement of event.detail.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
});
|
||||
this.#processedElements.add(targetElement);
|
||||
this.#processedElements.add(closestTagEditor);
|
||||
|
||||
this.findAllAndInitialize(closestTagEditor);
|
||||
});
|
||||
|
||||
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
|
||||
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
|
||||
this.findAllAndInitialize(event.detail);
|
||||
});
|
||||
}
|
||||
}
|
||||
291
src/content/components/philomena/TagsForm.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { emit, on, type UnsubscribeFunction } from "$content/components/events/comms";
|
||||
import { EVENT_FETCH_COMPLETE, EVENT_RELOAD, type ReloadCustomOptions } from "$content/components/events/booru-events";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
|
||||
import EditorPresetsBlock from "$content/components/extension/presets/EditorPresetsBlock";
|
||||
import { EVENT_PRESET_TAG_CHANGE_APPLIED, type PresetTagChange } from "$content/components/events/preset-block-events";
|
||||
|
||||
export class TagsForm extends BaseComponent {
|
||||
#togglePresetsButton: HTMLButtonElement = document.createElement('button');
|
||||
#presetsList = EditorPresetsBlock.create();
|
||||
#plainEditorTextarea: HTMLTextAreaElement|null = null;
|
||||
#fancyEditorInput: HTMLInputElement|null = null;
|
||||
#tagsSet: Set<string> = new Set();
|
||||
|
||||
protected build() {
|
||||
this.#togglePresetsButton.classList.add(
|
||||
'button',
|
||||
'button--state-primary',
|
||||
'button--bold',
|
||||
'button--separate-left',
|
||||
);
|
||||
|
||||
this.#togglePresetsButton.textContent = 'Presets';
|
||||
|
||||
this.container
|
||||
.querySelector(':is(.fancy-tag-edit, .fancy-tag-upload) ~ button:last-of-type')
|
||||
?.after(this.#togglePresetsButton, this.#presetsList.container);
|
||||
|
||||
this.#plainEditorTextarea = this.container.querySelector('textarea.tagsinput');
|
||||
this.#fancyEditorInput = this.container.querySelector('.js-taginput-fancy input');
|
||||
}
|
||||
|
||||
protected init() {
|
||||
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
|
||||
const unsubscribe = on(
|
||||
this.container,
|
||||
EVENT_FETCH_COMPLETE,
|
||||
() => this.#waitAndDetectUpdatedForm(unsubscribe),
|
||||
);
|
||||
|
||||
this.#togglePresetsButton.addEventListener('click', this.#togglePresetsList.bind(this));
|
||||
this.#presetsList.initialize();
|
||||
|
||||
this.#plainEditorTextarea?.addEventListener('input', this.#refreshTagsListForPresets.bind(this));
|
||||
this.#fancyEditorInput?.addEventListener('keydown', this.#refreshTagsListForPresets.bind(this));
|
||||
|
||||
this.#refreshTagsListForPresets();
|
||||
|
||||
on(this.#presetsList, EVENT_PRESET_TAG_CHANGE_APPLIED, this.#onTagChangeRequested.bind(this));
|
||||
|
||||
if (this.#plainEditorTextarea) {
|
||||
// When reloaded, we should catch and refresh the colors. Extension reuses this event to force site to update
|
||||
// list of tags in the fancy tag editor.
|
||||
on(this.#plainEditorTextarea, EVENT_RELOAD, this.#onPlainEditorReloadRequested.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
#waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void {
|
||||
const elementContainingTagEditor = this.container
|
||||
.closest('#image_tags_and_source')
|
||||
?.parentElement;
|
||||
|
||||
if (!elementContainingTagEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const tagsFormElement = elementContainingTagEditor.querySelector<HTMLElement>('#tags-form');
|
||||
|
||||
if (!tagsFormElement || getComponent(tagsFormElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormComponent = new TagsForm(tagsFormElement);
|
||||
tagFormComponent.initialize();
|
||||
|
||||
const fullTagEditor = tagFormComponent.parentTagEditorElement;
|
||||
|
||||
if (fullTagEditor) {
|
||||
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
|
||||
} else {
|
||||
console.info('Tag form is not in the tag editor. Event is not sent.');
|
||||
}
|
||||
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
observer.observe(elementContainingTagEditor, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
||||
|
||||
// Make sure to forcibly disconnect everything after a while.
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
get parentTagEditorElement(): HTMLElement | null {
|
||||
return this.container.closest<HTMLElement>('.js-tagsauce')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all the tag categories available on the page and color the tags in the editor according to them.
|
||||
*/
|
||||
refreshTagColors() {
|
||||
const tagCategories = this.#gatherTagCategories();
|
||||
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
|
||||
|
||||
for (const tagElement of editableTags) {
|
||||
// Tag name is stored in the "remove" link and not in the tag itself.
|
||||
const removeLink = tagElement.querySelector('a');
|
||||
|
||||
if (!removeLink) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = removeLink.dataset.tagName;
|
||||
|
||||
if (!tagName || !tagCategories.has(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryName = tagCategories.get(tagName)!;
|
||||
|
||||
tagElement.dataset.tagCategory = categoryName;
|
||||
tagElement.setAttribute('data-tag-category', categoryName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect list of categories from the tags on the page.
|
||||
* @return
|
||||
*/
|
||||
#gatherTagCategories(): Map<string, string> {
|
||||
const tagCategories: Map<string, string> = new Map();
|
||||
|
||||
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
|
||||
const tagName = tagElement.dataset.tagName;
|
||||
const tagCategory = tagElement.dataset.tagCategory;
|
||||
|
||||
if (!tagName || !tagCategory) {
|
||||
console.warn('Missing tag name or category!');
|
||||
continue;
|
||||
}
|
||||
|
||||
tagCategories.set(tagName, tagCategory);
|
||||
}
|
||||
|
||||
return tagCategories;
|
||||
}
|
||||
|
||||
#togglePresetsList(event: Event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
this.#presetsList.toggleVisibility();
|
||||
this.#refreshTagsListForPresets();
|
||||
}
|
||||
|
||||
#refreshTagsListForPresets() {
|
||||
this.#tagsSet = new Set(
|
||||
this.#plainEditorTextarea?.value
|
||||
.split(',')
|
||||
.map(tagName => tagName.trim())
|
||||
);
|
||||
|
||||
this.#presetsList.updateTags(this.#tagsSet);
|
||||
}
|
||||
|
||||
#onTagChangeRequested(event: CustomEvent<PresetTagChange>) {
|
||||
const { addedTags = null, removedTags = null } = event.detail;
|
||||
let tagChangeList: string[] = [];
|
||||
|
||||
if (addedTags) {
|
||||
tagChangeList.push(...addedTags);
|
||||
}
|
||||
|
||||
if (removedTags) {
|
||||
tagChangeList.push(
|
||||
...Array.from(removedTags)
|
||||
.filter(tagName => this.#tagsSet.has(tagName))
|
||||
.map(tagName => `-${tagName}`)
|
||||
);
|
||||
}
|
||||
|
||||
const offsetBeforeSubmission = this.#presetsList.container.offsetTop;
|
||||
|
||||
this.#applyTagChangesWithFancyTagEditor(
|
||||
tagChangeList.join(',')
|
||||
);
|
||||
|
||||
const offsetDifference = this.#presetsList.container.offsetTop - offsetBeforeSubmission;
|
||||
|
||||
// Compensating for the layout shift: when user clicks on a tag (or on "add/remove all tags"), tag editor might
|
||||
// overflow the current line and wrap tags around to the next line, causing presets section to shift. We need to
|
||||
// avoid that for better UX.
|
||||
if (offsetDifference !== 0) {
|
||||
window.scrollTo({
|
||||
top: window.scrollY + offsetDifference,
|
||||
behavior: 'instant',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#applyTagChangesWithFancyTagEditor(tagsListWithChanges: string): void {
|
||||
if (!this.#fancyEditorInput || !this.#plainEditorTextarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalValue = this.#fancyEditorInput.value;
|
||||
|
||||
// We have to tell plain text editor to also refresh the list of tags in the fancy editor, just in case user
|
||||
// made changes to it in plain mode.
|
||||
emit(this.#plainEditorTextarea, EVENT_RELOAD, {
|
||||
// Sending that we don't need to refresh the color on this event, since we will do that ourselves later, after
|
||||
// changes are applied.
|
||||
skipTagColorRefresh: true,
|
||||
skipTagRefresh: true,
|
||||
});
|
||||
|
||||
this.#fancyEditorInput.value = tagsListWithChanges;
|
||||
this.#fancyEditorInput.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Comma',
|
||||
}));
|
||||
|
||||
this.#fancyEditorInput.value = originalValue;
|
||||
|
||||
this.refreshTagColors();
|
||||
}
|
||||
|
||||
#onPlainEditorReloadRequested(event: CustomEvent<ReloadCustomOptions|null>) {
|
||||
if (!event.detail?.skipTagColorRefresh) {
|
||||
this.refreshTagColors();
|
||||
}
|
||||
|
||||
if (!event.detail?.skipTagRefresh) {
|
||||
this.#refreshTagsListForPresets();
|
||||
}
|
||||
}
|
||||
|
||||
static watchForEditors() {
|
||||
document.body.addEventListener('click', event => {
|
||||
const targetElement = event.target;
|
||||
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
|
||||
|
||||
if (!tagEditorWrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
|
||||
|
||||
if (!refreshTrigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
|
||||
|
||||
if (!tagFormElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tagEditor = getComponent(tagFormElement);
|
||||
|
||||
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
|
||||
tagEditor = new TagsForm(tagFormElement);
|
||||
tagEditor.initialize();
|
||||
}
|
||||
|
||||
(tagEditor as TagsForm).refreshTagColors();
|
||||
});
|
||||
}
|
||||
|
||||
static initializeUploadEditor() {
|
||||
const uploadEditorContainer = document.querySelector<HTMLElement>('.field:has(.fancy-tag-upload)');
|
||||
|
||||
if (!uploadEditorContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
new TagsForm(uploadEditorContainer).initialize();
|
||||
}
|
||||
}
|
||||
244
src/content/components/philomena/TagsListBlock.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { BaseComponent } from "$content/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";
|
||||
|
||||
export class TagsListBlock extends BaseComponent {
|
||||
#tagsListButtonsContainer: HTMLElement | null = null;
|
||||
#tagsListContainer: HTMLElement | null = null;
|
||||
|
||||
#toggleGroupingButton = document.createElement('a');
|
||||
#toggleGroupingButtonIcon = document.createElement('i');
|
||||
|
||||
#preferences = new TagsPreferences();
|
||||
|
||||
#shouldDisplaySeparation = false;
|
||||
|
||||
#separatedGroups = new Map<string, TagGroup>();
|
||||
#separatedHeaders = new Map<string, HTMLElement>();
|
||||
#groupsCount = new Map<string, number>();
|
||||
#lastTagGroup = new WeakMap<TagDropdown, TagGroup | null>;
|
||||
|
||||
#isReorderingPlanned = false;
|
||||
|
||||
protected build() {
|
||||
this.#tagsListButtonsContainer = this.container.querySelector('.block.tagsauce .block__header__buttons');
|
||||
this.#tagsListContainer = this.container.querySelector('.tag-list');
|
||||
|
||||
this.#toggleGroupingButton.innerText = ' Grouping';
|
||||
this.#toggleGroupingButton.href = 'javascript:void(0)';
|
||||
this.#toggleGroupingButton.classList.add('button', 'button--link', 'button--inline');
|
||||
this.#toggleGroupingButton.title = 'Toggle the global groups separation option. This will only toggle global ' +
|
||||
'setting without changing the separation of specific groups.';
|
||||
|
||||
this.#toggleGroupingButtonIcon.classList.add('fas', TagsListBlock.#iconGroupingDisabled);
|
||||
this.#toggleGroupingButton.prepend(this.#toggleGroupingButtonIcon);
|
||||
|
||||
if (this.#tagsListButtonsContainer) {
|
||||
this.#tagsListButtonsContainer.append(this.#toggleGroupingButton);
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.#preferences.groupSeparation.get().then(this.#onTagSeparationChange.bind(this));
|
||||
this.#preferences.subscribe(settings => {
|
||||
this.#onTagSeparationChange(Boolean(settings.groupSeparation))
|
||||
});
|
||||
|
||||
on(
|
||||
this,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
this.#onTagDropdownCustomGroupResolved.bind(this)
|
||||
);
|
||||
|
||||
this.#toggleGroupingButton.addEventListener('click', this.#onToggleGroupingClicked.bind(this));
|
||||
}
|
||||
|
||||
#onTagSeparationChange(isSeparationEnabled: boolean) {
|
||||
if (this.#shouldDisplaySeparation === isSeparationEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#shouldDisplaySeparation = isSeparationEnabled;
|
||||
this.#reorderSeparatedGroups();
|
||||
this.#updateToggleSeparationButton();
|
||||
}
|
||||
|
||||
#updateToggleSeparationButton() {
|
||||
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingEnabled, this.#shouldDisplaySeparation);
|
||||
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingDisabled, !this.#shouldDisplaySeparation);
|
||||
}
|
||||
|
||||
#onTagDropdownCustomGroupResolved(resolvedCustomGroupEvent: CustomEvent<TagGroup | null>) {
|
||||
const maybeDropdownElement = resolvedCustomGroupEvent.target;
|
||||
|
||||
if (!(maybeDropdownElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagDropdown = getComponent<TagDropdown>(maybeDropdownElement);
|
||||
|
||||
if (!tagDropdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagGroup = resolvedCustomGroupEvent.detail;
|
||||
|
||||
if (tagGroup) {
|
||||
this.#handleTagGroupChanges(tagGroup);
|
||||
}
|
||||
|
||||
this.#handleResolvedTagGroup(tagGroup, tagDropdown);
|
||||
|
||||
if (!this.#isReorderingPlanned) {
|
||||
this.#isReorderingPlanned = true;
|
||||
|
||||
requestAnimationFrame(this.#reorderSeparatedGroups.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
#onToggleGroupingClicked(event: Event) {
|
||||
event.preventDefault();
|
||||
void this.#preferences.groupSeparation.set(!this.#shouldDisplaySeparation);
|
||||
}
|
||||
|
||||
#handleTagGroupChanges(tagGroup: TagGroup) {
|
||||
const groupId = tagGroup.id;
|
||||
const processedGroup = this.#separatedGroups.get(groupId);
|
||||
|
||||
if (!tagGroup.settings.separate && processedGroup) {
|
||||
this.#separatedGroups.delete(groupId);
|
||||
this.#separatedHeaders.get(groupId)?.remove();
|
||||
this.#separatedHeaders.delete(groupId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Every time group is updated, a new object is being initialized
|
||||
if (tagGroup !== processedGroup) {
|
||||
this.#createOrUpdateHeaderForGroup(tagGroup);
|
||||
this.#separatedGroups.set(groupId, tagGroup);
|
||||
}
|
||||
}
|
||||
|
||||
#createOrUpdateHeaderForGroup(group: TagGroup) {
|
||||
let heading = this.#separatedHeaders.get(group.id);
|
||||
|
||||
if (!heading) {
|
||||
heading = document.createElement('h2');
|
||||
|
||||
// Heading is hidden by default and shown on next frame if there are tags to show in the section.
|
||||
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.
|
||||
this.#tagsListContainer?.insertAdjacentElement('afterbegin', heading);
|
||||
|
||||
this.#separatedHeaders.set(group.id, heading);
|
||||
}
|
||||
|
||||
heading.innerText = group.settings.name;
|
||||
}
|
||||
|
||||
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdown) {
|
||||
const previousGroupId = this.#lastTagGroup.get(tagComponent)?.id;
|
||||
const currentGroupId = resolvedGroup?.id;
|
||||
const isDifferentId = currentGroupId !== previousGroupId;
|
||||
const isSeparationEnabled = resolvedGroup?.settings.separate;
|
||||
|
||||
if (isDifferentId) {
|
||||
// Make sure to subtract the element from counters if there was a count before.
|
||||
if (previousGroupId && this.#groupsCount.has(previousGroupId)) {
|
||||
this.#groupsCount.set(previousGroupId, this.#groupsCount.get(previousGroupId)! - 1);
|
||||
}
|
||||
|
||||
// We only need to count groups which have separation enabled.
|
||||
if (currentGroupId && isSeparationEnabled) {
|
||||
const count = this.#groupsCount.get(resolvedGroup.id) ?? 0;
|
||||
this.#groupsCount.set(currentGroupId, count + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// We're adding the CSS order for the tag as the CSS variable. This variable is updated later.
|
||||
if (currentGroupId && isSeparationEnabled) {
|
||||
tagComponent.container.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(currentGroupId)}, 0)`;
|
||||
} else {
|
||||
tagComponent.container.style.removeProperty('order');
|
||||
}
|
||||
|
||||
// If separation is disabled in the new group, then we should remove the tag from map, so it can be recaptured
|
||||
// when tag group is getting enabled later.
|
||||
if (currentGroupId && !isSeparationEnabled) {
|
||||
this.#lastTagGroup.delete(tagComponent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark this tag component as related to the following group.
|
||||
this.#lastTagGroup.set(tagComponent, resolvedGroup);
|
||||
}
|
||||
|
||||
#reorderSeparatedGroups() {
|
||||
this.#isReorderingPlanned = false;
|
||||
|
||||
const tagGroups = Array.from(this.#separatedGroups.values())
|
||||
.toSorted((a, b) => a.settings.name.localeCompare(b.settings.name));
|
||||
|
||||
for (let index = 0; index < tagGroups.length; index++) {
|
||||
const tagGroup = tagGroups[index];
|
||||
const groupId = tagGroup.id;
|
||||
const usedCount = this.#groupsCount.get(groupId);
|
||||
const relatedHeading = this.#separatedHeaders.get(groupId);
|
||||
|
||||
if (this.#shouldDisplaySeparation) {
|
||||
this.container.style.setProperty(TagsListBlock.#orderCssVariableForGroup(groupId), (index + 1).toString());
|
||||
} else {
|
||||
this.container.style.removeProperty(TagsListBlock.#orderCssVariableForGroup(groupId));
|
||||
}
|
||||
|
||||
if (relatedHeading) {
|
||||
if (!this.#shouldDisplaySeparation || !usedCount || usedCount <= 0) {
|
||||
relatedHeading.style.display = 'none';
|
||||
} else {
|
||||
relatedHeading.style.removeProperty('display');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static #orderCssVariableForGroup(groupId: string): string {
|
||||
return `--ta-order-${groupId}`;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { ImageListInfo } from "$content/components/philomena/listing/ImageListInfo";
|
||||
|
||||
export class ImageListContainer extends BaseComponent {
|
||||
#info: ImageListInfo | null = null;
|
||||
|
||||
protected build() {
|
||||
const imageListInfoContainer = this.container.querySelector<HTMLElement>('.js-imagelist-info');
|
||||
|
||||
if (imageListInfoContainer) {
|
||||
this.#info = new ImageListInfo(imageListInfoContainer);
|
||||
this.#info.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
static findAndInitialize() {
|
||||
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
|
||||
|
||||
if (imageListContainer) {
|
||||
new ImageListContainer(imageListContainer).initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/content/components/philomena/listing/ImageListInfo.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
|
||||
export class ImageListInfo extends BaseComponent {
|
||||
#tagElement: HTMLElement | null = null;
|
||||
#impliedTags: string[] = [];
|
||||
#showUntaggedImplicationsButton: HTMLAnchorElement = document.createElement('a');
|
||||
|
||||
protected build() {
|
||||
const sectionAfterImage = this.container.querySelector('.tag-info__image + .flex__grow');
|
||||
|
||||
this.#tagElement = sectionAfterImage?.querySelector<HTMLElement>('.tag.dropdown') ?? null;
|
||||
|
||||
const labels = this.container
|
||||
.querySelectorAll<HTMLElement>('.tag-info__image + .flex__grow strong');
|
||||
|
||||
let targetElementToInsertBefore: HTMLElement | null = null;
|
||||
|
||||
for (const potentialListStarter of labels) {
|
||||
if (potentialListStarter.innerText === ImageListInfo.#implicationsStarterText) {
|
||||
targetElementToInsertBefore = potentialListStarter;
|
||||
this.#collectImplicationsFromListStarter(potentialListStarter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#impliedTags.length && targetElementToInsertBefore) {
|
||||
this.#showUntaggedImplicationsButton.href = '#';
|
||||
this.#showUntaggedImplicationsButton.innerText = '(Q)';
|
||||
this.#showUntaggedImplicationsButton.title =
|
||||
'Query untagged implications\n\n' +
|
||||
'This will open the search results with all untagged implications for the current tag.';
|
||||
this.#showUntaggedImplicationsButton.classList.add('detail-link');
|
||||
|
||||
targetElementToInsertBefore.insertAdjacentElement('beforebegin', this.#showUntaggedImplicationsButton);
|
||||
}
|
||||
}
|
||||
|
||||
protected init() {
|
||||
this.#showUntaggedImplicationsButton.addEventListener('click', this.#onShowUntaggedImplicationsClicked.bind(this));
|
||||
}
|
||||
|
||||
#collectImplicationsFromListStarter(listStarter: HTMLElement) {
|
||||
let targetElement: Element | null = listStarter.nextElementSibling;
|
||||
|
||||
while (targetElement) {
|
||||
if (targetElement instanceof HTMLAnchorElement) {
|
||||
this.#impliedTags.push(targetElement.innerText.trim());
|
||||
}
|
||||
|
||||
// First line break is considered the end of the list.
|
||||
if (targetElement instanceof HTMLBRElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
targetElement = targetElement.nextElementSibling;
|
||||
}
|
||||
}
|
||||
|
||||
#onShowUntaggedImplicationsClicked(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
url.pathname = '/search';
|
||||
url.search = '';
|
||||
|
||||
const currentTagName = this.#tagElement?.dataset.tagName;
|
||||
|
||||
url.searchParams.set('q', `${currentTagName}, !(${this.#impliedTags.join(", ")})`);
|
||||
|
||||
location.assign(url.href);
|
||||
}
|
||||
|
||||
static #implicationsStarterText = 'Implies:';
|
||||
}
|
||||
53
src/content/deps/amd.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { amdLite } from "amd-lite";
|
||||
|
||||
const originalDefine = amdLite.define;
|
||||
|
||||
/**
|
||||
* Set of already defined modules. Used for deduplication.
|
||||
*/
|
||||
const definedModules = new Set<string>();
|
||||
|
||||
/**
|
||||
* Throttle timer to make sure only one attempt at loading modules will run for a batch of loaded scripts.
|
||||
*/
|
||||
let throttledAutoRunTimer: NodeJS.Timeout | number | undefined;
|
||||
|
||||
/**
|
||||
* Schedule the automatic resolving of all waiting modules on the next available frame.
|
||||
*/
|
||||
function scheduleModulesAutoRun() {
|
||||
clearTimeout(throttledAutoRunTimer);
|
||||
|
||||
throttledAutoRunTimer = setTimeout(() => {
|
||||
amdLite.resolveDependencies(Object.keys(amdLite.waitingModules));
|
||||
});
|
||||
}
|
||||
|
||||
amdLite.define = (name, dependencies, originalCallback) => {
|
||||
// Chrome doesn't run the same content script multiple times, while Firefox does. Since each content script and their
|
||||
// chunks are intended to be run only once, we should just ignore any attempts of running the same module more than
|
||||
// once. Names of the modules are assumed to be unique.
|
||||
if (definedModules.has(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
definedModules.add(name);
|
||||
|
||||
originalDefine(name, dependencies, function () {
|
||||
const callbackResult = originalCallback(...arguments);
|
||||
|
||||
// Workaround for the entry script not returning anything causing AMD-Lite to send warning about modules not
|
||||
// being loaded/not existing.
|
||||
return typeof callbackResult !== 'undefined' ? callbackResult : {};
|
||||
});
|
||||
|
||||
// Schedule the auto run on the next available frame. Firefox and Chromium have a lot of differences in how they
|
||||
// decide to execute content scripts. For example, Firefox might decide to skip a frame before attempting to load
|
||||
// different groups of them. Chromium on the other hand doesn't have that issue, but it doesn't allow us to, for
|
||||
// example, schedule a microtask to run the modules.
|
||||
scheduleModulesAutoRun();
|
||||
}
|
||||
|
||||
amdLite.init({
|
||||
publicScope: window
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { initializeSiteHeader } from "$lib/components/SiteHeaderWrapper";
|
||||
|
||||
const siteHeader = document.querySelector('.header');
|
||||
|
||||
if (siteHeader) {
|
||||
initializeSiteHeader(siteHeader);
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import { createMaintenancePopup } from "$lib/components/MaintenancePopup";
|
||||
import { createMediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
import { calculateMediaBoxesPositions, initializeMediaBox } from "$lib/components/MediaBoxWrapper";
|
||||
import { createMaintenanceStatusIcon } from "$lib/components/MaintenanceStatusIcon";
|
||||
import { createImageShowFullscreenButton } from "$lib/components/ImageShowFullscreenButton";
|
||||
import { 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";
|
||||
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
const mediaBoxes = document.querySelectorAll('.media-box');
|
||||
const mediaBoxes = MediaBox.findElements();
|
||||
|
||||
mediaBoxes.forEach(mediaBoxElement => {
|
||||
initializeMediaBox(mediaBoxElement, [
|
||||
createMediaBoxTools(
|
||||
createMaintenancePopup(),
|
||||
createMaintenanceStatusIcon(),
|
||||
createImageShowFullscreenButton(),
|
||||
MediaBox.initialize(mediaBoxElement, [
|
||||
MediaBoxTools.create(
|
||||
TaggingProfilePopup.create(),
|
||||
TaggingProfileStatusIcon.create(),
|
||||
ImageShowFullscreenButton.create(),
|
||||
)
|
||||
]);
|
||||
|
||||
@@ -22,4 +22,5 @@ mediaBoxes.forEach(mediaBoxElement => {
|
||||
})
|
||||
});
|
||||
|
||||
calculateMediaBoxesPositions(mediaBoxes);
|
||||
MediaBox.initializePositionCalculation(mediaBoxes);
|
||||
ImageListContainer.findAndInitialize();
|
||||
|
||||
3
src/content/posts.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { BlockCommunication } from "$content/components/philomena/BlockCommunication";
|
||||
|
||||
BlockCommunication.findAndInitializeAll();
|
||||
@@ -1,3 +1,6 @@
|
||||
import { TagsForm } from "$lib/components/TagsForm";
|
||||
import { TagsForm } from "$content/components/philomena/TagsForm";
|
||||
import { TagsListBlock } from "$content/components/philomena/TagsListBlock";
|
||||
|
||||
TagsListBlock.initializeAll();
|
||||
TagsListBlock.watchUpdatedLists();
|
||||
TagsForm.watchForEditors();
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { watchTagDropdownsInTagsEditor, wrapTagDropdown } from "$lib/components/TagDropdownWrapper";
|
||||
import { TagDropdown } from "$content/components/philomena/TagDropdown";
|
||||
|
||||
for (let tagDropdownElement of document.querySelectorAll('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
|
||||
watchTagDropdownsInTagsEditor();
|
||||
TagDropdown.findAllAndInitialize();
|
||||
TagDropdown.watch();
|
||||
|
||||
3
src/content/upload.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { TagsForm } from "$content/components/philomena/TagsForm";
|
||||
|
||||
TagsForm.initializeUploadEditor();
|
||||
@@ -1,8 +1,13 @@
|
||||
/** @type {import('@sveltejs/kit').Reroute} */
|
||||
export function reroute({url}) {
|
||||
import type { Reroute } from "@sveltejs/kit";
|
||||
|
||||
export const reroute: Reroute = ({url}) => {
|
||||
// Reroute index.html as just / for the root.
|
||||
// Browser extension starts from with the index.html file in the pathname which is not correct for the router.
|
||||
if (url.pathname === '/index.html') {
|
||||
if (url.searchParams.has('path')) {
|
||||
return url.searchParams.get('path')!;
|
||||
}
|
||||
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
export const categories = [
|
||||
'rating',
|
||||
'spoiler',
|
||||
'origin',
|
||||
'oc',
|
||||
'error',
|
||||
'character',
|
||||
'content-official',
|
||||
'content-fanmade',
|
||||
'species',
|
||||
'body-type',
|
||||
];
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Build the map containing both real tags and their aliases.
|
||||
*
|
||||
* @param realAndAliasedTags List combining aliases and tag names.
|
||||
* @param realTags List of actual tag names, excluding aliases.
|
||||
*
|
||||
* @return Map where key is a tag or alias and value is an actual tag name.
|
||||
*/
|
||||
export function buildTagsAndAliasesMap(realAndAliasedTags: string[], realTags: string[]): Map<string, string> {
|
||||
const tagsAndAliasesMap: Map<string, string> = new Map();
|
||||
|
||||
for (const tagName of realTags) {
|
||||
tagsAndAliasesMap.set(tagName, tagName);
|
||||
}
|
||||
|
||||
let realTagName: string | null = null;
|
||||
|
||||
for (const tagNameOrAlias of realAndAliasedTags) {
|
||||
if (tagsAndAliasesMap.has(tagNameOrAlias)) {
|
||||
realTagName = tagNameOrAlias;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!realTagName) {
|
||||
console.warn('No real tag found for the alias:', tagNameOrAlias);
|
||||
continue;
|
||||
}
|
||||
|
||||
tagsAndAliasesMap.set(tagNameOrAlias, realTagName);
|
||||
}
|
||||
|
||||
return tagsAndAliasesMap;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { MaintenancePopup } from "$lib/components/MaintenancePopup";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventActiveProfileChanged } 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, eventActiveProfileChanged, 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;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventTagsUpdated } 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, eventTagsUpdated, 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;
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { QueryLexer, QuotedTermToken, TermToken, Token } from "$lib/booru/search/QueryLexer";
|
||||
import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings";
|
||||
|
||||
export class SearchWrapper extends BaseComponent {
|
||||
#searchField: HTMLInputElement | null = null;
|
||||
#lastParsedSearchValue: string | null = null;
|
||||
#cachedParsedQuery: Token[] = [];
|
||||
#searchSettings: SearchSettings = new SearchSettings();
|
||||
#arePropertiesSuggestionsEnabled: boolean = false;
|
||||
#propertiesSuggestionsPosition: SuggestionsPosition = "start";
|
||||
#cachedAutocompleteContainer: HTMLElement | null = null;
|
||||
#lastTermToken: TermToken | QuotedTermToken | null = null;
|
||||
|
||||
build() {
|
||||
this.#searchField = this.container.querySelector('input[name=q]');
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.#searchField) {
|
||||
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this))
|
||||
}
|
||||
|
||||
this.#searchSettings.resolvePropertiesSuggestionsEnabled()
|
||||
.then(isEnabled => this.#arePropertiesSuggestionsEnabled = isEnabled);
|
||||
this.#searchSettings.resolvePropertiesSuggestionsPosition()
|
||||
.then(position => this.#propertiesSuggestionsPosition = position);
|
||||
|
||||
this.#searchSettings.subscribe(settings => {
|
||||
this.#arePropertiesSuggestionsEnabled = Boolean(settings.suggestProperties);
|
||||
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition || "start";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch the user input and execute suggestions logic.
|
||||
* @param event Source event to find the input element from.
|
||||
*/
|
||||
#onInputFindProperties(event: Event) {
|
||||
// Ignore events until option is enabled.
|
||||
if (!this.#arePropertiesSuggestionsEnabled || !(event.currentTarget instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFragment = this.#findCurrentTagFragment();
|
||||
|
||||
if (!currentFragment) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#renderSuggestions(
|
||||
SearchWrapper.#resolveSuggestionsFromTerm(currentFragment),
|
||||
event.currentTarget
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selection position in the search field.
|
||||
*/
|
||||
#getInputUserSelection(): number {
|
||||
if (!this.#searchField) {
|
||||
throw new Error('Missing search field!');
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
this.#searchField.selectionStart ?? 0,
|
||||
this.#searchField.selectionEnd ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the search query and return the list of parsed tokens. Result will be cached for current search query.
|
||||
*/
|
||||
#resolveQueryTokens(): Token[] {
|
||||
if (!this.#searchField) {
|
||||
throw new Error('Missing search field!');
|
||||
}
|
||||
|
||||
const searchValue = this.#searchField.value;
|
||||
|
||||
if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) {
|
||||
return this.#cachedParsedQuery;
|
||||
}
|
||||
|
||||
this.#lastParsedSearchValue = searchValue;
|
||||
this.#cachedParsedQuery = new QueryLexer(searchValue).parse();
|
||||
|
||||
return this.#cachedParsedQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the currently selected term.
|
||||
* @return Selected term or null if none found.
|
||||
*/
|
||||
#findCurrentTagFragment(): string | null {
|
||||
if (!this.#searchField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let searchValue = this.#searchField.value;
|
||||
|
||||
if (!searchValue) {
|
||||
this.#lastTermToken = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = SearchWrapper.#findActiveSearchTermPosition(
|
||||
this.#resolveQueryTokens(),
|
||||
this.#getInputUserSelection(),
|
||||
);
|
||||
|
||||
if (token instanceof TermToken) {
|
||||
this.#lastTermToken = token;
|
||||
return token.value;
|
||||
}
|
||||
|
||||
if (token instanceof QuotedTermToken) {
|
||||
this.#lastTermToken = token;
|
||||
return token.decodedValue;
|
||||
}
|
||||
|
||||
this.#lastTermToken = null;
|
||||
return searchValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the autocomplete container from the document. Once resolved, it can be safely reused without breaking
|
||||
* anything. Assuming refactored autocomplete handler is still implemented the way it is.
|
||||
*
|
||||
* This means, that properties will only be suggested once actual autocomplete logic was activated.
|
||||
*
|
||||
* @return Resolved element or nothing.
|
||||
*/
|
||||
#resolveAutocompleteContainer(): HTMLElement | null {
|
||||
if (this.#cachedAutocompleteContainer) {
|
||||
return this.#cachedAutocompleteContainer;
|
||||
}
|
||||
|
||||
this.#cachedAutocompleteContainer = document.querySelector('.autocomplete');
|
||||
|
||||
return this.#cachedAutocompleteContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the list of suggestions into the existing popup or create and populate a new one.
|
||||
* @param suggestions List of suggestion to render the popup from.
|
||||
* @param targetInput Target input to attach the popup to.
|
||||
*/
|
||||
#renderSuggestions(suggestions: string[], targetInput: HTMLInputElement) {
|
||||
const suggestedListItems = suggestions
|
||||
.map(suggestedTerm => this.#renderTermSuggestion(suggestedTerm));
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const autocompleteContainer = this.#resolveAutocompleteContainer();
|
||||
|
||||
if (!autocompleteContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Since the autocomplete popup was refactored to re-use the same element over and over again, we need to remove
|
||||
// the options from the popup manually when autocomplete was removed from the DOM, since site is not doing that.
|
||||
const termsToRemove = autocompleteContainer.isConnected
|
||||
// Only removing properties when element is still connected to the DOM (popup is used by the website)
|
||||
? autocompleteContainer.querySelectorAll('.autocomplete__item--property')
|
||||
// Remove everything if popup was disconnected from the DOM.
|
||||
: autocompleteContainer.querySelectorAll('.autocomplete__item')
|
||||
|
||||
for (let existingTerm of termsToRemove) {
|
||||
existingTerm.remove();
|
||||
}
|
||||
|
||||
const listContainer = autocompleteContainer.querySelector('ul');
|
||||
|
||||
if (!listContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (this.#propertiesSuggestionsPosition) {
|
||||
case "start":
|
||||
listContainer.prepend(...suggestedListItems);
|
||||
break;
|
||||
|
||||
case "end":
|
||||
listContainer.append(...suggestedListItems);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn("Invalid position for property suggestions!");
|
||||
}
|
||||
|
||||
const parentScrollTop = targetInput.parentElement?.scrollTop ?? 0;
|
||||
|
||||
autocompleteContainer.style.position = 'absolute';
|
||||
autocompleteContainer.style.left = `${targetInput.offsetLeft}px`;
|
||||
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - parentScrollTop}px`;
|
||||
|
||||
document.body.append(autocompleteContainer);
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Loosely estimate where current selected search term is located and return it if found.
|
||||
* @param tokens Search value to find the actively selected term from.
|
||||
* @param userSelectionIndex The index of the user selection.
|
||||
* @return Search term object or NULL if nothing found.
|
||||
*/
|
||||
static #findActiveSearchTermPosition(tokens: Token[], userSelectionIndex: number): Token | null {
|
||||
return tokens.find(
|
||||
token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regular expression to search the properties' syntax.
|
||||
*/
|
||||
static #propertySearchTermHeadingRegExp = /^(?<name>[a-z\d_]+)(?<op_syntax>\.(?<op>[a-z]*))?(?<value_syntax>:(?<value>.*))?$/;
|
||||
|
||||
/**
|
||||
* Create a list of suggested elements using the input received from the user.
|
||||
* @param searchTermValue Original decoded term received from the user.
|
||||
* @return {string[]} List of suggestions. Could be empty.
|
||||
*/
|
||||
static #resolveSuggestionsFromTerm(searchTermValue: string): string[] {
|
||||
const suggestionsList: string[] = [];
|
||||
|
||||
this.#propertySearchTermHeadingRegExp.lastIndex = 0;
|
||||
const parsedResult = this.#propertySearchTermHeadingRegExp.exec(searchTermValue);
|
||||
|
||||
if (!parsedResult) {
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
const propertyName = parsedResult.groups?.name;
|
||||
|
||||
if (!propertyName) {
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
const propertyType = this.#properties.get(propertyName);
|
||||
const hasOperatorSyntax = Boolean(parsedResult.groups?.op_syntax);
|
||||
const hasValueSyntax = Boolean(parsedResult.groups?.value_syntax);
|
||||
|
||||
// No suggestions for values for now, maybe could add suggestions for namespaces like my:*
|
||||
if (hasValueSyntax && propertyType) {
|
||||
if (this.#typeValues.has(propertyType)) {
|
||||
const givenValue = parsedResult.groups?.value;
|
||||
const candidateValues = this.#typeValues.get(propertyType) || [];
|
||||
|
||||
for (let candidateValue of candidateValues) {
|
||||
if (givenValue && !candidateValue.startsWith(givenValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestionsList.push(`${propertyName}${parsedResult.groups?.op_syntax ?? ''}:${candidateValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
// If at least one dot placed, start suggesting operators
|
||||
if (hasOperatorSyntax && propertyType) {
|
||||
if (this.#typeOperators.has(propertyType)) {
|
||||
const operatorName = parsedResult.groups?.op;
|
||||
const candidateOperators = this.#typeOperators.get(propertyType) ?? [];
|
||||
|
||||
for (let candidateOperator of candidateOperators) {
|
||||
if (operatorName && !candidateOperator.startsWith(operatorName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestionsList.push(`${propertyName}.${candidateOperator}:`);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
// Otherwise, search for properties with names starting with the term
|
||||
for (let [candidateProperty] of this.#properties) {
|
||||
if (propertyName && !candidateProperty.startsWith(propertyName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestionsList.push(candidateProperty);
|
||||
}
|
||||
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single suggestion item and connect required events to interact with the user.
|
||||
* @param suggestedTerm Term to use for suggestion item.
|
||||
* @return Resulting element.
|
||||
*/
|
||||
#renderTermSuggestion(suggestedTerm: string): HTMLElement {
|
||||
const suggestionItem = document.createElement('li');
|
||||
suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property');
|
||||
suggestionItem.dataset.value = suggestedTerm;
|
||||
suggestionItem.innerText = suggestedTerm;
|
||||
|
||||
const propertyIcon = document.createElement('i');
|
||||
propertyIcon.classList.add('fa', 'fa-info-circle');
|
||||
suggestionItem.insertAdjacentElement('afterbegin', propertyIcon);
|
||||
|
||||
suggestionItem.addEventListener('mouseover', () => {
|
||||
SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem);
|
||||
suggestionItem.classList.add('autocomplete__item--selected');
|
||||
});
|
||||
|
||||
suggestionItem.addEventListener('mouseout', () => {
|
||||
SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem);
|
||||
});
|
||||
|
||||
suggestionItem.addEventListener('click', () => {
|
||||
this.#replaceLastActiveTokenWithSuggestion(suggestedTerm);
|
||||
});
|
||||
|
||||
return suggestionItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically replace the last active token stored in the variable with the new value.
|
||||
* @param suggestedTerm Term to replace the value with.
|
||||
*/
|
||||
#replaceLastActiveTokenWithSuggestion(suggestedTerm: string) {
|
||||
if (!this.#lastTermToken || !this.#searchField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchQuery = this.#searchField.value;
|
||||
const beforeToken = searchQuery.substring(0, this.#lastTermToken.index);
|
||||
const afterToken = searchQuery.substring(this.#lastTermToken.index + this.#lastTermToken.value.length);
|
||||
|
||||
let replacementValue = suggestedTerm;
|
||||
|
||||
if (replacementValue.includes('"')) {
|
||||
replacementValue = `"${QuotedTermToken.encode(replacementValue)}"`
|
||||
}
|
||||
|
||||
this.#searchField.value = beforeToken + replacementValue + afterToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's
|
||||
* front-end.
|
||||
* @param suggestedElement Target element to search from. If element is disconnected from the DOM, search will be
|
||||
* halted.
|
||||
*/
|
||||
static #findAndResetSelectedSuggestion(suggestedElement: HTMLElement) {
|
||||
if (!suggestedElement.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let selectedElement of suggestedElement.parentElement.querySelectorAll('.autocomplete__item--selected')) {
|
||||
selectedElement.classList.remove('autocomplete__item--selected');
|
||||
}
|
||||
}
|
||||
|
||||
static #typeNumeric = Symbol();
|
||||
static #typeDate = Symbol();
|
||||
static #typeLiteral = Symbol();
|
||||
static #typePersonal = Symbol();
|
||||
static #typeBoolean = Symbol();
|
||||
|
||||
static #properties = new Map([
|
||||
['animated', SearchWrapper.#typeBoolean],
|
||||
['aspect_ratio', SearchWrapper.#typeNumeric],
|
||||
['body_type_tag_count', SearchWrapper.#typeNumeric],
|
||||
['character_tag_count', SearchWrapper.#typeNumeric],
|
||||
['comment_count', SearchWrapper.#typeNumeric],
|
||||
['content_fanmade_tag_count', SearchWrapper.#typeNumeric],
|
||||
['content_official_tag_count', SearchWrapper.#typeNumeric],
|
||||
['created_at', SearchWrapper.#typeDate],
|
||||
['description', SearchWrapper.#typeLiteral],
|
||||
['downvotes', SearchWrapper.#typeNumeric],
|
||||
['duration', SearchWrapper.#typeNumeric],
|
||||
['error_tag_count', SearchWrapper.#typeNumeric],
|
||||
['faved_by', SearchWrapper.#typeLiteral],
|
||||
['faved_by_id', SearchWrapper.#typeNumeric],
|
||||
['faves', SearchWrapper.#typeNumeric],
|
||||
['file_name', SearchWrapper.#typeLiteral],
|
||||
['first_seen_at', SearchWrapper.#typeDate],
|
||||
['height', SearchWrapper.#typeNumeric],
|
||||
['id', SearchWrapper.#typeNumeric],
|
||||
['oc_tag_count', SearchWrapper.#typeNumeric],
|
||||
['orig_sha512_hash', SearchWrapper.#typeLiteral],
|
||||
['original_format', SearchWrapper.#typeLiteral],
|
||||
['pixels', SearchWrapper.#typeNumeric],
|
||||
['rating_tag_count', SearchWrapper.#typeNumeric],
|
||||
['score', SearchWrapper.#typeNumeric],
|
||||
['sha512_hash', SearchWrapper.#typeLiteral],
|
||||
['size', SearchWrapper.#typeNumeric],
|
||||
['source_count', SearchWrapper.#typeNumeric],
|
||||
['source_url', SearchWrapper.#typeLiteral],
|
||||
['species_tag_count', SearchWrapper.#typeNumeric],
|
||||
['spoiler_tag_count', SearchWrapper.#typeNumeric],
|
||||
['tag_count', SearchWrapper.#typeNumeric],
|
||||
['updated_at', SearchWrapper.#typeDate],
|
||||
['uploader', SearchWrapper.#typeLiteral],
|
||||
['uploader_id', SearchWrapper.#typeNumeric],
|
||||
['upvotes', SearchWrapper.#typeNumeric],
|
||||
['width', SearchWrapper.#typeNumeric],
|
||||
['wilson_score', SearchWrapper.#typeNumeric],
|
||||
['my', SearchWrapper.#typePersonal],
|
||||
]);
|
||||
|
||||
static #comparisonOperators = ['gt', 'gte', 'lt', 'lte'];
|
||||
|
||||
static #typeOperators = new Map([
|
||||
[SearchWrapper.#typeNumeric, SearchWrapper.#comparisonOperators],
|
||||
[SearchWrapper.#typeDate, SearchWrapper.#comparisonOperators],
|
||||
]);
|
||||
|
||||
static #typeValues = new Map([
|
||||
[SearchWrapper.#typePersonal, [
|
||||
'comments',
|
||||
'faves',
|
||||
'posts',
|
||||
'uploads',
|
||||
'upvotes',
|
||||
'watched',
|
||||
]],
|
||||
[SearchWrapper.#typeBoolean, [
|
||||
'true',
|
||||
'false',
|
||||
]]
|
||||
]);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { SearchWrapper } from "$lib/components/SearchWrapper";
|
||||
|
||||
class SiteHeaderWrapper extends BaseComponent {
|
||||
#searchWrapper: SearchWrapper | null = null;
|
||||
|
||||
build() {
|
||||
const searchForm = this.container.querySelector<HTMLElement>('.header__search');
|
||||
this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.#searchWrapper) {
|
||||
this.#searchWrapper.initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeSiteHeader(siteHeaderElement: HTMLElement) {
|
||||
new SiteHeaderWrapper(siteHeaderElement)
|
||||
.initialize();
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { emit, on, type UnsubscribeFunction } from "$lib/components/events/comms";
|
||||
import { eventFetchComplete } from "$lib/components/events/booru-events";
|
||||
import { eventFormEditorUpdated } 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,
|
||||
eventFetchComplete,
|
||||
() => 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, eventFormEditorUpdated, fullTagEditor);
|
||||
} else {
|
||||
console.info('Tag form is not in the tag editor. Event is not sent.');
|
||||
}
|
||||
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
observer.observe(elementContainingTagEditor, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
||||
|
||||
// Make sure to forcibly disconnect everything after a while.
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
get parentTagEditorElement(): HTMLElement | null {
|
||||
return this.container.closest<HTMLElement>('.js-tagsauce')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all the tag categories available on the page and color the tags in the editor according to them.
|
||||
*/
|
||||
refreshTagColors() {
|
||||
const tagCategories = this.#gatherTagCategories();
|
||||
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
|
||||
|
||||
for (const tagElement of editableTags) {
|
||||
// Tag name is stored in the "remove" link and not in the tag itself.
|
||||
const removeLink = tagElement.querySelector('a');
|
||||
|
||||
if (!removeLink) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = removeLink.dataset.tagName;
|
||||
|
||||
if (!tagName || !tagCategories.has(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryName = tagCategories.get(tagName)!;
|
||||
|
||||
tagElement.dataset.tagCategory = categoryName;
|
||||
tagElement.setAttribute('data-tag-category', categoryName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect list of categories from the tags on the page.
|
||||
* @return
|
||||
*/
|
||||
#gatherTagCategories(): Map<string, string> {
|
||||
const tagCategories: Map<string, string> = new Map();
|
||||
|
||||
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
|
||||
const tagName = tagElement.dataset.tagName;
|
||||
const tagCategory = tagElement.dataset.tagCategory;
|
||||
|
||||
if (!tagName || !tagCategory) {
|
||||
console.warn('Missing tag name or category!');
|
||||
continue;
|
||||
}
|
||||
|
||||
tagCategories.set(tagName, tagCategory);
|
||||
}
|
||||
|
||||
return tagCategories;
|
||||
}
|
||||
|
||||
static watchForEditors() {
|
||||
document.body.addEventListener('click', event => {
|
||||
const targetElement = event.target;
|
||||
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
|
||||
|
||||
if (!tagEditorWrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
|
||||
|
||||
if (!refreshTrigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
|
||||
|
||||
if (!tagFormElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tagEditor = getComponent(tagFormElement);
|
||||
|
||||
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
|
||||
tagEditor = new TagsForm(tagFormElement);
|
||||
tagEditor.initialize();
|
||||
}
|
||||
|
||||
(tagEditor as TagsForm).refreshTagColors();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export const eventFetchComplete = 'fetchcomplete';
|
||||
|
||||
export interface BooruEventsMap {
|
||||
[eventFetchComplete]: null; // Site sends the response, but extension will not get it due to isolation.
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
|
||||
|
||||
export const eventSizeLoaded = 'size-loaded';
|
||||
|
||||
export interface FullscreenViewerEventsMap {
|
||||
[eventSizeLoaded]: FullscreenViewerSize;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
export const eventActiveProfileChanged = 'active-profile-changed';
|
||||
export const eventMaintenanceStateChanged = 'maintenance-state-change';
|
||||
export const eventTagsUpdated = 'tags-updated';
|
||||
|
||||
type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting';
|
||||
|
||||
export interface MaintenancePopupEventsMap {
|
||||
[eventActiveProfileChanged]: MaintenanceProfile | null;
|
||||
[eventMaintenanceStateChanged]: MaintenanceState;
|
||||
[eventTagsUpdated]: Map<string, string> | null;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export const eventFormEditorUpdated = 'tags-form-updated';
|
||||
|
||||
export interface TagsFormEventsMap {
|
||||
[eventFormEditorUpdated]: HTMLElement;
|
||||
}
|
||||
4
src/lib/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Automatically generated name of the plugin.
|
||||
*/
|
||||
export const PLUGIN_NAME = __CURRENT_SITE_NAME__ + ' Tagging Assistant';
|
||||
11
src/lib/dom-utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Reusable function to create icons from FontAwesome. Usable only for website, since extension doesn't host its own
|
||||
* copy of FA styles. Extension should use imports of SVGs inside CSS instead.
|
||||
* @param iconSlug Slug of the icon to be added.
|
||||
* @return Element with classes for FontAwesome icon added.
|
||||
*/
|
||||
export function createFontAwesomeIcon(iconSlug: string): HTMLElement {
|
||||
const iconElement = document.createElement('i');
|
||||
iconElement.classList.add('fa-solid', `fa-${iconSlug}`);
|
||||
return iconElement;
|
||||
}
|
||||
124
src/lib/extension/BulkEntitiesTransporter.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
|
||||
import type { ImportableElementsList, ImportableEntityObject } from "$lib/extension/transporting/importables";
|
||||
import EntitiesTransporter, { type SameSiteStatus } from "$lib/extension/EntitiesTransporter";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import TagEditorPreset from "$entities/TagEditorPreset";
|
||||
|
||||
type TransportersMapping = {
|
||||
[EntityName in keyof App.EntityNamesMap]: EntitiesTransporter<App.EntityNamesMap[EntityName]>;
|
||||
}
|
||||
|
||||
export default class BulkEntitiesTransporter {
|
||||
#lastSameSiteStatus: SameSiteStatus = null;
|
||||
|
||||
get lastImportSameSiteStatus() {
|
||||
return this.#lastSameSiteStatus;
|
||||
}
|
||||
|
||||
parseAndImportFromJSON(jsonString: string): StorageEntity[] {
|
||||
let parsedObject: any;
|
||||
|
||||
this.#lastSameSiteStatus = null;
|
||||
|
||||
try {
|
||||
parsedObject = JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
throw new TypeError('Invalid JSON!', {cause: e});
|
||||
}
|
||||
|
||||
if (!BulkEntitiesTransporter.isList(parsedObject)) {
|
||||
throw new TypeError('Invalid or unsupported object!');
|
||||
}
|
||||
|
||||
this.#lastSameSiteStatus = EntitiesTransporter.checkIsSameSiteImportedObject(parsedObject);
|
||||
|
||||
let hasDifferentStatuses = false;
|
||||
|
||||
const resultEntities = parsedObject.elements
|
||||
.map(importableObject => {
|
||||
if (!(importableObject.$type in BulkEntitiesTransporter.#transporters)) {
|
||||
console.warn('Attempting to import unsupported entity: ' + importableObject.$type);
|
||||
return null;
|
||||
}
|
||||
|
||||
const transporter = BulkEntitiesTransporter.#transporters[importableObject.$type as keyof App.EntityNamesMap];
|
||||
const resultEntity = transporter.importFromObject(importableObject);
|
||||
|
||||
if (transporter.lastImportSameSiteStatus !== this.#lastSameSiteStatus) {
|
||||
hasDifferentStatuses = true;
|
||||
}
|
||||
|
||||
return resultEntity;
|
||||
})
|
||||
.filter(maybeEntity => !!maybeEntity);
|
||||
|
||||
if (hasDifferentStatuses) {
|
||||
this.#lastSameSiteStatus = 'unknown';
|
||||
}
|
||||
|
||||
return resultEntities;
|
||||
}
|
||||
|
||||
parseAndImportFromCompressedJSON(compressedJsonString: string): StorageEntity[] {
|
||||
return this.parseAndImportFromJSON(
|
||||
decompressFromEncodedURIComponent(compressedJsonString)
|
||||
);
|
||||
}
|
||||
|
||||
exportToJSON(entities: StorageEntity[]): string {
|
||||
return JSON.stringify({
|
||||
$type: 'list',
|
||||
$site: __CURRENT_SITE__,
|
||||
elements: entities
|
||||
.map(entity => {
|
||||
switch (true) {
|
||||
case entity instanceof TaggingProfile:
|
||||
return BulkEntitiesTransporter.#transporters.profiles.exportToObject(entity);
|
||||
case entity instanceof TagGroup:
|
||||
return BulkEntitiesTransporter.#transporters.groups.exportToObject(entity);
|
||||
case entity instanceof TagEditorPreset:
|
||||
return BulkEntitiesTransporter.#transporters.presets.exportToObject(entity);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter(value => !!value)
|
||||
} as ImportableElementsList<ImportableEntityObject<StorageEntity>>, null, 2);
|
||||
}
|
||||
|
||||
exportToCompressedJSON(entities: StorageEntity[]): string {
|
||||
return compressToEncodedURIComponent(
|
||||
this.exportToJSON(entities)
|
||||
);
|
||||
}
|
||||
|
||||
static isList(targetObject: any): targetObject is ImportableElementsList<ImportableEntityObject<StorageEntity>> {
|
||||
return targetObject.$type
|
||||
&& targetObject.$type === 'list'
|
||||
&& targetObject.elements
|
||||
&& Array.isArray(targetObject.elements);
|
||||
}
|
||||
|
||||
static #transporters: TransportersMapping = {
|
||||
profiles: new EntitiesTransporter(TaggingProfile),
|
||||
groups: new EntitiesTransporter(TagGroup),
|
||||
presets: new EntitiesTransporter(TagEditorPreset),
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the imported object is created for the same site extension or not.
|
||||
* @param importedObject Object to check.
|
||||
* @private
|
||||
*/
|
||||
static #checkIsSameSiteImportedObject(importedObject: Record<string, any>): SameSiteStatus {
|
||||
if (!('$site' in importedObject)) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
return importedObject.$site === __CURRENT_SITE__
|
||||
? "same"
|
||||
: "different";
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,16 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag
|
||||
|
||||
export default class ConfigurationController {
|
||||
readonly #configurationName: string;
|
||||
readonly #storage: StorageHelper;
|
||||
|
||||
/**
|
||||
* @param {string} configurationName Name of the configuration to work with.
|
||||
* @param {StorageHelper} [storage] Selected storage where the settings are stored in. If not provided, local storage
|
||||
* is used.
|
||||
*/
|
||||
constructor(configurationName: string) {
|
||||
constructor(configurationName: string, storage: StorageHelper = new StorageHelper(chrome.storage.local)) {
|
||||
this.#configurationName = configurationName;
|
||||
this.#storage = storage;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,7 +23,7 @@ export default class ConfigurationController {
|
||||
* @return The setting value or the default value if the setting does not exist.
|
||||
*/
|
||||
async readSetting<Type = any, DefaultType = any>(settingName: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
|
||||
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
|
||||
const settings = await this.#storage.read(this.#configurationName, {});
|
||||
return settings[settingName] ?? defaultValue;
|
||||
}
|
||||
|
||||
@@ -32,11 +36,11 @@ export default class ConfigurationController {
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async writeSetting(settingName: string, value: any): Promise<void> {
|
||||
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
|
||||
const settings = await this.#storage.read(this.#configurationName, {});
|
||||
|
||||
settings[settingName] = value;
|
||||
|
||||
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
|
||||
this.#storage.write(this.#configurationName, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,11 +49,11 @@ export default class ConfigurationController {
|
||||
* @param {string} settingName Setting name to delete.
|
||||
*/
|
||||
async deleteSetting(settingName: string): Promise<void> {
|
||||
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
|
||||
const settings = await this.#storage.read(this.#configurationName, {});
|
||||
|
||||
delete settings[settingName];
|
||||
|
||||
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
|
||||
this.#storage.write(this.#configurationName, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,10 +73,8 @@ export default class ConfigurationController {
|
||||
callback(changes[this.#configurationName].newValue);
|
||||
}
|
||||
|
||||
ConfigurationController.#storageHelper.subscribe(subscriber);
|
||||
this.#storage.subscribe(subscriber);
|
||||
|
||||
return () => ConfigurationController.#storageHelper.unsubscribe(subscriber);
|
||||
return () => this.#storage.unsubscribe(subscriber);
|
||||
}
|
||||
|
||||
static #storageHelper = new StorageHelper(chrome.storage.local);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
|
||||
import type { TagDropdown } from "$content/components/philomena/TagDropdown";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import { escapeRegExp } from "$lib/utils";
|
||||
import { emit } from "$content/components/events/comms";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdown-events";
|
||||
|
||||
export default class CustomCategoriesResolver {
|
||||
#tagCategories = new Map<string, string>();
|
||||
#compiledRegExps = new Map<RegExp, string>();
|
||||
#tagDropdowns: TagDropdownWrapper[] = [];
|
||||
#nextQueuedUpdate = -1;
|
||||
#exactGroupMatches = new Map<string, TagGroup>();
|
||||
#regExpGroupMatches = new Map<RegExp, TagGroup>();
|
||||
#tagDropdowns: TagDropdown[] = [];
|
||||
#nextQueuedUpdate: Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
TagGroup.subscribe(this.#onTagGroupsReceived.bind(this));
|
||||
TagGroup.readAll().then(this.#onTagGroupsReceived.bind(this));
|
||||
}
|
||||
|
||||
public addElement(tagDropdown: TagDropdownWrapper): void {
|
||||
public addElement(tagDropdown: TagDropdown): void {
|
||||
this.#tagDropdowns.push(tagDropdown);
|
||||
|
||||
if (!this.#tagCategories.size && !this.#compiledRegExps.size) {
|
||||
if (!this.#exactGroupMatches.size && !this.#regExpGroupMatches.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,7 +26,9 @@ export default class CustomCategoriesResolver {
|
||||
}
|
||||
|
||||
#queueUpdatingTags() {
|
||||
clearTimeout(this.#nextQueuedUpdate);
|
||||
if (this.#nextQueuedUpdate) {
|
||||
clearTimeout(this.#nextQueuedUpdate);
|
||||
}
|
||||
|
||||
this.#nextQueuedUpdate = setTimeout(
|
||||
this.#updateUnprocessedTags.bind(this),
|
||||
@@ -34,7 +38,6 @@ export default class CustomCategoriesResolver {
|
||||
|
||||
#updateUnprocessedTags() {
|
||||
this.#tagDropdowns
|
||||
.filter(CustomCategoriesResolver.#skipTagsWithOriginalCategory)
|
||||
.filter(this.#applyCustomCategoryForExactMatches.bind(this))
|
||||
.filter(this.#matchCustomCategoryByRegExp.bind(this))
|
||||
.forEach(CustomCategoriesResolver.#resetToOriginalCategory);
|
||||
@@ -46,26 +49,36 @@ export default class CustomCategoriesResolver {
|
||||
* @return {boolean} Will return false when tag is processed and true when it is not found.
|
||||
* @private
|
||||
*/
|
||||
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
|
||||
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdown): boolean {
|
||||
const tagName = tagDropdown.tagName!;
|
||||
|
||||
if (!this.#tagCategories.has(tagName)) {
|
||||
if (!this.#exactGroupMatches.has(tagName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
tagDropdown.tagCategory = this.#tagCategories.get(tagName)!;
|
||||
emit(
|
||||
tagDropdown,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
this.#exactGroupMatches.get(tagName)!
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
|
||||
#matchCustomCategoryByRegExp(tagDropdown: TagDropdown) {
|
||||
const tagName = tagDropdown.tagName!;
|
||||
|
||||
for (const targetRegularExpression of this.#compiledRegExps.keys()) {
|
||||
for (const targetRegularExpression of this.#regExpGroupMatches.keys()) {
|
||||
if (!targetRegularExpression.test(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tagDropdown.tagCategory = this.#compiledRegExps.get(targetRegularExpression)!;
|
||||
emit(
|
||||
tagDropdown,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
this.#regExpGroupMatches.get(targetRegularExpression)!
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -73,24 +86,30 @@ export default class CustomCategoriesResolver {
|
||||
}
|
||||
|
||||
#onTagGroupsReceived(tagGroups: TagGroup[]) {
|
||||
this.#tagCategories.clear();
|
||||
this.#compiledRegExps.clear();
|
||||
this.#exactGroupMatches.clear();
|
||||
this.#regExpGroupMatches.clear();
|
||||
|
||||
if (!tagGroups.length) {
|
||||
this.#queueUpdatingTags();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const tagGroup of tagGroups) {
|
||||
const categoryName = tagGroup.settings.category;
|
||||
|
||||
for (const tagName of tagGroup.settings.tags) {
|
||||
this.#tagCategories.set(tagName, categoryName);
|
||||
this.#exactGroupMatches.set(tagName, tagGroup);
|
||||
}
|
||||
|
||||
for (const tagPrefix of tagGroup.settings.prefixes) {
|
||||
this.#compiledRegExps.set(
|
||||
this.#regExpGroupMatches.set(
|
||||
new RegExp(`^${escapeRegExp(tagPrefix)}`),
|
||||
categoryName
|
||||
tagGroup,
|
||||
);
|
||||
}
|
||||
|
||||
for (let tagSuffix of tagGroup.settings.suffixes) {
|
||||
this.#regExpGroupMatches.set(
|
||||
new RegExp(`${escapeRegExp(tagSuffix)}$`),
|
||||
tagGroup,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,12 +117,12 @@ export default class CustomCategoriesResolver {
|
||||
this.#queueUpdatingTags();
|
||||
}
|
||||
|
||||
static #skipTagsWithOriginalCategory(tagDropdown: TagDropdownWrapper): boolean {
|
||||
return !tagDropdown.originalCategory;
|
||||
}
|
||||
|
||||
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
|
||||
tagDropdown.tagCategory = tagDropdown.originalCategory;
|
||||
static #resetToOriginalCategory(tagDropdown: TagDropdown): void {
|
||||
emit(
|
||||
tagDropdown,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
static #unprocessedTagsTimeout = 0;
|
||||
|
||||
@@ -2,17 +2,46 @@ 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?
|
||||
return ((this.#targetEntityConstructor as any) as typeof StorageEntity)._entityName;
|
||||
const entityName = ((this.#targetEntityConstructor as any) as typeof StorageEntity)._entityName;
|
||||
|
||||
if (entityName === "entity") {
|
||||
throw new Error("Generic entity name encountered!");
|
||||
}
|
||||
|
||||
return entityName;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,32 +55,47 @@ export default class EntitiesTransporter<EntityType> {
|
||||
this.#targetEntityConstructor = entityConstructor;
|
||||
}
|
||||
|
||||
importFromJSON(jsonString: string): EntityType {
|
||||
const importedObject = this.#tryParsingAsJSON(jsonString);
|
||||
isCorrectEntity(entityObject: unknown): entityObject is EntityType {
|
||||
return entityObject instanceof this.#targetEntityConstructor;
|
||||
}
|
||||
|
||||
if (!importedObject) {
|
||||
throw new Error('Invalid JSON!');
|
||||
}
|
||||
importFromObject(importedObject: Record<string, any>): EntityType {
|
||||
this.#lastSameSiteStatus = null;
|
||||
|
||||
// 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)
|
||||
)
|
||||
}
|
||||
|
||||
exportToJSON(entityObject: EntityType): string {
|
||||
if (!(entityObject instanceof this.#targetEntityConstructor)) {
|
||||
exportToObject(entityObject: EntityType) {
|
||||
if (!this.isCorrectEntity(entityObject)) {
|
||||
throw new TypeError('Transporter should be connected to the same entity to export!');
|
||||
}
|
||||
|
||||
@@ -59,12 +103,18 @@ export default class EntitiesTransporter<EntityType> {
|
||||
throw new TypeError('Only storage entities could be exported!');
|
||||
}
|
||||
|
||||
const exportableObject = exportEntityToObject(
|
||||
entityObject,
|
||||
this.#entityName
|
||||
return exportEntityToObject(
|
||||
this.#entityName,
|
||||
entityObject
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.stringify(exportableObject, null, 2);
|
||||
exportToJSON(entityObject: EntityType): string {
|
||||
return JSON.stringify(
|
||||
this.exportToObject(entityObject),
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
exportToCompressedJSON(entityObject: EntityType): string {
|
||||
@@ -86,4 +136,18 @@ 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";
|
||||
}
|
||||
}
|
||||
|
||||
179
src/lib/extension/base/CacheablePreferences.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController";
|
||||
|
||||
/**
|
||||
* Initialization options for the preference field helper class.
|
||||
*/
|
||||
type PreferenceFieldOptions<FieldKey, ValueType> = {
|
||||
/**
|
||||
* Field name which will be read or updated.
|
||||
*/
|
||||
field: FieldKey;
|
||||
/**
|
||||
* Default value for this field.
|
||||
*/
|
||||
defaultValue: ValueType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class for a field. Contains all information needed to read or set the values into the preferences while
|
||||
* retaining proper types for the values.
|
||||
*/
|
||||
export class PreferenceField<
|
||||
/**
|
||||
* Mapping of keys to fields. Usually this is the same type used for defining the structure of the storage itself.
|
||||
* Is automatically captured when preferences class instance is passed into the constructor.
|
||||
*/
|
||||
Fields extends Record<string, any> = Record<string, any>,
|
||||
/**
|
||||
* Field key for resolving which value will be resolved from getter or which value type should be passed into the
|
||||
* setter method.
|
||||
*/
|
||||
Key extends keyof Fields = keyof Fields
|
||||
> {
|
||||
/**
|
||||
* Instance of the preferences class to read/update values on.
|
||||
* @private
|
||||
*/
|
||||
readonly #preferences: CacheablePreferences<Fields>;
|
||||
/**
|
||||
* Key of a field we want to read or write with the helper class.
|
||||
* @private
|
||||
*/
|
||||
readonly #fieldKey: Key;
|
||||
/**
|
||||
* Stored default value for a field.
|
||||
* @private
|
||||
*/
|
||||
readonly #defaultValue: Fields[Key];
|
||||
|
||||
/**
|
||||
* @param preferencesInstance Instance of preferences to work with.
|
||||
* @param options Initialization options for this field.
|
||||
*/
|
||||
constructor(preferencesInstance: CacheablePreferences<Fields>, options: PreferenceFieldOptions<Key, Fields[Key]>) {
|
||||
this.#preferences = preferencesInstance;
|
||||
this.#fieldKey = options.field;
|
||||
this.#defaultValue = options.defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the field value from the preferences.
|
||||
*/
|
||||
get() {
|
||||
return this.#preferences.readRaw(this.#fieldKey, this.#defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the preference field with provided value.
|
||||
* @param value Value to update the field with.
|
||||
*/
|
||||
set(value: Fields[Key]) {
|
||||
return this.#preferences.writeRaw(this.#fieldKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper type for preference classes to enforce having field objects inside the preferences instance. It should be
|
||||
* applied on child classes of {@link CacheablePreferences}.
|
||||
*/
|
||||
export type WithFields<FieldsType extends Record<string, any>> = {
|
||||
readonly [FieldKey in keyof FieldsType]: PreferenceField<FieldsType, FieldKey>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for any preferences instances. It contains methods for reading or updating any arbitrary values inside
|
||||
* extension storage. It also tries to save the value resolved from the storage into special internal cache after the
|
||||
* first call.
|
||||
*
|
||||
* Should be usually paired with implementation of {@link WithFields} helper type as interface for much more usable
|
||||
* API.
|
||||
*/
|
||||
export default abstract class CacheablePreferences<Fields> {
|
||||
#controller: ConfigurationController;
|
||||
#cachedValues: Map<keyof Fields, any> = new Map();
|
||||
#disposables: Function[] = [];
|
||||
|
||||
/**
|
||||
* @param settingsNamespace Name of the field inside the extension storage where these preferences stored.
|
||||
* @protected
|
||||
*/
|
||||
protected constructor(settingsNamespace: string) {
|
||||
this.#controller = new ConfigurationController(settingsNamespace);
|
||||
|
||||
this.#disposables.push(
|
||||
this.#controller.subscribeToChanges(settings => {
|
||||
for (const key of Object.keys(settings)) {
|
||||
this.#cachedValues.set(
|
||||
key as keyof Fields,
|
||||
settings[key]
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value from the preferences by the field. This function doesn't handle default values, so you generally
|
||||
* should avoid using this method and accessing the special fields instead.
|
||||
* @param settingName Name of the field to read.
|
||||
* @param defaultValue Default value to return if value is not set.
|
||||
* @return Value of the field or default value if it is not set.
|
||||
*/
|
||||
public async readRaw<Key extends keyof Fields>(settingName: Key, defaultValue: Fields[Key]): Promise<Fields[Key]> {
|
||||
if (this.#cachedValues.has(settingName)) {
|
||||
return this.#cachedValues.get(settingName);
|
||||
}
|
||||
|
||||
const settingValue = await this.#controller.readSetting(settingName as string, defaultValue);
|
||||
|
||||
this.#cachedValues.set(settingName, settingValue);
|
||||
|
||||
return settingValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the value into specific field of the storage. You should generally avoid calling this function directly and
|
||||
* instead rely on special field helpers inside your preferences class.
|
||||
* @param settingName Name of the setting to write.
|
||||
* @param value Value to pass.
|
||||
* @param force Ignore the cache and force the update.
|
||||
* @protected
|
||||
*/
|
||||
async writeRaw<Key extends keyof Fields>(settingName: Key, value: Fields[Key], force: boolean = false): Promise<void> {
|
||||
if (
|
||||
!force
|
||||
&& this.#cachedValues.has(settingName)
|
||||
&& this.#cachedValues.get(settingName) === value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.#controller.writeSetting(
|
||||
settingName as string,
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the changes made to the storage.
|
||||
* @param callback Callback which will receive list of settings on every update. This function will not be called
|
||||
* on initialization.
|
||||
* @return Unsubscribe function to call in order to disable the watching.
|
||||
*/
|
||||
subscribe(callback: (settings: Partial<Fields>) => void): () => void {
|
||||
const unsubscribeCallback = this.#controller.subscribeToChanges(callback as (fields: Record<string, any>) => void);
|
||||
|
||||
this.#disposables.push(unsubscribeCallback);
|
||||
|
||||
return unsubscribeCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Completely disable all subscriptions.
|
||||
*/
|
||||
dispose() {
|
||||
for (let disposeCallback of this.#disposables) {
|
||||
disposeCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController";
|
||||
|
||||
export default class CacheableSettings<Fields> {
|
||||
#controller: ConfigurationController;
|
||||
#cachedValues: Map<keyof Fields, any> = new Map();
|
||||
#disposables: Function[] = [];
|
||||
|
||||
constructor(settingsNamespace: string) {
|
||||
this.#controller = new ConfigurationController(settingsNamespace);
|
||||
|
||||
this.#disposables.push(
|
||||
this.#controller.subscribeToChanges(settings => {
|
||||
for (const key of Object.keys(settings)) {
|
||||
this.#cachedValues.set(
|
||||
key as keyof Fields,
|
||||
settings[key]
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template SettingType
|
||||
* @param {string} settingName
|
||||
* @param {SettingType} defaultValue
|
||||
* @return {Promise<SettingType>}
|
||||
* @protected
|
||||
*/
|
||||
protected async _resolveSetting<Key extends keyof Fields>(settingName: Key, defaultValue: Fields[Key]): Promise<Fields[Key]> {
|
||||
if (this.#cachedValues.has(settingName)) {
|
||||
return this.#cachedValues.get(settingName);
|
||||
}
|
||||
|
||||
const settingValue = await this.#controller.readSetting(settingName as string, defaultValue);
|
||||
|
||||
this.#cachedValues.set(settingName, settingValue);
|
||||
|
||||
return settingValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param settingName Name of the setting to write.
|
||||
* @param value Value to pass.
|
||||
* @param force Ignore the cache and force the update.
|
||||
* @protected
|
||||
*/
|
||||
async _writeSetting<Key extends keyof Fields>(settingName: Key, value: Fields[Key], force: boolean = false): Promise<void> {
|
||||
if (
|
||||
!force
|
||||
&& this.#cachedValues.has(settingName)
|
||||
&& this.#cachedValues.get(settingName) === value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.#controller.writeSetting(
|
||||
settingName as string,
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the changes made to the storage.
|
||||
* @param {function(Object): void} callback Callback which will receive list of settings.
|
||||
* @return {function(): void} Unsubscribe function.
|
||||
*/
|
||||
subscribe(callback: (settings: Partial<Fields>) => void): () => void {
|
||||
const unsubscribeCallback = this.#controller.subscribeToChanges(callback as (fields: Record<string, any>) => void);
|
||||
|
||||
this.#disposables.push(unsubscribeCallback);
|
||||
|
||||
return unsubscribeCallback;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (let disposeCallback of this.#disposables) {
|
||||
disposeCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,18 +24,22 @@ export default abstract class StorageEntity<SettingsType extends Object = {}> {
|
||||
return this.#settings;
|
||||
}
|
||||
|
||||
public static readonly _entityName: string = "entity";
|
||||
get type() {
|
||||
return (this.constructor as typeof StorageEntity)._entityName;
|
||||
}
|
||||
|
||||
public static readonly _entityName: keyof App.EntityNamesMap | "entity" = "entity";
|
||||
|
||||
async save() {
|
||||
await EntitiesController.updateEntity(
|
||||
(this.constructor as typeof StorageEntity)._entityName,
|
||||
this.type,
|
||||
this
|
||||
);
|
||||
}
|
||||
|
||||
async delete() {
|
||||
await EntitiesController.deleteEntity(
|
||||
(this.constructor as typeof StorageEntity)._entityName,
|
||||
this.type,
|
||||
this.id
|
||||
);
|
||||
}
|
||||
|
||||
17
src/lib/extension/entities/TagEditorPreset.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
interface TagEditorPresetSettings {
|
||||
name: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export default class TagEditorPreset extends StorageEntity<TagEditorPresetSettings> {
|
||||
constructor(id: string, settings: Partial<TagEditorPresetSettings>) {
|
||||
super(id, {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || [],
|
||||
});
|
||||
}
|
||||
|
||||
public static readonly _entityName = 'presets';
|
||||
}
|
||||
@@ -4,7 +4,9 @@ export interface TagGroupSettings {
|
||||
name: string;
|
||||
tags: string[];
|
||||
prefixes: string[];
|
||||
suffixes: string[];
|
||||
category: string;
|
||||
separate: boolean;
|
||||
}
|
||||
|
||||
export default class TagGroup extends StorageEntity<TagGroupSettings> {
|
||||
@@ -13,9 +15,11 @@ export default class TagGroup extends StorageEntity<TagGroupSettings> {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || [],
|
||||
prefixes: settings.prefixes || [],
|
||||
category: settings.category || ''
|
||||
suffixes: settings.suffixes || [],
|
||||
category: settings.category || '',
|
||||
separate: Boolean(settings.separate),
|
||||
});
|
||||
}
|
||||
|
||||
static _entityName = 'groups';
|
||||
public static readonly _entityName: keyof App.EntityNamesMap = "groups";
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export interface MaintenanceProfileSettings {
|
||||
export interface TaggingProfileSettings {
|
||||
name: string;
|
||||
tags: string[];
|
||||
temporary: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class representing the maintenance profile entity.
|
||||
* Class representing the tagging profile entity.
|
||||
*/
|
||||
export default class MaintenanceProfile extends StorageEntity<MaintenanceProfileSettings> {
|
||||
export default class TaggingProfile extends StorageEntity<TaggingProfileSettings> {
|
||||
/**
|
||||
* @param id ID of the entity.
|
||||
* @param settings Maintenance profile settings object.
|
||||
*/
|
||||
constructor(id: string, settings: Partial<MaintenanceProfileSettings>) {
|
||||
constructor(id: string, settings: Partial<TaggingProfileSettings>) {
|
||||
super(id, {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || [],
|
||||
@@ -30,5 +30,5 @@ export default class MaintenanceProfile extends StorageEntity<MaintenanceProfile
|
||||
return super.save();
|
||||
}
|
||||
|
||||
public static readonly _entityName = "profiles";
|
||||
public static readonly _entityName: keyof App.EntityNamesMap = "profiles";
|
||||
}
|
||||
27
src/lib/extension/preferences/MiscPreferences.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import CacheablePreferences, {
|
||||
PreferenceField,
|
||||
type WithFields
|
||||
} from "$lib/extension/base/CacheablePreferences";
|
||||
|
||||
export type FullscreenViewerSize = keyof App.ImageURIs;
|
||||
|
||||
interface MiscPreferencesFields {
|
||||
fullscreenViewer: boolean;
|
||||
fullscreenViewerSize: FullscreenViewerSize;
|
||||
}
|
||||
|
||||
export default class MiscPreferences extends CacheablePreferences<MiscPreferencesFields> implements WithFields<MiscPreferencesFields> {
|
||||
constructor() {
|
||||
super("misc");
|
||||
}
|
||||
|
||||
readonly fullscreenViewer = new PreferenceField(this, {
|
||||
field: "fullscreenViewer",
|
||||
defaultValue: true,
|
||||
});
|
||||
|
||||
readonly fullscreenViewerSize = new PreferenceField(this, {
|
||||
field: "fullscreenViewerSize",
|
||||
defaultValue: "large",
|
||||
});
|
||||
}
|
||||
40
src/lib/extension/preferences/TaggingProfilesPreferences.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
|
||||
interface TaggingProfilePreferencesFields {
|
||||
activeProfile: string | null;
|
||||
stripBlacklistedTags: boolean;
|
||||
}
|
||||
|
||||
class ActiveProfilePreference extends PreferenceField<TaggingProfilePreferencesFields, "activeProfile"> {
|
||||
constructor(preferencesInstance: CacheablePreferences<TaggingProfilePreferencesFields>) {
|
||||
super(preferencesInstance, {
|
||||
field: "activeProfile",
|
||||
defaultValue: null,
|
||||
});
|
||||
}
|
||||
|
||||
async asObject(): Promise<TaggingProfile | null> {
|
||||
const activeProfileId = await this.get();
|
||||
|
||||
if (!activeProfileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await TaggingProfile.readAll())
|
||||
.find(profile => profile.id === activeProfileId) || null;
|
||||
}
|
||||
}
|
||||
|
||||
export default class TaggingProfilesPreferences extends CacheablePreferences<TaggingProfilePreferencesFields> implements WithFields<TaggingProfilePreferencesFields> {
|
||||
constructor() {
|
||||
super("maintenance");
|
||||
}
|
||||
|
||||
readonly activeProfile = new ActiveProfilePreference(this);
|
||||
|
||||
readonly stripBlacklistedTags = new PreferenceField(this, {
|
||||
field: "stripBlacklistedTags",
|
||||
defaultValue: false,
|
||||
});
|
||||
}
|
||||
28
src/lib/extension/preferences/TagsPreferences.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
|
||||
interface TagsPreferencesFields {
|
||||
groupSeparation: boolean;
|
||||
replaceLinks: boolean;
|
||||
replaceLinkText: boolean;
|
||||
}
|
||||
|
||||
export default class TagsPreferences extends CacheablePreferences<TagsPreferencesFields> implements WithFields<TagsPreferencesFields> {
|
||||
constructor() {
|
||||
super("tag");
|
||||
}
|
||||
|
||||
readonly groupSeparation = new PreferenceField(this, {
|
||||
field: "groupSeparation",
|
||||
defaultValue: true,
|
||||
});
|
||||
|
||||
readonly replaceLinks = new PreferenceField(this, {
|
||||
field: "replaceLinks",
|
||||
defaultValue: false,
|
||||
});
|
||||
|
||||
readonly replaceLinkText = new PreferenceField(this, {
|
||||
field: "replaceLinkText",
|
||||
defaultValue: true,
|
||||
});
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings";
|
||||
|
||||
interface MaintenanceSettingsFields {
|
||||
activeProfile: string | null;
|
||||
stripBlacklistedTags: boolean;
|
||||
}
|
||||
|
||||
export default class MaintenanceSettings extends CacheableSettings<MaintenanceSettingsFields> {
|
||||
constructor() {
|
||||
super("maintenance");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active maintenance profile.
|
||||
*/
|
||||
async resolveActiveProfileId() {
|
||||
return this._resolveSetting("activeProfile", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active maintenance profile if it is set.
|
||||
*/
|
||||
async resolveActiveProfileAsObject(): Promise<MaintenanceProfile | null> {
|
||||
const resolvedProfileId = await this.resolveActiveProfileId();
|
||||
|
||||
return (await MaintenanceProfile.readAll())
|
||||
.find(profile => profile.id === resolvedProfileId) || null;
|
||||
}
|
||||
|
||||
async resolveStripBlacklistedTags() {
|
||||
return this._resolveSetting('stripBlacklistedTags', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active maintenance profile.
|
||||
*
|
||||
* @param profileId ID of the profile to set as active. If `null`, the active profile will be considered
|
||||
* unset.
|
||||
*/
|
||||
async setActiveProfileId(profileId: string | null): Promise<void> {
|
||||
await this._writeSetting("activeProfile", profileId);
|
||||
}
|
||||
|
||||
async setStripBlacklistedTags(isEnabled: boolean) {
|
||||
await this._writeSetting('stripBlacklistedTags', isEnabled);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings";
|
||||
|
||||
export type FullscreenViewerSize = keyof App.ImageURIs;
|
||||
|
||||
interface MiscSettingsFields {
|
||||
fullscreenViewer: boolean;
|
||||
fullscreenViewerSize: FullscreenViewerSize;
|
||||
}
|
||||
|
||||
export default class MiscSettings extends CacheableSettings<MiscSettingsFields> {
|
||||
constructor() {
|
||||
super("misc");
|
||||
}
|
||||
|
||||
async resolveFullscreenViewerEnabled() {
|
||||
return this._resolveSetting("fullscreenViewer", true);
|
||||
}
|
||||
|
||||
async resolveFullscreenViewerPreviewSize() {
|
||||
return this._resolveSetting('fullscreenViewerSize', 'large');
|
||||
}
|
||||
|
||||
async setFullscreenViewerEnabled(isEnabled: boolean) {
|
||||
return this._writeSetting("fullscreenViewer", isEnabled);
|
||||
}
|
||||
|
||||
async setFullscreenViewerPreviewSize(size: FullscreenViewerSize | string) {
|
||||
return this._writeSetting('fullscreenViewerSize', size as FullscreenViewerSize);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||