mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2025-12-23 23:02:58 +00:00
BIN
.github/assets/fullscreen-viewer-icon.png
vendored
Normal file
BIN
.github/assets/fullscreen-viewer-icon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
.github/assets/fullscreen-viewer-showcase.png
vendored
Normal file
BIN
.github/assets/fullscreen-viewer-showcase.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 343 KiB |
BIN
.github/assets/groups-showcase.png
vendored
Normal file
BIN
.github/assets/groups-showcase.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
.github/assets/profiles-showcase.png
vendored
Normal file
BIN
.github/assets/profiles-showcase.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
@@ -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.
|
||||
@@ -56,69 +58,187 @@ 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'),
|
||||
}
|
||||
});
|
||||
|
||||
// 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,
|
||||
],
|
||||
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,
|
||||
],
|
||||
define: defineConstants,
|
||||
});
|
||||
|
||||
return path.resolve(buildOptions.outputDir, `${outputBaseName}.css`);
|
||||
return pathsReplacement;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,3 +247,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,53 @@ 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];
|
||||
}
|
||||
|
||||
this.#manifestObject.host_permissions = singleOrMultipleHostnames.map(hostname => `*://*.${hostname}/`);
|
||||
|
||||
this.#manifestObject.content_scripts?.forEach(entry => {
|
||||
entry.matches = entry.matches.reduce((resultMatches, originalMatchPattern) => {
|
||||
for (const updatedHostname of singleOrMultipleHostnames) {
|
||||
resultMatches.push(
|
||||
originalMatchPattern.replace(
|
||||
/\*:\/\/\*\.[a-z]+\.[a-z]+\//,
|
||||
`*://*.${updatedHostname}/`
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return resultMatches;
|
||||
}, []);
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -86,13 +165,27 @@ 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[]|undefined} js
|
||||
* @property {string[]|undefined} css
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* Build addition assets required for the extension and pack it into the directory.
|
||||
@@ -11,45 +11,70 @@ 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;
|
||||
});
|
||||
})
|
||||
|
||||
if (process.env.SITE === 'derpibooru') {
|
||||
manifest.replaceHostTo([
|
||||
'derpibooru.org',
|
||||
'trixiebooru.org'
|
||||
]);
|
||||
manifest.replaceBooruNameWith('Derpibooru');
|
||||
manifest.setGeckoIdentifier('derpibooru-tagging-assistant@thecore.city');
|
||||
}
|
||||
|
||||
manifest.passVersionFromPackage(path.resolve(settings.rootDir, 'package.json'));
|
||||
manifest.saveTo(path.resolve(settings.exportDir, 'manifest.json'));
|
||||
|
||||
46
.vite/plugins/scss-read-env-variable-function.js
Normal file
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
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
|
||||
*/
|
||||
56
README.md
56
README.md
@@ -1,10 +1,47 @@
|
||||
# Furbooru Tagging Assistant
|
||||
# Philomena Tagging Assistant
|
||||
|
||||
This is a browser extension written for the [Furbooru](https://furbooru.org) and [Derpibooru](https://derpibooru.org)
|
||||
image-boards. It gives you the ability to manually go over the list of images and apply tags to them without opening
|
||||
each individual image.
|
||||
|
||||
## Installation
|
||||
|
||||
This extension is available for both Chromium- and Firefox-based browsers. You can find the links to the extension pages
|
||||
below.
|
||||
|
||||
### Furbooru Tagging Assistant
|
||||
|
||||
[](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 Tagging Assistant
|
||||
|
||||
I wasn't able to release the extension for Derpibooru yet. Links will be available shortly.
|
||||
|
||||
## 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 +56,18 @@ 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 2 different imageboards using one of the following commands:
|
||||
|
||||
```shell
|
||||
# To build the extension for Furbooru, use:
|
||||
npm run build
|
||||
|
||||
# To build the extension for Derpbooru, use:
|
||||
npm run build:derpibooru
|
||||
```
|
||||
|
||||
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,7 +1,7 @@
|
||||
{
|
||||
"name": "Furbooru Tagging Assistant",
|
||||
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
|
||||
"version": "0.4.5",
|
||||
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
|
||||
"version": "0.5.0",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "furbooru-tagging-assistant@thecore.city"
|
||||
@@ -18,6 +18,14 @@
|
||||
"*://*.furbooru.org/"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/deps/amd.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/",
|
||||
@@ -33,17 +41,6 @@
|
||||
"src/styles/content/listing.scss"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/header.ts"
|
||||
],
|
||||
"css": [
|
||||
"src/styles/content/header.scss"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/images/*"
|
||||
|
||||
37
package-lock.json
generated
37
package-lock.json
generated
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"amd-lite": "^1.0.1",
|
||||
"lz-string": "^1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -19,6 +20,7 @@
|
||||
"@types/node": "^22.15.29",
|
||||
"@vitest/coverage-v8": "^3.2.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"cross-env": "^10.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"sass": "^1.89.1",
|
||||
"svelte": "^5.33.14",
|
||||
@@ -230,6 +232,13 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz",
|
||||
@@ -1410,6 +1419,12 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/amd-lite": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/amd-lite/-/amd-lite-1.0.1.tgz",
|
||||
"integrity": "sha512-2EqBXjF5YjgCHeb4hfhUx3iIiN/vmuXjXdT1QVDXylwH5c2oqEvGPDmyVo9yGRGEQlGRdwa9gJ68/m32yUnriw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
@@ -1639,6 +1654,24 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.0.0.tgz",
|
||||
"integrity": "sha512-aU8qlEK/nHYtVuN4p7UQgAwVljzMg8hB4YK5ThRqD2l/ziSnryncPNn7bMLt5cFYsKVKBh8HqLqyCoTupEUu7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@epic-web/invariant": "^1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "dist/bin/cross-env.js",
|
||||
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "npm run build:popup && npm run build:extension",
|
||||
"build:derpibooru": "cross-env SITE=derpibooru npm run build",
|
||||
"build:popup": "vite build",
|
||||
"build:extension": "node build-extension.js",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
@@ -19,6 +20,7 @@
|
||||
"@types/node": "^22.15.29",
|
||||
"@vitest/coverage-v8": "^3.2.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"cross-env": "^10.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"sass": "^1.89.1",
|
||||
"svelte": "^5.33.14",
|
||||
@@ -30,6 +32,7 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"amd-lite": "^1.0.1",
|
||||
"lz-string": "^1.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
10
src/app.d.ts
vendored
10
src/app.d.ts
vendored
@@ -4,6 +4,16 @@ import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
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>;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<header>
|
||||
<a href="/">Furbooru Tagging Assistant</a>
|
||||
<a href="/">{__CURRENT_SITE_NAME__} Tagging Assistant</a>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -19,50 +19,60 @@
|
||||
.tag-color-container:is(:global(.tag-color-container--rating)) :global(.tag) {
|
||||
background-color: colors.$tag-rating-background;
|
||||
color: colors.$tag-rating-text;
|
||||
border-color: colors.$tag-rating-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--spoiler)) :global(.tag) {
|
||||
background-color: colors.$tag-spoiler-background;
|
||||
color: colors.$tag-spoiler-text;
|
||||
border-color: colors.$tag-spoiler-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--origin)) :global(.tag) {
|
||||
background-color: colors.$tag-origin-background;
|
||||
color: colors.$tag-origin-text;
|
||||
border-color: colors.$tag-origin-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--oc)) :global(.tag) {
|
||||
background-color: colors.$tag-oc-background;
|
||||
color: colors.$tag-oc-text;
|
||||
border-color: colors.$tag-oc-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--error)) :global(.tag) {
|
||||
background-color: colors.$tag-error-background;
|
||||
color: colors.$tag-error-text;
|
||||
border-color: colors.$tag-error-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--character)) :global(.tag) {
|
||||
background-color: colors.$tag-character-background;
|
||||
color: colors.$tag-character-text;
|
||||
border-color: colors.$tag-character-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--content-official)) :global(.tag) {
|
||||
background-color: colors.$tag-content-official-background;
|
||||
color: colors.$tag-content-official-text;
|
||||
border-color: colors.$tag-content-official-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--content-fanmade)) :global(.tag) {
|
||||
background-color: colors.$tag-content-fanmade-background;
|
||||
color: colors.$tag-content-fanmade-text;
|
||||
border-color: colors.$tag-content-fanmade-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--species)) :global(.tag) {
|
||||
background-color: colors.$tag-species-background;
|
||||
color: colors.$tag-species-text;
|
||||
border-color: colors.$tag-species-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--body-type)) :global(.tag) {
|
||||
background-color: colors.$tag-body-type-background;
|
||||
color: colors.$tag-body-type-text;
|
||||
border-color: colors.$tag-body-type-border;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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,4 @@
|
||||
export const tagsBlacklist: string[] = [
|
||||
export const tagsBlacklist: string[] = (__CURRENT_SITE__ === 'furbooru' ? [
|
||||
"anthro art",
|
||||
"anthro artist",
|
||||
"anthro cute",
|
||||
@@ -63,4 +63,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"
|
||||
]);
|
||||
|
||||
22
src/content/deps/amd.ts
Normal file
22
src/content/deps/amd.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { amdLite } from "amd-lite";
|
||||
|
||||
const originalDefine = amdLite.define;
|
||||
|
||||
amdLite.define = (name, dependencies, originalCallback) => {
|
||||
return 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 : {};
|
||||
})
|
||||
}
|
||||
|
||||
amdLite.init({
|
||||
publicScope: window
|
||||
});
|
||||
|
||||
// We don't have anything asynchronous, so it's safe to execute everything on the next frame.
|
||||
requestAnimationFrame(() => {
|
||||
amdLite.resolveDependencies(Object.keys(amdLite.waitingModules))
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { initializeSiteHeader } from "$lib/components/SiteHeaderWrapper";
|
||||
|
||||
const siteHeader = document.querySelector<HTMLElement>('.header');
|
||||
|
||||
if (siteHeader) {
|
||||
initializeSiteHeader(siteHeader);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
120
src/lib/extension/BulkEntitiesTransporter.ts
Normal file
120
src/lib/extension/BulkEntitiesTransporter.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
|
||||
import type { ImportableElementsList, ImportableEntityObject } from "$lib/extension/transporting/importables";
|
||||
import EntitiesTransporter, { type SameSiteStatus } from "$lib/extension/EntitiesTransporter";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
|
||||
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 MaintenanceProfile:
|
||||
return BulkEntitiesTransporter.#transporters.profiles.exportToObject(entity);
|
||||
case entity instanceof TagGroup:
|
||||
return BulkEntitiesTransporter.#transporters.groups.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(MaintenanceProfile),
|
||||
groups: new EntitiesTransporter(TagGroup),
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -21,5 +21,5 @@ export default class TagGroup extends StorageEntity<TagGroupSettings> {
|
||||
});
|
||||
}
|
||||
|
||||
static _entityName = 'groups';
|
||||
public static readonly _entityName: keyof App.EntityNamesMap = "groups";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,30 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
import type { ImportableEntityObject } from "$lib/extension/transporting/importables";
|
||||
|
||||
type ExporterFunction<EntityType extends StorageEntity> = (entity: EntityType) => ImportableEntityObject<EntityType>;
|
||||
|
||||
type ExportersMap = {
|
||||
[EntityName in keyof App.EntityNamesMap]: (entity: App.EntityNamesMap[EntityName]) => Record<string, any>
|
||||
};
|
||||
[EntityName in keyof App.EntityNamesMap]: ExporterFunction<App.EntityNamesMap[EntityName]>;
|
||||
}
|
||||
|
||||
const entitiesExporters: ExportersMap = {
|
||||
profiles: entity => {
|
||||
return {
|
||||
v: 1,
|
||||
$type: "profiles",
|
||||
$site: __CURRENT_SITE__,
|
||||
v: 2,
|
||||
id: entity.id,
|
||||
name: entity.settings.name,
|
||||
tags: entity.settings.tags,
|
||||
// Any exported profile should be considered non-temporary.
|
||||
temporary: false,
|
||||
}
|
||||
},
|
||||
groups: entity => {
|
||||
return {
|
||||
v: 1,
|
||||
$type: "groups",
|
||||
$site: __CURRENT_SITE__,
|
||||
v: 2,
|
||||
id: entity.id,
|
||||
name: entity.settings.name,
|
||||
tags: entity.settings.tags,
|
||||
@@ -27,10 +36,13 @@ const entitiesExporters: ExportersMap = {
|
||||
}
|
||||
};
|
||||
|
||||
export function exportEntityToObject(entityInstance: StorageEntity<any>, entityName: string): Record<string, any> {
|
||||
export function exportEntityToObject<EntityName extends keyof App.EntityNamesMap>(
|
||||
entityName: EntityName,
|
||||
entityInstance: App.EntityNamesMap[EntityName]
|
||||
): ImportableEntityObject<App.EntityNamesMap[EntityName]> {
|
||||
if (!(entityName in entitiesExporters) || !entitiesExporters.hasOwnProperty(entityName)) {
|
||||
throw new Error(`Missing exporter for entity: ${entityName}`);
|
||||
}
|
||||
|
||||
return entitiesExporters[entityName as keyof App.EntityNamesMap].call(null, entityInstance);
|
||||
return entitiesExporters[entityName].call(null, entityInstance);
|
||||
}
|
||||
|
||||
40
src/lib/extension/transporting/importables.ts
Normal file
40
src/lib/extension/transporting/importables.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export interface ImportableElement<Type extends string = string> {
|
||||
/**
|
||||
* Type of importable. Should be unique to properly import everything.
|
||||
*/
|
||||
$type: Type;
|
||||
/**
|
||||
* Identifier of the site this element is built for.
|
||||
*/
|
||||
$site?: string;
|
||||
}
|
||||
|
||||
export interface ImportableElementsList<ElementsType extends ImportableElement = ImportableElement> extends ImportableElement<"list"> {
|
||||
/**
|
||||
* List of elements inside. Elements could be of any type and should be checked and mapped.
|
||||
*/
|
||||
elements: ElementsType[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Base information on the object which should be present on every entity.
|
||||
*/
|
||||
export interface BaseImportableEntity extends ImportableElement<keyof App.EntityNamesMap> {
|
||||
/**
|
||||
* Numeric version of the entity for upgrading.
|
||||
*/
|
||||
v: number;
|
||||
/**
|
||||
* Unique ID of the entity.
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility type which combines base importable object and the entity type interfaces together. It strips away any types
|
||||
* defined for the properties, since imported object can not be trusted and should be type-checked by the validators.
|
||||
*/
|
||||
export type ImportableEntityObject<EntityType extends StorageEntity> = { [ObjectKey in keyof BaseImportableEntity]: any }
|
||||
& { [SettingKey in keyof EntityType["settings"]]: any };
|
||||
@@ -1,32 +1,12 @@
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
/**
|
||||
* Base information on the object which should be present on every entity.
|
||||
*/
|
||||
interface BaseImportableObject {
|
||||
/**
|
||||
* Numeric version of the entity for upgrading.
|
||||
*/
|
||||
v: number;
|
||||
/**
|
||||
* Unique ID of the entity.
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility type which combines base importable object and the entity type interfaces together. It strips away any types
|
||||
* defined for the properties, since imported object can not be trusted and should be type-checked by the validators.
|
||||
*/
|
||||
type ImportableObject<EntityType extends StorageEntity> = { [ObjectKey in keyof BaseImportableObject]: any }
|
||||
& { [SettingKey in keyof EntityType["settings"]]: any };
|
||||
import type { ImportableEntityObject } from "$lib/extension/transporting/importables";
|
||||
|
||||
/**
|
||||
* Function for validating the entities.
|
||||
* @todo Probably would be better to replace the throw-catch method with some kind of result-error returning type.
|
||||
* Errors are only properly definable in the JSDoc.
|
||||
*/
|
||||
type ValidationFunction<EntityType extends StorageEntity> = (importedObject: ImportableObject<EntityType>) => void;
|
||||
type ValidationFunction<EntityType extends StorageEntity> = (importedObject: ImportableEntityObject<EntityType>) => void;
|
||||
|
||||
/**
|
||||
* Mapping of validation functions for all entities present in the extension. Key is a name of entity and value is a
|
||||
@@ -36,39 +16,70 @@ type EntitiesValidationMap = {
|
||||
[EntityKey in keyof App.EntityNamesMap]?: ValidationFunction<App.EntityNamesMap[EntityKey]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the following value is defined, not empty and is of correct type.
|
||||
* @param value Value to be checked.
|
||||
*/
|
||||
function validateRequiredString(value: unknown): boolean {
|
||||
return Boolean(value && typeof value === 'string');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the following value is not set or is a valid array.
|
||||
* @param value Value to be checked.
|
||||
*/
|
||||
function validateOptionalArray(value: unknown): boolean {
|
||||
return typeof value === 'undefined' || value === null || Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of validators for each entity. Function should throw the error if validation failed.
|
||||
*/
|
||||
const entitiesValidators: EntitiesValidationMap = {
|
||||
profiles: importedObject => {
|
||||
if (importedObject.v !== 1) {
|
||||
throw new Error('Unsupported version!');
|
||||
if (!importedObject.v || importedObject.v > 2) {
|
||||
throw new Error('Unsupported profile version!');
|
||||
}
|
||||
|
||||
if (
|
||||
!importedObject.id
|
||||
|| typeof importedObject.id !== "string"
|
||||
|| !importedObject.name
|
||||
|| typeof importedObject.name !== "string"
|
||||
|| !importedObject.tags
|
||||
|| !Array.isArray(importedObject.tags)
|
||||
!validateRequiredString(importedObject?.id)
|
||||
|| !validateRequiredString(importedObject?.name)
|
||||
|| !validateOptionalArray(importedObject?.tags)
|
||||
) {
|
||||
throw new Error('Invalid profile format detected!');
|
||||
}
|
||||
}
|
||||
},
|
||||
groups: importedObject => {
|
||||
if (!importedObject.v || importedObject.v > 2) {
|
||||
throw new Error('Unsupported group version!');
|
||||
}
|
||||
|
||||
if (
|
||||
!validateRequiredString(importedObject?.id)
|
||||
|| !validateRequiredString(importedObject?.name)
|
||||
|| !validateOptionalArray(importedObject?.tags)
|
||||
|| !validateOptionalArray(importedObject?.prefixes)
|
||||
|| !validateOptionalArray(importedObject?.suffixes)
|
||||
) {
|
||||
throw new Error('Invalid group format detected!');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the structure of the entity.
|
||||
* @param importedObject Object imported from JSON.
|
||||
* @param entityName Name of the entity to validate. Should be loaded from the entity class.
|
||||
* @param importedObject Object imported from JSON.
|
||||
* @throws {Error} Error in case validation failed with the reason stored in the message.
|
||||
*/
|
||||
export function validateImportedEntity(importedObject: any, entityName: string) {
|
||||
export function validateImportedEntity<EntityName extends keyof App.EntityNamesMap>(
|
||||
entityName: EntityName,
|
||||
importedObject: any
|
||||
) {
|
||||
if (!entitiesValidators.hasOwnProperty(entityName)) {
|
||||
console.error(`Trying to validate entity without the validator present! Entity name: ${entityName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
entitiesValidators[entityName as keyof EntitiesValidationMap]!.call(null, importedObject);
|
||||
entitiesValidators[entityName]!.call(null, importedObject);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
|
||||
<MenuItem href="/features/groups">Tag Groups</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/transporting">Import/Export</MenuItem>
|
||||
<MenuItem href="/preferences">Preferences</MenuItem>
|
||||
<MenuItem href="/about">About</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
|
||||
let currentSiteUrl = 'https://furbooru.org';
|
||||
|
||||
if (__CURRENT_SITE__ === 'derpibooru') {
|
||||
currentSiteUrl = 'https://derpibooru.org';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
@@ -8,18 +14,26 @@
|
||||
<hr>
|
||||
</Menu>
|
||||
<h1>
|
||||
Furbooru Tagging Assistant
|
||||
{__CURRENT_SITE_NAME__} Tagging Assistant
|
||||
</h1>
|
||||
<p>
|
||||
This is a tool made to help tag images on Furbooru more efficiently. It is currently in development and is not yet
|
||||
ready for use, but it still can provide some useful functionality.
|
||||
This is a small tool to make tagging on {__CURRENT_SITE_NAME__} just a little bit more convenient. Group tags with
|
||||
your own rules; add or remove tags from the images without opening them up; preview images and videos on click and
|
||||
a little bit more. This extension is highly unstable and might break at any point, so be aware.
|
||||
</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem href="https://furbooru.org" icon="globe" target="_blank">
|
||||
Visit Furbooru
|
||||
<MenuItem href={currentSiteUrl} icon="globe" target="_blank">
|
||||
Visit {__CURRENT_SITE_NAME__}
|
||||
</MenuItem>
|
||||
<MenuItem href="https://github.com/koloml/furbooru-tagging-assistant" icon="info-circle" target="_blank">
|
||||
GitHub Repo
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<style>
|
||||
h1, p {
|
||||
margin-top: .5em;
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences/tags">Tagging</MenuItem>
|
||||
<MenuItem href="/preferences/search">Search</MenuItem>
|
||||
<MenuItem href="/preferences/misc">Misc & Tools</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences/debug">Debug</MenuItem>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import { searchPropertiesSuggestionsEnabled, searchPropertiesSuggestionsPosition } from "$stores/preferences/search";
|
||||
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
|
||||
import SelectField from "$components/ui/forms/SelectField.svelte";
|
||||
|
||||
const propertiesPositions = {
|
||||
start: "At the start of the list",
|
||||
end: "At the end of the list",
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/preferences" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl>
|
||||
<CheckboxField bind:checked={$searchPropertiesSuggestionsEnabled}>
|
||||
Auto-complete properties
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
{#if $searchPropertiesSuggestionsEnabled}
|
||||
<FormControl label="Show completed properties:">
|
||||
<SelectField bind:value={$searchPropertiesSuggestionsPosition}
|
||||
options="{propertiesPositions}"></SelectField>
|
||||
</FormControl>
|
||||
{/if}
|
||||
</FormContainer>
|
||||
11
src/routes/transporting/+page.svelte
Normal file
11
src/routes/transporting/+page.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/transporting/export">Export</MenuItem>
|
||||
<MenuItem href="/transporting/import">Import</MenuItem>
|
||||
</Menu>
|
||||
135
src/routes/transporting/export/+page.svelte
Normal file
135
src/routes/transporting/export/+page.svelte
Normal file
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import BulkEntitiesTransporter from "$lib/extension/BulkEntitiesTransporter";
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
|
||||
const bulkTransporter = new BulkEntitiesTransporter();
|
||||
|
||||
let exportAllProfiles = $state(false);
|
||||
let exportAllGroups = $state(false);
|
||||
|
||||
let displayExportedString = $state(false);
|
||||
let shouldUseCompressed = $state(true);
|
||||
let compressedExport = $state('');
|
||||
let plainExport = $state('');
|
||||
let selectedExportString = $derived(shouldUseCompressed ? compressedExport : plainExport);
|
||||
|
||||
const exportedEntities: Record<keyof App.EntityNamesMap, Record<string, boolean>> = $state({
|
||||
profiles: {},
|
||||
groups: {},
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (displayExportedString) {
|
||||
const elementsToExport: StorageEntity[] = [];
|
||||
|
||||
$maintenanceProfiles.forEach(profile => {
|
||||
if (exportedEntities.profiles[profile.id]) {
|
||||
elementsToExport.push(profile);
|
||||
}
|
||||
});
|
||||
|
||||
$tagGroups.forEach(group => {
|
||||
if (exportedEntities.groups[group.id]) {
|
||||
elementsToExport.push(group);
|
||||
}
|
||||
});
|
||||
|
||||
plainExport = bulkTransporter.exportToJSON(elementsToExport);
|
||||
compressedExport = bulkTransporter.exportToCompressedJSON(elementsToExport);
|
||||
}
|
||||
});
|
||||
|
||||
function refreshAreAllEntitiesChecked() {
|
||||
requestAnimationFrame(() => {
|
||||
exportAllProfiles = $maintenanceProfiles.every(profile => exportedEntities.profiles[profile.id]);
|
||||
exportAllGroups = $tagGroups.every(group => exportedEntities.groups[group.id]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a handler to toggle on or off specific entity type.
|
||||
* @param targetEntity Code of the entity.
|
||||
*/
|
||||
function createToggleAllOnUserInput(targetEntity: keyof App.EntityNamesMap) {
|
||||
return () => {
|
||||
requestAnimationFrame(() => {
|
||||
switch (targetEntity) {
|
||||
case "profiles":
|
||||
$maintenanceProfiles.forEach(profile => exportedEntities.profiles[profile.id] = exportAllProfiles);
|
||||
break;
|
||||
case "groups":
|
||||
$tagGroups.forEach(group => exportedEntities.groups[group.id] = exportAllGroups);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Trying to toggle unsupported entity type: ${targetEntity}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExportedStringDisplay() {
|
||||
displayExportedString = !displayExportedString;
|
||||
}
|
||||
|
||||
function toggleExportedStringType() {
|
||||
shouldUseCompressed = !shouldUseCompressed;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !displayExportedString}
|
||||
<Menu>
|
||||
<MenuItem href="/transporting" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
{#if $maintenanceProfiles.length}
|
||||
<MenuCheckboxItem bind:checked={exportAllProfiles} oninput={createToggleAllOnUserInput('profiles')}>
|
||||
Export All Profiles
|
||||
</MenuCheckboxItem>
|
||||
{#each $maintenanceProfiles as profile}
|
||||
<MenuCheckboxItem bind:checked={exportedEntities.profiles[profile.id]} oninput={refreshAreAllEntitiesChecked}>
|
||||
Profile: {profile.settings.name}
|
||||
</MenuCheckboxItem>
|
||||
{/each}
|
||||
<hr>
|
||||
{/if}
|
||||
{#if $tagGroups.length}
|
||||
<MenuCheckboxItem bind:checked={exportAllGroups} oninput={createToggleAllOnUserInput('groups')}>
|
||||
Export All Groups
|
||||
</MenuCheckboxItem>
|
||||
{#each $tagGroups as group}
|
||||
<MenuCheckboxItem bind:checked={exportedEntities.groups[group.id]} oninput={refreshAreAllEntitiesChecked}>
|
||||
Group: {group.settings.name}
|
||||
</MenuCheckboxItem>
|
||||
{/each}
|
||||
<hr>
|
||||
{/if}
|
||||
<MenuItem icon="file-export" onclick={toggleExportedStringDisplay}>Export Selected</MenuItem>
|
||||
</Menu>
|
||||
{:else}
|
||||
<Menu>
|
||||
<MenuItem onclick={toggleExportedStringDisplay} icon="arrow-left">Back to Selection</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Export string">
|
||||
<textarea readonly rows="6">{selectedExportString}</textarea>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem onclick={toggleExportedStringType}>
|
||||
Switch Format:
|
||||
{#if shouldUseCompressed}
|
||||
Base64-Encoded
|
||||
{:else}
|
||||
Raw JSON
|
||||
{/if}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
{/if}
|
||||
275
src/routes/transporting/import/+page.svelte
Normal file
275
src/routes/transporting/import/+page.svelte
Normal file
@@ -0,0 +1,275 @@
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import BulkEntitiesTransporter from "$lib/extension/BulkEntitiesTransporter";
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
import ProfileView from "$components/features/ProfileView.svelte";
|
||||
import GroupView from "$components/features/GroupView.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import type { SameSiteStatus } from "$lib/extension/EntitiesTransporter";
|
||||
|
||||
let importedString = $state('');
|
||||
let errorMessage = $state('');
|
||||
|
||||
let importedProfiles = $state<MaintenanceProfile[]>([]);
|
||||
let importedGroups = $state<TagGroup[]>([]);
|
||||
|
||||
let saveAllProfiles = $state(false);
|
||||
let saveAllGroups = $state(false);
|
||||
|
||||
let selectedEntities: Record<keyof App.EntityNamesMap, Record<string, boolean>> = $state({
|
||||
profiles: {},
|
||||
groups: {},
|
||||
});
|
||||
|
||||
let previewedEntity = $state<StorageEntity | null>(null);
|
||||
|
||||
const existingProfilesMap = $derived(
|
||||
$maintenanceProfiles.reduce((map, profile) => {
|
||||
map.set(profile.id, profile);
|
||||
return map;
|
||||
}, new Map<string, MaintenanceProfile>())
|
||||
);
|
||||
|
||||
const existingGroupsMap = $derived(
|
||||
$tagGroups.reduce((map, group) => {
|
||||
map.set(group.id, group);
|
||||
return map;
|
||||
}, new Map<string, TagGroup>())
|
||||
);
|
||||
|
||||
const hasImportedEntities = $derived(
|
||||
Boolean(importedProfiles.length || importedGroups.length)
|
||||
);
|
||||
|
||||
const transporter = new BulkEntitiesTransporter();
|
||||
|
||||
let lastImportStatus = $state<SameSiteStatus>(null);
|
||||
|
||||
function tryBulkImport() {
|
||||
importedProfiles = [];
|
||||
importedGroups = [];
|
||||
errorMessage = '';
|
||||
|
||||
importedString = importedString.trim();
|
||||
|
||||
if (!importedString) {
|
||||
errorMessage = 'Nothing to import.';
|
||||
return;
|
||||
}
|
||||
|
||||
let importedEntities: StorageEntity[] = [];
|
||||
|
||||
try {
|
||||
if (importedString.startsWith('{')) {
|
||||
importedEntities = transporter.parseAndImportFromJSON(importedString);
|
||||
} else {
|
||||
importedEntities = transporter.parseAndImportFromCompressedJSON(importedString);
|
||||
}
|
||||
} catch (importError) {
|
||||
errorMessage = importError instanceof Error ? importError.message : 'Unknown error!';
|
||||
return;
|
||||
}
|
||||
|
||||
lastImportStatus = transporter.lastImportSameSiteStatus;
|
||||
|
||||
if (importedEntities.length) {
|
||||
for (const targetImportedEntity of importedEntities) {
|
||||
switch (targetImportedEntity.type) {
|
||||
case "profiles":
|
||||
importedProfiles.push(targetImportedEntity as MaintenanceProfile);
|
||||
break;
|
||||
case "groups":
|
||||
importedGroups.push(targetImportedEntity as TagGroup);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unprocessed entity type detected: ${targetImportedEntity.type}`, targetImportedEntity);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errorMessage = "Import string contains nothing!";
|
||||
}
|
||||
}
|
||||
|
||||
function cancelImport() {
|
||||
importedProfiles = [];
|
||||
importedGroups = [];
|
||||
}
|
||||
|
||||
function refreshAreAllEntitiesChecked() {
|
||||
requestAnimationFrame(() => {
|
||||
saveAllProfiles = importedProfiles.every(profile => selectedEntities.profiles[profile.id]);
|
||||
saveAllGroups = importedGroups.every(group => selectedEntities.groups[group.id]);
|
||||
});
|
||||
}
|
||||
|
||||
function createToggleAllOnUserInput(entityType: keyof App.EntityNamesMap) {
|
||||
return () => {
|
||||
requestAnimationFrame(() => {
|
||||
switch (entityType) {
|
||||
case "profiles":
|
||||
importedProfiles.forEach(profile => selectedEntities.profiles[profile.id] = saveAllProfiles);
|
||||
break;
|
||||
case "groups":
|
||||
importedGroups.forEach(group => selectedEntities.groups[group.id] = saveAllGroups);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Trying to toggle unsupported entity type: ${entityType}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createShowPreviewForEntity(entity: StorageEntity) {
|
||||
return (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
previewedEntity = entity;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSelectedEntities() {
|
||||
Promise.allSettled([
|
||||
Promise.allSettled(
|
||||
importedProfiles
|
||||
.filter(profile => selectedEntities.profiles[profile.id])
|
||||
.map(profile => profile.save())
|
||||
),
|
||||
Promise.allSettled(
|
||||
importedGroups
|
||||
.filter(group => selectedEntities.groups[group.id])
|
||||
.map(group => group.save())
|
||||
),
|
||||
]).then(() => {
|
||||
goto("/transporting");
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !hasImportedEntities}
|
||||
<Menu>
|
||||
<MenuItem href="/transporting" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if errorMessage}
|
||||
<p class="error">{errorMessage}</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
</Menu>
|
||||
{/if}
|
||||
<FormContainer>
|
||||
<FormControl label="Import string">
|
||||
<textarea bind:value={importedString} rows="6"></textarea>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem onclick={tryBulkImport}>Import & Preview</MenuItem>
|
||||
</Menu>
|
||||
{:else if previewedEntity}
|
||||
<Menu>
|
||||
<MenuItem onclick={() => previewedEntity = null} icon="arrow-left">Back to Selection</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if previewedEntity instanceof MaintenanceProfile}
|
||||
<ProfileView profile={previewedEntity}></ProfileView>
|
||||
{:else if previewedEntity instanceof TagGroup}
|
||||
<GroupView group={previewedEntity}></GroupView>
|
||||
{/if}
|
||||
{:else}
|
||||
<Menu>
|
||||
<MenuItem onclick={cancelImport} icon="arrow-left">Cancel Import</MenuItem>
|
||||
{#if lastImportStatus !== 'same'}
|
||||
<hr>
|
||||
{/if}
|
||||
</Menu>
|
||||
{#if lastImportStatus === "different"}
|
||||
<p class="warning">
|
||||
<b>Warning!</b>
|
||||
Looks like these entities were exported for the different extension! There are many differences between tagging
|
||||
systems of Furobooru and Derpibooru, so make sure to check if these settings are correct before using them!
|
||||
</p>
|
||||
{/if}
|
||||
{#if lastImportStatus === 'unknown'}
|
||||
<p class="warning">
|
||||
<b>Warning!</b>
|
||||
We couldn't verify if these settings are meant for this site or not. There are many differences between tagging
|
||||
systems of Furbooru and Derpibooru, so make sure to check if these settings are correct before using them.
|
||||
</p>
|
||||
{/if}
|
||||
<Menu>
|
||||
{#if importedProfiles.length}
|
||||
<hr>
|
||||
<MenuCheckboxItem bind:checked={saveAllProfiles} oninput={createToggleAllOnUserInput('profiles')}>
|
||||
Import All Profiles
|
||||
</MenuCheckboxItem>
|
||||
{#each importedProfiles as candidateProfile}
|
||||
<MenuCheckboxItem
|
||||
bind:checked={selectedEntities.profiles[candidateProfile.id]}
|
||||
oninput={refreshAreAllEntitiesChecked}
|
||||
onitemclick={createShowPreviewForEntity(candidateProfile)}
|
||||
>
|
||||
{#if existingProfilesMap.has(candidateProfile.id)}
|
||||
Update:
|
||||
{:else}
|
||||
New:
|
||||
{/if}
|
||||
{candidateProfile.settings.name || 'Unnamed Profile'}
|
||||
</MenuCheckboxItem>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if importedGroups.length}
|
||||
<hr>
|
||||
<MenuCheckboxItem
|
||||
bind:checked={saveAllGroups}
|
||||
oninput={createToggleAllOnUserInput('groups')}
|
||||
>
|
||||
Import All Groups
|
||||
</MenuCheckboxItem>
|
||||
{#each importedGroups as candidateGroup}
|
||||
<MenuCheckboxItem
|
||||
bind:checked={selectedEntities.groups[candidateGroup.id]}
|
||||
oninput={refreshAreAllEntitiesChecked}
|
||||
onitemclick={createShowPreviewForEntity(candidateGroup)}
|
||||
>
|
||||
{#if existingGroupsMap.has(candidateGroup.id)}
|
||||
Update:
|
||||
{:else}
|
||||
New:
|
||||
{/if}
|
||||
{candidateGroup.settings.name || 'Unnamed Group'}
|
||||
</MenuCheckboxItem>
|
||||
{/each}
|
||||
{/if}
|
||||
<hr>
|
||||
<MenuItem onclick={saveSelectedEntities}>
|
||||
Imported Selected
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
|
||||
.error, .warning {
|
||||
padding: 5px 24px;
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: colors.$warning-background;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: colors.$error-background;
|
||||
}
|
||||
</style>
|
||||
@@ -1,28 +0,0 @@
|
||||
import { type Writable, writable } from "svelte/store";
|
||||
import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings";
|
||||
|
||||
export const searchPropertiesSuggestionsEnabled = writable(false);
|
||||
|
||||
export const searchPropertiesSuggestionsPosition: Writable<SuggestionsPosition> = writable('start');
|
||||
|
||||
const searchSettings = new SearchSettings();
|
||||
|
||||
Promise.allSettled([
|
||||
// First we wait for all properties to load and save
|
||||
searchSettings.resolvePropertiesSuggestionsEnabled().then(v => searchPropertiesSuggestionsEnabled.set(v)),
|
||||
searchSettings.resolvePropertiesSuggestionsPosition().then(v => searchPropertiesSuggestionsPosition.set(v))
|
||||
]).then(() => {
|
||||
// And then we can start reading value changes from the writable objects
|
||||
searchPropertiesSuggestionsEnabled.subscribe(value => {
|
||||
void searchSettings.setPropertiesSuggestions(value);
|
||||
});
|
||||
|
||||
searchPropertiesSuggestionsPosition.subscribe(value => {
|
||||
void searchSettings.setPropertiesSuggestionsPosition(value);
|
||||
});
|
||||
|
||||
searchSettings.subscribe(settings => {
|
||||
searchPropertiesSuggestionsEnabled.set(Boolean(settings.suggestProperties));
|
||||
searchPropertiesSuggestionsPosition.set(settings.suggestPropertiesPosition || 'start');
|
||||
});
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
@use 'sass:color';
|
||||
@use 'environment';
|
||||
|
||||
$background: #15121a;
|
||||
|
||||
@@ -26,27 +27,38 @@ $media-box-border: #311e49;
|
||||
$tag-background: #1b3c21;
|
||||
$tag-count-background: #2d6236;
|
||||
$tag-text: #4aa158;
|
||||
$tag-border: #2d6236;
|
||||
|
||||
$tag-rating-text: #418dd9;
|
||||
$tag-rating-background: color.adjust($tag-rating-text, $lightness: -35%);
|
||||
$tag-rating-border: color.adjust($tag-rating-text, $saturation: -10%, $lightness: -20%);
|
||||
$tag-spoiler-text: #d49b39;
|
||||
$tag-spoiler-background: color.adjust($tag-spoiler-text, $lightness: -34%);
|
||||
$tag-spoiler-border: color.adjust($tag-spoiler-text, $lightness: -23%);
|
||||
$tag-origin-text: #6f66d6;
|
||||
$tag-origin-background: color.adjust($tag-origin-text, $lightness: -40%);
|
||||
$tag-origin-border: color.adjust($tag-origin-text, $saturation: -28%, $lightness: -22%);
|
||||
$tag-oc-text: #b157b7;
|
||||
$tag-oc-background: color.adjust($tag-oc-text, $lightness: -33%);
|
||||
$tag-oc-border: color.adjust($tag-oc-text, $lightness: -15%);
|
||||
$tag-error-text: #d45460;
|
||||
$tag-error-background: color.adjust($tag-error-text, $lightness: -38%, $saturation: -6%, $space: hsl);
|
||||
$tag-error-border: color.adjust($tag-error-text, $lightness: -22%, $space: hsl);
|
||||
$tag-character-text: #4aaabf;
|
||||
$tag-character-background: color.adjust($tag-character-text, $lightness: -33%);
|
||||
$tag-character-border: color.adjust($tag-character-text, $lightness: -20%);
|
||||
$tag-content-official-text: #b9b541;
|
||||
$tag-content-official-background: color.adjust($tag-content-official-text, $lightness: -29%, $saturation: -2%, $space: hsl);
|
||||
$tag-content-official-border: color.adjust($tag-content-official-text, $lightness: -20%, $space: hsl);
|
||||
$tag-content-fanmade-text: #cc8eb5;
|
||||
$tag-content-fanmade-background: color.adjust($tag-content-fanmade-text, $lightness: -40%);
|
||||
$tag-content-fanmade-border: color.adjust($tag-content-fanmade-text, $saturation: -10%, $lightness: -20%);
|
||||
$tag-species-text: #b16b50;
|
||||
$tag-species-background: color.adjust($tag-species-text, $lightness: -35%);
|
||||
$tag-species-border: color.adjust($tag-species-text, $saturation: -10%, $lightness: -20%);
|
||||
$tag-body-type-text: #b8b8b8;
|
||||
$tag-body-type-background: color.adjust($tag-body-type-text, $lightness: -35%, $saturation: -10%, $space: hsl);
|
||||
$tag-body-type-background: color.adjust($tag-body-type-text, $lightness: -50%, $space: hsl);
|
||||
$tag-body-type-border: color.adjust($tag-body-type-text, $lightness: -37%, $saturation: -10%, $space: hsl);
|
||||
|
||||
$input-background: #26232d;
|
||||
$input-border: #5c5a61;
|
||||
@@ -55,3 +67,31 @@ $error-background: #7a2725;
|
||||
|
||||
$warning-background: #7d4825;
|
||||
$warning-border: #95562c;
|
||||
|
||||
@if environment.$current-site == 'derpibooru' {
|
||||
$background: #141a24;
|
||||
|
||||
$text: #e0e0e0;
|
||||
$text-gray: #90a1bb;
|
||||
|
||||
$link: #478acc;
|
||||
$link-hover: #b099dd;
|
||||
|
||||
$header: #284371;
|
||||
$header-toolbar: #1c3252;
|
||||
$header-hover-background: #1d3153;
|
||||
$header-mobile-link-hover: #546c99;
|
||||
|
||||
$footer: #1d242f;
|
||||
$footer-text: $text-gray;
|
||||
|
||||
$block-header: #252d3c;
|
||||
$block-border: #2d3649;
|
||||
$block-background: #1d242f;
|
||||
$block-background-alternate: #171d26;
|
||||
|
||||
$media-box-border: #3d4657;
|
||||
|
||||
$input-background: #282e39;
|
||||
$input-border: #575e6b;
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
.autocomplete {
|
||||
&__item {
|
||||
&--property {
|
||||
i {
|
||||
margin-right: .5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@use '$styles/colors';
|
||||
@use '$styles/booru-vars';
|
||||
@use '$styles/environment';
|
||||
|
||||
// This will fix wierd misplacing of the modified media boxes in the listing.
|
||||
.js-resizable-media-container {
|
||||
@@ -66,9 +67,17 @@
|
||||
|
||||
.tag {
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
user-select: none;
|
||||
|
||||
// Derpibooru has slight differences in how tags are displayed.
|
||||
@if environment.$current-site == 'derpibooru' {
|
||||
padding: 0 5px;
|
||||
gap: 0;
|
||||
}
|
||||
@else {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: booru-vars.$resolved-tag-color;
|
||||
color: booru-vars.$resolved-tag-background;
|
||||
|
||||
20
src/styles/environment.scss
Normal file
20
src/styles/environment.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
@use 'sass:meta';
|
||||
@use 'sass:string';
|
||||
|
||||
@function get-defined-constant($constant-name, $default-value: '') {
|
||||
$resolved-value: $default-value;
|
||||
|
||||
@if meta.function-exists('vite-read-env-variable') {
|
||||
$candidate-value: meta.call(meta.get-function('vite-read-env-variable'), $constant-name);
|
||||
|
||||
@if string.length($candidate-value) != 0 {
|
||||
$resolved-value: $candidate-value
|
||||
}
|
||||
}
|
||||
|
||||
@return $resolved-value;
|
||||
}
|
||||
|
||||
$current-site: get-defined-constant('__CURRENT_SITE__', 'furbooru');
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@use '../colors';
|
||||
@use '../environment';
|
||||
|
||||
.tag {
|
||||
background: colors.$tag-background;
|
||||
@@ -9,9 +10,13 @@
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
|
||||
@if environment.$current-site == 'derpibooru' {
|
||||
border: 1px solid colors.$tag-border;
|
||||
}
|
||||
|
||||
.remove {
|
||||
content: "x";
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
src/types/amd-lite.d.ts
vendored
Normal file
23
src/types/amd-lite.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
// Types for the small untyped AMD loader. These types do not cover all the functions available in the package, only
|
||||
// parts required for content scripts in extension to work.
|
||||
declare module 'amd-lite' {
|
||||
interface AMDLiteInitOptions {
|
||||
publicScope: any;
|
||||
verbosity: number;
|
||||
}
|
||||
|
||||
interface AMDLite {
|
||||
waitingModules: Record<string, any>;
|
||||
readyModules: Record<string, any>;
|
||||
|
||||
init(options: Partial<AMDLiteInitOptions>): void;
|
||||
|
||||
define(name: string, dependencies: string[], callback: function): void;
|
||||
|
||||
resolveDependency(dependencyPath: string);
|
||||
|
||||
resolveDependencies(dependencyNames: string[], from?: string);
|
||||
}
|
||||
|
||||
export const amdLite: AMDLite;
|
||||
}
|
||||
@@ -1,21 +1,38 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { ScssViteReadEnvVariableFunctionPlugin } from "./.vite/plugins/scss-read-env-variable-function";
|
||||
import { SwapDefinedVariablesPlugin } from "./.vite/plugins/swap-defined-variables";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
// SVGs imported from the FA6 don't need to be inlined!
|
||||
assetsInlineLimit: 0
|
||||
},
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
exclude: ['**/node_modules/**', '.*\\.d\\.ts$', '.*\\.spec\\.ts$'],
|
||||
coverage: {
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/lib/**/*.{js,ts}'],
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
build: {
|
||||
// SVGs imported from the FA6 don't need to be inlined!
|
||||
assetsInlineLimit: 0
|
||||
},
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
ScssViteReadEnvVariableFunctionPlugin(),
|
||||
SwapDefinedVariablesPlugin({
|
||||
envVariable: 'SITE',
|
||||
expectedValue: 'derpibooru',
|
||||
define: {
|
||||
__CURRENT_SITE__: JSON.stringify('derpibooru'),
|
||||
__CURRENT_SITE_NAME__: JSON.stringify('Derpibooru'),
|
||||
}
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
exclude: ['**/node_modules/**', '.*\\.d\\.ts$', '.*\\.spec\\.ts$'],
|
||||
coverage: {
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/lib/**/*.{js,ts}'],
|
||||
}
|
||||
},
|
||||
define: {
|
||||
__CURRENT_SITE__: JSON.stringify('furbooru'),
|
||||
__CURRENT_SITE_NAME__: JSON.stringify('Furbooru'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user