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

2 Commits

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

View File

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

View File

@@ -17,38 +17,6 @@ class ManifestProcessor {
this.#manifestObject = parsedManifest;
}
/**
* Collect all the content scripts & stylesheets for single build action.
*
* @returns {Set<string>}
*/
collectContentScripts() {
const contentScripts = this.#manifestObject.content_scripts;
if (!contentScripts) {
console.info('No content scripts to collect.');
return new Set();
}
const entryPoints = new Set();
for (let entry of contentScripts) {
if (entry.js) {
for (let jsPath of entry.js) {
entryPoints.add(jsPath);
}
}
if (entry.css) {
for (let cssPath of entry.css) {
entryPoints.add(cssPath);
}
}
}
return entryPoints;
}
/**
* Map over every content script defined in the manifest. If no content scripts defined, no calls will be made to the
* callback.
@@ -86,53 +54,6 @@ class ManifestProcessor {
}
}
/**
* Find all patterns in content scripts and host permissions and replace the hostname to the different one.
*
* @param {string|string[]} singleOrMultipleHostnames One or multiple hostnames to replace the original hostname with.
*/
replaceHostTo(singleOrMultipleHostnames) {
if (typeof singleOrMultipleHostnames === 'string') {
singleOrMultipleHostnames = [singleOrMultipleHostnames];
}
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.
*
@@ -165,27 +86,13 @@ export function loadManifest(filePath) {
/**
* @typedef {Object} Manifest
* @property {string} name
* @property {string} description
* @property {string} version
* @property {BrowserSpecificSettings} browser_specific_settings
* @property {string[]} host_permissions
* @property {ContentScriptsEntry[]|undefined} content_scripts
*/
/**
* @typedef {Object} BrowserSpecificSettings
* @property {GeckoSettings} gecko
*/
/**
* @typedef {Object} GeckoSettings
* @property {string} id
*/
/**
* @typedef {Object} ContentScriptsEntry
* @property {string[]} matches
* @property {string[]} mathces
* @property {string[]|undefined} js
* @property {string[]|undefined} css
*/

View File

@@ -1,8 +1,8 @@
import { loadManifest } from "./lib/manifest.js";
import {loadManifest} from "./lib/manifest.js";
import path from "path";
import { buildScriptsAndStyles } from "./lib/content-scripts.js";
import { extractInlineScriptsFromIndex } from "./lib/index-file.js";
import { normalizePath } from "vite";
import {buildScript, buildStyle} from "./lib/content-scripts.js";
import {normalizePath} from "vite";
import {extractInlineScriptsFromIndex} from "./lib/index-file.js";
/**
* Build addition assets required for the extension and pack it into the directory.
@@ -11,70 +11,45 @@ import { normalizePath } from "vite";
export async function packExtension(settings) {
const manifest = loadManifest(path.resolve(settings.rootDir, 'manifest.json'));
const replacementMapping = await buildScriptsAndStyles({
inputs: manifest.collectContentScripts(),
outputDir: settings.contentScriptsDir,
rootDir: settings.rootDir,
});
await manifest.mapContentScripts(async entry => {
// Since we CAN'T really build all scripts and stylesheets in a single build entry, we will run build for every single
// one of them in a row. This way, no chunks will be generated. Thanks, ManifestV3!
await manifest.mapContentScripts(async (entry) => {
if (entry.js) {
entry.js = entry.js
.map(jsSourcePath => {
if (!replacementMapping.has(jsSourcePath)) {
return [];
}
return replacementMapping.get(jsSourcePath);
})
.flat(1)
.map(pathName => {
return normalizePath(
path.relative(
settings.exportDir,
path.join(
settings.contentScriptsDir,
pathName
)
)
)
for (let scriptIndex = 0; scriptIndex < entry.js.length; scriptIndex++) {
const builtScriptFilePath = await buildScript({
input: path.resolve(settings.rootDir, entry.js[scriptIndex]),
outputDir: settings.contentScriptsDir,
rootDir: settings.rootDir,
});
entry.js[scriptIndex] = normalizePath(
path.relative(
settings.exportDir,
builtScriptFilePath
)
);
}
}
if (entry.css) {
entry.css = entry.css
.map(jsSourcePath => {
if (!replacementMapping.has(jsSourcePath)) {
return [];
}
for (let styleIndex = 0; styleIndex < entry.css.length; styleIndex++) {
const builtStylesheetFilePath = await buildStyle({
input: path.resolve(settings.rootDir, entry.css[styleIndex]),
outputDir: settings.contentScriptsDir,
rootDir: settings.rootDir
});
return replacementMapping.get(jsSourcePath);
})
.flat(1)
.map(pathName => {
return normalizePath(
path.relative(
settings.exportDir,
path.join(
settings.contentScriptsDir,
pathName
)
)
entry.css[styleIndex] = normalizePath(
path.relative(
settings.exportDir,
builtStylesheetFilePath
)
})
);
}
}
return entry;
})
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'));

View File

@@ -1,46 +0,0 @@
import { SassString, Value } from "sass";
/**
* @return {import('vite').Plugin}
*/
export function ScssViteReadEnvVariableFunctionPlugin() {
return {
name: 'koloml:scss-read-env-variable-function',
apply: 'build',
enforce: 'post',
configResolved: config => {
config.css.preprocessorOptions ??= {};
config.css.preprocessorOptions.scss ??= {};
config.css.preprocessorOptions.scss.functions ??= {};
/**
* @param {Value[]} args
* @return {SassString}
*/
config.css.preprocessorOptions.scss.functions['vite-read-env-variable($constant-name)'] = (args) => {
const constName = args[0].assertString('constant-name').text;
if (config.define && config.define.hasOwnProperty(constName)) {
let returnedValue = config.define[constName];
try {
returnedValue = JSON.parse(returnedValue);
} catch {
returnedValue = null;
}
if (typeof returnedValue !== 'string') {
console.warn(`Attempting to read the constant with non-string type: ${constName}`);
return new SassString('');
}
return new SassString(returnedValue);
}
console.warn(`Constant does not exist: ${constName}`);
return new SassString('');
}
}
}
}

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "Furbooru Tagging Assistant",
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
"version": "0.5.1",
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
"version": "0.4.5",
"browser_specific_settings": {
"gecko": {
"id": "furbooru-tagging-assistant@thecore.city"
@@ -18,14 +18,6 @@
"*://*.furbooru.org/"
],
"content_scripts": [
{
"matches": [
"*://*.furbooru.org/*"
],
"js": [
"src/content/deps/amd.ts"
]
},
{
"matches": [
"*://*.furbooru.org/",
@@ -41,15 +33,23 @@
"src/styles/content/listing.scss"
]
},
{
"matches": [
"*://*.furbooru.org/*"
],
"js": [
"src/content/header.ts"
],
"css": [
"src/styles/content/header.scss"
]
},
{
"matches": [
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/tags-editor.ts"
],
"css": [
"src/styles/content/tags-editor.scss"
]
},
{

37
package-lock.json generated
View File

@@ -1,15 +1,14 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.5.1",
"version": "0.4.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "furbooru-tagging-assistant",
"version": "0.5.0",
"version": "0.4.5",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"amd-lite": "^1.0.1",
"lz-string": "^1.5.0"
},
"devDependencies": {
@@ -20,7 +19,6 @@
"@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",
@@ -232,13 +230,6 @@
"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",
@@ -1419,12 +1410,6 @@
"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",
@@ -1654,24 +1639,6 @@
"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",

View File

@@ -1,10 +1,9 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.5.1",
"version": "0.4.5",
"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",
@@ -20,7 +19,6 @@
"@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",
@@ -32,7 +30,6 @@
"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
View File

@@ -4,16 +4,6 @@ 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>;

View File

@@ -1,5 +1,5 @@
<header>
<a href="/">{__CURRENT_SITE_NAME__} Tagging Assistant</a>
<a href="/">Furbooru Tagging Assistant</a>
</header>
<style lang="scss">

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
export const tagsBlacklist: string[] = (__CURRENT_SITE__ === 'furbooru' ? [
export const tagsBlacklist: string[] = [
"anthro art",
"anthro artist",
"anthro cute",
@@ -63,21 +63,4 @@ export const tagsBlacklist: string[] = (__CURRENT_SITE__ === 'furbooru' ? [
"tagme",
"upvotes galore",
"wall of faves"
] : [
"tagme",
"tag me",
"not tagged",
"no tag",
"notag",
"notags",
"upvotes galore",
"downvotes galore",
"wall of faves",
"drama in the comments",
"drama in comments",
"tag needed",
"paywall",
"cringeworthy",
"solo oc",
"tag your shit"
]);
];

View File

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

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

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

View File

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

View File

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

View File

@@ -148,13 +148,7 @@ export class TagDropdownWrapper extends BaseComponent {
profileSpecificButtonText = `Remove from profile "${profileName}"`;
}
// Derpibooru has icons in dropdown. Make sure to only edit text and keep the icon untouched. Also, add the space
// before the text to make space between text and icon.
if (__CURRENT_SITE__ === 'derpibooru' && this.#toggleOnExistingButton.lastChild instanceof Text) {
this.#toggleOnExistingButton.lastChild.textContent = ` ${profileSpecificButtonText}`;
} else {
this.#toggleOnExistingButton.textContent = profileSpecificButtonText;
}
this.#toggleOnExistingButton.innerText = profileSpecificButtonText;
if (!this.#toggleOnExistingButton.isConnected) {
this.#dropdownContainer?.append(this.#toggleOnExistingButton);
@@ -249,19 +243,9 @@ export class TagDropdownWrapper extends BaseComponent {
static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement {
const dropdownLink = document.createElement('a');
dropdownLink.href = '#';
dropdownLink.innerText = text;
dropdownLink.className = 'tag__dropdown__link';
// Derpibooru has an icon in dropdown item. Create the icon and place the text with additional space in front of it.
if (__CURRENT_SITE__ === 'derpibooru') {
const dropdownLinkIcon = document.createElement('i');
dropdownLinkIcon.classList.add('fa', 'fa-tags');
dropdownLink.textContent = ` ${text}`;
dropdownLink.insertAdjacentElement('afterbegin', dropdownLinkIcon);
} else {
dropdownLink.textContent = text;
}
dropdownLink.addEventListener('click', event => {
event.preventDefault();
onClickHandler(event);

View File

@@ -134,7 +134,6 @@ export class TagsListBlock extends BaseComponent {
heading.style.display = 'none';
heading.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(group.id)}, 0)`;
heading.style.flexBasis = '100%';
heading.classList.add('tag-category-headline');
// We're inserting heading to the top just to make sure that heading is always in front of the tags related to
// this category.

View File

@@ -1,120 +0,0 @@
import type StorageEntity from "$lib/extension/base/StorageEntity";
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
import type { ImportableElementsList, ImportableEntityObject } from "$lib/extension/transporting/importables";
import EntitiesTransporter, { type SameSiteStatus } from "$lib/extension/EntitiesTransporter";
import 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";
}
}

View File

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

View File

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

View File

@@ -30,5 +30,5 @@ export default class MaintenanceProfile extends StorageEntity<MaintenanceProfile
return super.save();
}
public static readonly _entityName: keyof App.EntityNamesMap = "profiles";
public static readonly _entityName = "profiles";
}

View File

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

View File

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

View File

@@ -1,30 +1,21 @@
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]: ExporterFunction<App.EntityNamesMap[EntityName]>;
}
[EntityName in keyof App.EntityNamesMap]: (entity: App.EntityNamesMap[EntityName]) => Record<string, any>
};
const entitiesExporters: ExportersMap = {
profiles: entity => {
return {
$type: "profiles",
$site: __CURRENT_SITE__,
v: 2,
v: 1,
id: entity.id,
name: entity.settings.name,
tags: entity.settings.tags,
// Any exported profile should be considered non-temporary.
temporary: false,
}
},
groups: entity => {
return {
$type: "groups",
$site: __CURRENT_SITE__,
v: 2,
v: 1,
id: entity.id,
name: entity.settings.name,
tags: entity.settings.tags,
@@ -36,13 +27,10 @@ const entitiesExporters: ExportersMap = {
}
};
export function exportEntityToObject<EntityName extends keyof App.EntityNamesMap>(
entityName: EntityName,
entityInstance: App.EntityNamesMap[EntityName]
): ImportableEntityObject<App.EntityNamesMap[EntityName]> {
export function exportEntityToObject(entityInstance: StorageEntity<any>, entityName: string): Record<string, any> {
if (!(entityName in entitiesExporters) || !entitiesExporters.hasOwnProperty(entityName)) {
throw new Error(`Missing exporter for entity: ${entityName}`);
}
return entitiesExporters[entityName].call(null, entityInstance);
return entitiesExporters[entityName as keyof App.EntityNamesMap].call(null, entityInstance);
}

View File

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

View File

@@ -1,12 +1,32 @@
import type StorageEntity from "$lib/extension/base/StorageEntity";
import type { ImportableEntityObject } from "$lib/extension/transporting/importables";
/**
* 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 };
/**
* 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: ImportableEntityObject<EntityType>) => void;
type ValidationFunction<EntityType extends StorageEntity> = (importedObject: ImportableObject<EntityType>) => void;
/**
* Mapping of validation functions for all entities present in the extension. Key is a name of entity and value is a
@@ -16,70 +36,39 @@ 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 || importedObject.v > 2) {
throw new Error('Unsupported profile version!');
if (importedObject.v !== 1) {
throw new Error('Unsupported version!');
}
if (
!validateRequiredString(importedObject?.id)
|| !validateRequiredString(importedObject?.name)
|| !validateOptionalArray(importedObject?.tags)
!importedObject.id
|| typeof importedObject.id !== "string"
|| !importedObject.name
|| typeof importedObject.name !== "string"
|| !importedObject.tags
|| !Array.isArray(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 entityName Name of the entity to validate. Should be loaded from the entity class.
* @param importedObject Object imported from JSON.
* @param entityName Name of the entity to validate. Should be loaded from the entity class.
* @throws {Error} Error in case validation failed with the reason stored in the message.
*/
export function validateImportedEntity<EntityName extends keyof App.EntityNamesMap>(
entityName: EntityName,
importedObject: any
) {
export function validateImportedEntity(importedObject: any, entityName: string) {
if (!entitiesValidators.hasOwnProperty(entityName)) {
console.error(`Trying to validate entity without the validator present! Entity name: ${entityName}`);
return;
}
entitiesValidators[entityName]!.call(null, importedObject);
entitiesValidators[entityName as keyof EntitiesValidationMap]!.call(null, importedObject);
}

View File

@@ -24,7 +24,6 @@
<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>

View File

@@ -1,12 +1,6 @@
<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>
@@ -14,26 +8,18 @@
<hr>
</Menu>
<h1>
{__CURRENT_SITE_NAME__} Tagging Assistant
Furbooru Tagging Assistant
</h1>
<p>
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.
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.
</p>
<Menu>
<hr>
<MenuItem href={currentSiteUrl} icon="globe" target="_blank">
Visit {__CURRENT_SITE_NAME__}
<MenuItem href="https://furbooru.org" icon="globe" target="_blank">
Visit Furbooru
</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>

View File

@@ -7,6 +7,7 @@
<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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
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');
});
})

View File

@@ -1,9 +1,6 @@
$background-color: var(--background-color);
$media-border: var(--media-border);
$media-box-color: var(--media-box-color);
$padding-small: var(--padding-small);
$padding-normal: var(--padding-normal);
$padding-large: var(--padding-large);
// These variables are defined dynamically based on the category of the tag
$resolved-tag-background: var(--tag-background);

View File

@@ -1,5 +1,4 @@
@use 'sass:color';
@use 'environment';
$background: #15121a;
@@ -27,38 +26,27 @@ $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: -50%, $space: hsl);
$tag-body-type-border: color.adjust($tag-body-type-text, $lightness: -37%, $saturation: -10%, $space: hsl);
$tag-body-type-background: color.adjust($tag-body-type-text, $lightness: -35%, $saturation: -10%, $space: hsl);
$input-background: #26232d;
$input-border: #5c5a61;
@@ -67,31 +55,3 @@ $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;
}

View File

@@ -0,0 +1,9 @@
.autocomplete {
&__item {
&--property {
i {
margin-right: .5em;
}
}
}
}

View File

@@ -1,6 +1,5 @@
@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 {
@@ -67,17 +66,9 @@
.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;

View File

@@ -1,23 +0,0 @@
@use '$styles/booru-vars';
@use '$styles/environment';
h2.tag-category-headline {
// Basic margin top and bottom values gathered from Chrome.
$base-margin-top: .83em;
$base-margin-bottom: .62em;
// Tag List element was updated to use flex & gaps. This should be applied to Furbooru later, once updates will be
// applied from the base Philomena version.
@if environment.$current-site == 'derpibooru' {
margin: {
top: calc(#{$base-margin-top} - #{booru-vars.$padding-small});
bottom: calc(#{$base-margin-bottom} - #{booru-vars.$padding-small});
}
}
@else {
margin: {
top: $base-margin-top;
bottom: $base-margin-bottom;
}
}
}

View File

@@ -1,20 +0,0 @@
@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');

View File

@@ -1,25 +1,17 @@
@use '../colors';
@use '../environment';
.tag {
background: colors.$tag-background;
line-height: 28px;
color: colors.$tag-text;
font-weight: 700;
font-size: 14px;
padding: 0 4px;
display: flex;
@if environment.$current-site == 'derpibooru' {
border: 1px solid colors.$tag-border;
line-height: 24px;
}
@else {
line-height: 28px;
}
.remove {
content: "x";
margin-left: 6px;
cursor: pointer;
}
}
}

View File

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

View File

@@ -0,0 +1,33 @@
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
describe('buildTagsAndAliasesMap', () => {
const exampleTag = 'safe';
const exampleTagAlias = 'rating:safe';
const tagsAndAliases = [exampleTag, exampleTagAlias, 'anthro', 'cat', 'feline', 'mammal', 'male', 'boy'];
const tagsOnly = [exampleTag, 'anthro', 'cat', 'feline', 'mammal', 'male'];
const mapping = buildTagsAndAliasesMap(tagsAndAliases, tagsOnly);
it('should return a map of tags', () => {
expect(mapping).toBeInstanceOf(Map);
});
it('should point aliases to their original tags', () => {
expect(mapping.get(exampleTagAlias)).toBe(exampleTag);
});
it('should point tags to themselves', () => {
expect(mapping.get(exampleTag)).toBe(exampleTag);
});
it('should ignore broken tag aliases and show a warning', () => {
vi.spyOn(console, 'warn');
const brokenMapping = buildTagsAndAliasesMap(
['broken alias', 'tag1', 'tag2'],
['tag1', 'tag2'],
);
expect(console.warn).toBeCalledTimes(1);
expect(brokenMapping.has('broken alias')).toBe(false);
});
});

43
tests/lib/utils.spec.ts Normal file
View File

@@ -0,0 +1,43 @@
import { randomString } from "$tests/utils";
import { escapeRegExp, findDeepObject } from "$lib/utils";
import { randomInt } from "node:crypto";
describe('findDeepObject', () => {
const targetObject = {
somewhere: {
deep: {
stringValue: randomString(),
numericValue: randomInt(0, 1000),
}
}
};
it('should just return null when nothing is found', () => {
const nonExistentValue = findDeepObject(targetObject, ['completely', 'wrong']);
expect(nonExistentValue).toBe(null);
});
it('should retrieve something stored deep inside object', () => {
const returnedDeepObject = findDeepObject(targetObject, ['somewhere', 'deep']);
expect(returnedDeepObject).toBe(targetObject.somewhere.deep);
});
it('should return null if value located on given path is not an object', () => {
const returnedForStringValue = findDeepObject(targetObject, ['somewhere', 'deep', 'stringValue']);
expect(returnedForStringValue).not.toBe(targetObject.somewhere.deep.stringValue);
expect(returnedForStringValue).toBe(null);
const returnedForNumericValue = findDeepObject(targetObject, ['somewhere', 'deep', 'numericValue']);
expect(returnedForNumericValue).not.toBe(targetObject.somewhere.deep.numericValue);
expect(returnedForNumericValue).toBe(null);
});
});
describe('escapeRegExp', () => {
const specialCharactersToMatch = "$[(?:)]{}()*./\\+?|^";
it('should sufficiently enough escape special characters', () => {
const generatedRegExp = new RegExp(`^${escapeRegExp(specialCharactersToMatch)}$`, 'm');
expect(generatedRegExp.test(specialCharactersToMatch)).toBe(true);
});
});

View File

@@ -1,38 +1,21 @@
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(() => {
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'),
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}'],
}
};
}
});