diff --git a/.github/assets/fullscreen-viewer-icon.png b/.github/assets/fullscreen-viewer-icon.png new file mode 100644 index 0000000..1db420b Binary files /dev/null and b/.github/assets/fullscreen-viewer-icon.png differ diff --git a/.github/assets/fullscreen-viewer-showcase.png b/.github/assets/fullscreen-viewer-showcase.png new file mode 100644 index 0000000..5bb8bd6 Binary files /dev/null and b/.github/assets/fullscreen-viewer-showcase.png differ diff --git a/.github/assets/groups-showcase.png b/.github/assets/groups-showcase.png new file mode 100644 index 0000000..de43e21 Binary files /dev/null and b/.github/assets/groups-showcase.png differ diff --git a/.github/assets/profiles-showcase.png b/.github/assets/profiles-showcase.png new file mode 100644 index 0000000..c4d7660 Binary files /dev/null and b/.github/assets/profiles-showcase.png differ diff --git a/.vite/lib/content-scripts.js b/.vite/lib/content-scripts.js index 34f8653..a59da49 100644 --- a/.vite/lib/content-scripts.js +++ b/.vite/lib/content-scripts.js @@ -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} Result file path. + * @param {import('rollup').OutputChunk} chunk + * @param {import('rollup').OutputBundle} bundle + * @param {Set} 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>} + */ +export async function buildScriptsAndStyles(buildOptions) { + /** @type {Map} */ + const pathsReplacement = new Map(); + /** @type {Map} */ + 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} 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} 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. + */ diff --git a/.vite/lib/manifest.js b/.vite/lib/manifest.js index 817b5e7..54aa712 100644 --- a/.vite/lib/manifest.js +++ b/.vite/lib/manifest.js @@ -17,6 +17,38 @@ class ManifestProcessor { this.#manifestObject = parsedManifest; } + /** + * Collect all the content scripts & stylesheets for single build action. + * + * @returns {Set} + */ + 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 */ diff --git a/.vite/pack-extension.js b/.vite/pack-extension.js index c4d7d91..d264e9f 100644 --- a/.vite/pack-extension.js +++ b/.vite/pack-extension.js @@ -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')); diff --git a/.vite/plugins/scss-read-env-variable-function.js b/.vite/plugins/scss-read-env-variable-function.js new file mode 100644 index 0000000..9bf94f8 --- /dev/null +++ b/.vite/plugins/scss-read-env-variable-function.js @@ -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(''); + } + } + } +} diff --git a/.vite/plugins/swap-defined-variables.js b/.vite/plugins/swap-defined-variables.js new file mode 100644 index 0000000..61aabfc --- /dev/null +++ b/.vite/plugins/swap-defined-variables.js @@ -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} define + */ diff --git a/README.md b/README.md index 869da41..09b5127 100644 --- a/README.md +++ b/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 [![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) -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! + +![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) ## 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. diff --git a/manifest.json b/manifest.json index a294ef5..f61bc04 100644 --- a/manifest.json +++ b/manifest.json @@ -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/*" diff --git a/package-lock.json b/package-lock.json index f25431d..2acab45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ccda3f5..ef887c7 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/app.d.ts b/src/app.d.ts index e0ceca7..ffec69b 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -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; diff --git a/src/components/layout/Header.svelte b/src/components/layout/Header.svelte index 9740212..40ab04f 100644 --- a/src/components/layout/Header.svelte +++ b/src/components/layout/Header.svelte @@ -1,5 +1,5 @@
- Furbooru Tagging Assistant + {__CURRENT_SITE_NAME__} Tagging Assistant
diff --git a/src/components/ui/menu/MenuCheckboxItem.svelte b/src/components/ui/menu/MenuCheckboxItem.svelte index 9591e8a..155144e 100644 --- a/src/components/ui/menu/MenuCheckboxItem.svelte +++ b/src/components/ui/menu/MenuCheckboxItem.svelte @@ -9,10 +9,19 @@ value?: string; href?: string; children?: Snippet; + /** + * Click event received by the checkbox input element. + */ onclick?: MouseEventHandler; oninput?: FormEventHandler; + /** + * Click event received by the menu item instead of the checkbox element. + */ + onitemclick?: MouseEventHandler; } + 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')); + } + } + } - - + + {@render children?.()} diff --git a/src/config/tags.ts b/src/config/tags.ts index 6cfea79..ccef91e 100644 --- a/src/config/tags.ts +++ b/src/config/tags.ts @@ -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" +]); diff --git a/src/content/deps/amd.ts b/src/content/deps/amd.ts new file mode 100644 index 0000000..9f4f525 --- /dev/null +++ b/src/content/deps/amd.ts @@ -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)) +}); diff --git a/src/content/header.ts b/src/content/header.ts deleted file mode 100644 index a306a89..0000000 --- a/src/content/header.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { initializeSiteHeader } from "$lib/components/SiteHeaderWrapper"; - -const siteHeader = document.querySelector('.header'); - -if (siteHeader) { - initializeSiteHeader(siteHeader); -} diff --git a/src/lib/components/SearchWrapper.ts b/src/lib/components/SearchWrapper.ts deleted file mode 100644 index aeebdde..0000000 --- a/src/lib/components/SearchWrapper.ts +++ /dev/null @@ -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 = /^(?[a-z\d_]+)(?\.(?[a-z]*))?(?:(?.*))?$/; - - /** - * 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', - ]] - ]); -} diff --git a/src/lib/components/SiteHeaderWrapper.ts b/src/lib/components/SiteHeaderWrapper.ts deleted file mode 100644 index c1b22fe..0000000 --- a/src/lib/components/SiteHeaderWrapper.ts +++ /dev/null @@ -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('.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(); -} diff --git a/src/lib/extension/BulkEntitiesTransporter.ts b/src/lib/extension/BulkEntitiesTransporter.ts new file mode 100644 index 0000000..b83fa49 --- /dev/null +++ b/src/lib/extension/BulkEntitiesTransporter.ts @@ -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; +} + +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>, null, 2); + } + + exportToCompressedJSON(entities: StorageEntity[]): string { + return compressToEncodedURIComponent( + this.exportToJSON(entities) + ); + } + + static isList(targetObject: any): targetObject is ImportableElementsList> { + 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): SameSiteStatus { + if (!('$site' in importedObject)) { + return "unknown"; + } + + return importedObject.$site === __CURRENT_SITE__ + ? "same" + : "different"; + } +} diff --git a/src/lib/extension/EntitiesTransporter.ts b/src/lib/extension/EntitiesTransporter.ts index 8e258e2..0b90cec 100644 --- a/src/lib/extension/EntitiesTransporter.ts +++ b/src/lib/extension/EntitiesTransporter.ts @@ -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 { 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 { 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): 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 { 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 { 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): SameSiteStatus { + if (!('$site' in importedObject)) { + return "unknown"; + } + + return importedObject.$site === __CURRENT_SITE__ + ? "same" + : "different"; + } } diff --git a/src/lib/extension/base/StorageEntity.ts b/src/lib/extension/base/StorageEntity.ts index 88be756..60eb0bf 100644 --- a/src/lib/extension/base/StorageEntity.ts +++ b/src/lib/extension/base/StorageEntity.ts @@ -24,18 +24,22 @@ export default abstract class StorageEntity { 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 ); } diff --git a/src/lib/extension/entities/MaintenanceProfile.ts b/src/lib/extension/entities/MaintenanceProfile.ts index d9509c5..fd2dc82 100644 --- a/src/lib/extension/entities/MaintenanceProfile.ts +++ b/src/lib/extension/entities/MaintenanceProfile.ts @@ -30,5 +30,5 @@ export default class MaintenanceProfile extends StorageEntity { }); } - static _entityName = 'groups'; + public static readonly _entityName: keyof App.EntityNamesMap = "groups"; } diff --git a/src/lib/extension/settings/SearchSettings.ts b/src/lib/extension/settings/SearchSettings.ts deleted file mode 100644 index 373c613..0000000 --- a/src/lib/extension/settings/SearchSettings.ts +++ /dev/null @@ -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 { - 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); - } -} diff --git a/src/lib/extension/transporting/exporters.ts b/src/lib/extension/transporting/exporters.ts index 8477617..a0aa6c7 100644 --- a/src/lib/extension/transporting/exporters.ts +++ b/src/lib/extension/transporting/exporters.ts @@ -1,21 +1,30 @@ import StorageEntity from "$lib/extension/base/StorageEntity"; +import type { ImportableEntityObject } from "$lib/extension/transporting/importables"; + +type ExporterFunction = (entity: EntityType) => ImportableEntityObject; type ExportersMap = { - [EntityName in keyof App.EntityNamesMap]: (entity: App.EntityNamesMap[EntityName]) => Record -}; + [EntityName in keyof App.EntityNamesMap]: ExporterFunction; +} 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, entityName: string): Record { +export function exportEntityToObject( + entityName: EntityName, + entityInstance: App.EntityNamesMap[EntityName] +): ImportableEntityObject { 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); } diff --git a/src/lib/extension/transporting/importables.ts b/src/lib/extension/transporting/importables.ts new file mode 100644 index 0000000..e1f4c10 --- /dev/null +++ b/src/lib/extension/transporting/importables.ts @@ -0,0 +1,40 @@ +import type StorageEntity from "$lib/extension/base/StorageEntity"; + +export interface ImportableElement { + /** + * 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 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 { + /** + * 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 = { [ObjectKey in keyof BaseImportableEntity]: any } + & { [SettingKey in keyof EntityType["settings"]]: any }; diff --git a/src/lib/extension/transporting/validators.ts b/src/lib/extension/transporting/validators.ts index dbb1223..2efec8b 100644 --- a/src/lib/extension/transporting/validators.ts +++ b/src/lib/extension/transporting/validators.ts @@ -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 = { [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 = (importedObject: ImportableObject) => void; +type ValidationFunction = (importedObject: ImportableEntityObject) => 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; }; +/** + * 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: 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); } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ac92655..3df1e07 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -24,6 +24,7 @@ Tagging Profiles Tag Groups
+ Import/Export Preferences About diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index 9f578eb..40f6382 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -1,6 +1,12 @@ @@ -8,18 +14,26 @@

- Furbooru Tagging Assistant + {__CURRENT_SITE_NAME__} Tagging Assistant

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


- - Visit Furbooru + + Visit {__CURRENT_SITE_NAME__} GitHub Repo
+ + diff --git a/src/routes/preferences/+page.svelte b/src/routes/preferences/+page.svelte index 5937831..5f861a2 100644 --- a/src/routes/preferences/+page.svelte +++ b/src/routes/preferences/+page.svelte @@ -7,7 +7,6 @@ Back
Tagging - Search Misc & Tools
Debug diff --git a/src/routes/preferences/search/+page.svelte b/src/routes/preferences/search/+page.svelte deleted file mode 100644 index 5e55d90..0000000 --- a/src/routes/preferences/search/+page.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - - - Back -
-
- - - - Auto-complete properties - - - {#if $searchPropertiesSuggestionsEnabled} - - - - {/if} - diff --git a/src/routes/transporting/+page.svelte b/src/routes/transporting/+page.svelte new file mode 100644 index 0000000..769ae5e --- /dev/null +++ b/src/routes/transporting/+page.svelte @@ -0,0 +1,11 @@ + + + + Back +
+ Export + Import +
diff --git a/src/routes/transporting/export/+page.svelte b/src/routes/transporting/export/+page.svelte new file mode 100644 index 0000000..be11514 --- /dev/null +++ b/src/routes/transporting/export/+page.svelte @@ -0,0 +1,135 @@ + + +{#if !displayExportedString} + + Back +
+ {#if $maintenanceProfiles.length} + + Export All Profiles + + {#each $maintenanceProfiles as profile} + + Profile: {profile.settings.name} + + {/each} +
+ {/if} + {#if $tagGroups.length} + + Export All Groups + + {#each $tagGroups as group} + + Group: {group.settings.name} + + {/each} +
+ {/if} + Export Selected +
+{:else} + + Back to Selection +
+
+ + + + + + +
+ + Switch Format: + {#if shouldUseCompressed} + Base64-Encoded + {:else} + Raw JSON + {/if} + +
+{/if} diff --git a/src/routes/transporting/import/+page.svelte b/src/routes/transporting/import/+page.svelte new file mode 100644 index 0000000..ddc744d --- /dev/null +++ b/src/routes/transporting/import/+page.svelte @@ -0,0 +1,275 @@ + + +{#if !hasImportedEntities} + + Back +
+
+ {#if errorMessage} +

{errorMessage}

+ +
+
+ {/if} + + + + + + +
+ Import & Preview +
+{:else if previewedEntity} + + previewedEntity = null} icon="arrow-left">Back to Selection +
+
+ {#if previewedEntity instanceof MaintenanceProfile} + + {:else if previewedEntity instanceof TagGroup} + + {/if} +{:else} + + Cancel Import + {#if lastImportStatus !== 'same'} +
+ {/if} +
+ {#if lastImportStatus === "different"} +

+ Warning! + 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! +

+ {/if} + {#if lastImportStatus === 'unknown'} +

+ Warning! + 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. +

+ {/if} + + {#if importedProfiles.length} +
+ + Import All Profiles + + {#each importedProfiles as candidateProfile} + + {#if existingProfilesMap.has(candidateProfile.id)} + Update: + {:else} + New: + {/if} + {candidateProfile.settings.name || 'Unnamed Profile'} + + {/each} + {/if} + {#if importedGroups.length} +
+ + Import All Groups + + {#each importedGroups as candidateGroup} + + {#if existingGroupsMap.has(candidateGroup.id)} + Update: + {:else} + New: + {/if} + {candidateGroup.settings.name || 'Unnamed Group'} + + {/each} + {/if} +
+ + Imported Selected + +
+{/if} + + diff --git a/src/stores/preferences/search.ts b/src/stores/preferences/search.ts deleted file mode 100644 index 58a8227..0000000 --- a/src/stores/preferences/search.ts +++ /dev/null @@ -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 = 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'); - }); -}) diff --git a/src/styles/colors.scss b/src/styles/colors.scss index 75ebcbf..40e72bb 100644 --- a/src/styles/colors.scss +++ b/src/styles/colors.scss @@ -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; +} diff --git a/src/styles/content/header.scss b/src/styles/content/header.scss deleted file mode 100644 index 22b76e0..0000000 --- a/src/styles/content/header.scss +++ /dev/null @@ -1,9 +0,0 @@ -.autocomplete { - &__item { - &--property { - i { - margin-right: .5em; - } - } - } -} diff --git a/src/styles/content/listing.scss b/src/styles/content/listing.scss index ef53e9b..467bcda 100644 --- a/src/styles/content/listing.scss +++ b/src/styles/content/listing.scss @@ -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; diff --git a/src/styles/environment.scss b/src/styles/environment.scss new file mode 100644 index 0000000..3837505 --- /dev/null +++ b/src/styles/environment.scss @@ -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'); + + diff --git a/src/styles/injectable/tag.scss b/src/styles/injectable/tag.scss index 68dd70c..bbec107 100644 --- a/src/styles/injectable/tag.scss +++ b/src/styles/injectable/tag.scss @@ -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; } -} \ No newline at end of file +} diff --git a/src/types/amd-lite.d.ts b/src/types/amd-lite.d.ts new file mode 100644 index 0000000..9549e1e --- /dev/null +++ b/src/types/amd-lite.d.ts @@ -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; + readyModules: Record; + + init(options: Partial): void; + + define(name: string, dependencies: string[], callback: function): void; + + resolveDependency(dependencyPath: string); + + resolveDependencies(dependencyNames: string[], from?: string); + } + + export const amdLite: AMDLite; +} diff --git a/vite.config.ts b/vite.config.ts index f8d70c5..b6d5b66 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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'), } - } + }; });