mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2025-12-23 23:02:58 +00:00
Merge pull request #127 from koloml/feature/multiple-sites-build
Support building the extension for both Derpibooru and Furbooru
This commit is contained in:
BIN
.github/assets/fullscreen-viewer-icon.png
vendored
Normal file
BIN
.github/assets/fullscreen-viewer-icon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
.github/assets/fullscreen-viewer-showcase.png
vendored
Normal file
BIN
.github/assets/fullscreen-viewer-showcase.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 343 KiB |
BIN
.github/assets/groups-showcase.png
vendored
Normal file
BIN
.github/assets/groups-showcase.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
.github/assets/profiles-showcase.png
vendored
Normal file
BIN
.github/assets/profiles-showcase.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
@@ -2,6 +2,8 @@ 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.
|
||||
@@ -157,6 +159,19 @@ export async function buildScriptsAndStyles(buildOptions) {
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -192,7 +207,9 @@ export async function buildScriptsAndStyles(buildOptions) {
|
||||
.get(fileName)
|
||||
?.push(...dependencies);
|
||||
}),
|
||||
]
|
||||
derpibooruSwapPlugin,
|
||||
],
|
||||
define: defineConstants,
|
||||
});
|
||||
|
||||
// Build styles separately because AMD converts styles to JS files.
|
||||
@@ -215,7 +232,10 @@ export async function buildScriptsAndStyles(buildOptions) {
|
||||
},
|
||||
plugins: [
|
||||
wrapScriptIntoIIFE(),
|
||||
]
|
||||
ScssViteReadEnvVariableFunctionPlugin(),
|
||||
derpibooruSwapPlugin,
|
||||
],
|
||||
define: defineConstants,
|
||||
});
|
||||
|
||||
return pathsReplacement;
|
||||
|
||||
@@ -86,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.
|
||||
*
|
||||
@@ -118,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
|
||||
*/
|
||||
|
||||
@@ -67,6 +67,15 @@ export async function packExtension(settings) {
|
||||
return entry;
|
||||
})
|
||||
|
||||
if (process.env.SITE === 'derpibooru') {
|
||||
manifest.replaceHostTo([
|
||||
'derpibooru.org',
|
||||
'trixiebooru.org'
|
||||
]);
|
||||
manifest.replaceBooruNameWith('Derpibooru');
|
||||
manifest.setGeckoIdentifier('derpibooru-tagging-assistant@thecore.city');
|
||||
}
|
||||
|
||||
manifest.passVersionFromPackage(path.resolve(settings.rootDir, 'package.json'));
|
||||
manifest.saveTo(path.resolve(settings.exportDir, 'manifest.json'));
|
||||
|
||||
|
||||
46
.vite/plugins/scss-read-env-variable-function.js
Normal file
46
.vite/plugins/scss-read-env-variable-function.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { SassString, Value } from "sass";
|
||||
|
||||
/**
|
||||
* @return {import('vite').Plugin}
|
||||
*/
|
||||
export function ScssViteReadEnvVariableFunctionPlugin() {
|
||||
return {
|
||||
name: 'koloml:scss-read-env-variable-function',
|
||||
apply: 'build',
|
||||
enforce: 'post',
|
||||
|
||||
configResolved: config => {
|
||||
config.css.preprocessorOptions ??= {};
|
||||
config.css.preprocessorOptions.scss ??= {};
|
||||
config.css.preprocessorOptions.scss.functions ??= {};
|
||||
|
||||
/**
|
||||
* @param {Value[]} args
|
||||
* @return {SassString}
|
||||
*/
|
||||
config.css.preprocessorOptions.scss.functions['vite-read-env-variable($constant-name)'] = (args) => {
|
||||
const constName = args[0].assertString('constant-name').text;
|
||||
|
||||
if (config.define && config.define.hasOwnProperty(constName)) {
|
||||
let returnedValue = config.define[constName];
|
||||
|
||||
try {
|
||||
returnedValue = JSON.parse(returnedValue);
|
||||
} catch {
|
||||
returnedValue = null;
|
||||
}
|
||||
|
||||
if (typeof returnedValue !== 'string') {
|
||||
console.warn(`Attempting to read the constant with non-string type: ${constName}`);
|
||||
return new SassString('');
|
||||
}
|
||||
|
||||
return new SassString(returnedValue);
|
||||
}
|
||||
|
||||
console.warn(`Constant does not exist: ${constName}`);
|
||||
return new SassString('');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
.vite/plugins/swap-defined-variables.js
Normal file
28
.vite/plugins/swap-defined-variables.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @param {SwapDefinedVariablesSettings} settings
|
||||
* @return {import('vite').Plugin}
|
||||
*/
|
||||
export function SwapDefinedVariablesPlugin(settings) {
|
||||
return {
|
||||
name: 'koloml:swap-defined-variables',
|
||||
enforce: 'post',
|
||||
configResolved: (config) => {
|
||||
if (
|
||||
config.define
|
||||
&& process.env.hasOwnProperty(settings.envVariable)
|
||||
&& process.env[settings.envVariable] === settings.expectedValue
|
||||
) {
|
||||
for (const [key, value] of Object.entries(settings.define)) {
|
||||
config.define[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} SwapDefinedVariablesSettings
|
||||
* @property {string} envVariable
|
||||
* @property {string} expectedValue
|
||||
* @property {Record<string, string>} define
|
||||
*/
|
||||
56
README.md
56
README.md
@@ -1,10 +1,47 @@
|
||||
# Furbooru Tagging Assistant
|
||||
# Philomena Tagging Assistant
|
||||
|
||||
This is a browser extension written for the [Furbooru](https://furbooru.org) and [Derpibooru](https://derpibooru.org)
|
||||
image-boards. It gives you the ability to manually go over the list of images and apply tags to them without opening
|
||||
each individual image.
|
||||
|
||||
## Installation
|
||||
|
||||
This extension is available for both Chromium- and Firefox-based browsers. You can find the links to the extension pages
|
||||
below.
|
||||
|
||||
### Furbooru Tagging Assistant
|
||||
|
||||
[](https://addons.mozilla.org/en-US/firefox/addon/furbooru-tagging-assistant/)
|
||||
[](https://chromewebstore.google.com/detail/kpgaphaooaaodgodmnkamhmoedjcnfkj)
|
||||
|
||||
This is a browser extension written for the [Furbooru](https://furbooru.org) image-board. It gives you the ability to
|
||||
tag the images more easily and quickly.
|
||||
### Derpibooru Tagging Assistant
|
||||
|
||||
I wasn't able to release the extension for Derpibooru yet. Links will be available shortly.
|
||||
|
||||
## Features
|
||||
|
||||
### Tagging Profiles
|
||||
|
||||
Select a set of tags and add/remove them from images without opening them. Just hover over image, click on tags and
|
||||
you're done!
|
||||
|
||||

|
||||
|
||||
### Custom Tag Groups
|
||||
|
||||
Customize the list of tags with your own custom tag groups. Apply custom colors to different groups or even separate
|
||||
them from each other with group titles.
|
||||
|
||||

|
||||
|
||||
### Fullscreen Viewer
|
||||
|
||||
Open up the specific image or video in fullscreen mode by clicking 🔍 icon in the bottom left corner of the image. This
|
||||
feature is opt-in and should be enabled in the settings first.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Building
|
||||
|
||||
@@ -19,11 +56,18 @@ npm install --save-dev
|
||||
```
|
||||
|
||||
Second, you need to run the `build` command. It will first build the popup using SvelteKit and then build all the
|
||||
content scripts/stylesheets and copy the manifest afterward. Simply run:
|
||||
content scripts/stylesheets and copy the manifest afterward.
|
||||
|
||||
Extension can currently be built for 2 different imageboards using one of the following commands:
|
||||
|
||||
```shell
|
||||
# To build the extension for Furbooru, use:
|
||||
npm run build
|
||||
|
||||
# To build the extension for Derpbooru, use:
|
||||
npm run build:derpibooru
|
||||
```
|
||||
|
||||
When building is complete, resulting files can be found in the `/build` directory. These files can be either used
|
||||
directly in Chrome (via loading the extension as unpacked extension) or manually compressed into `*.zip` file.
|
||||
When build is complete, extension files can be found in the `/build` directory. These files can be either used
|
||||
directly in Chrome (via loading the extension as unpacked extension) or manually compressed into `*.zip` file and loaded
|
||||
into Firefox.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Furbooru Tagging Assistant",
|
||||
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
|
||||
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
|
||||
"version": "0.4.5",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
|
||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -20,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",
|
||||
@@ -231,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",
|
||||
@@ -1646,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",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"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",
|
||||
|
||||
10
src/app.d.ts
vendored
10
src/app.d.ts
vendored
@@ -4,6 +4,16 @@ import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
declare global {
|
||||
/**
|
||||
* Identifier of the current site this extension is built for.
|
||||
*/
|
||||
const __CURRENT_SITE__: string;
|
||||
|
||||
/**
|
||||
* Name of the site.
|
||||
*/
|
||||
const __CURRENT_SITE_NAME__: string;
|
||||
|
||||
// Helper type to not deal with differences between the setTimeout of @types/node and usual web browser's type.
|
||||
type Timeout = ReturnType<typeof setTimeout>;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<header>
|
||||
<a href="/">Furbooru Tagging Assistant</a>
|
||||
<a href="/">{__CURRENT_SITE_NAME__} Tagging Assistant</a>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -19,50 +19,60 @@
|
||||
.tag-color-container:is(:global(.tag-color-container--rating)) :global(.tag) {
|
||||
background-color: colors.$tag-rating-background;
|
||||
color: colors.$tag-rating-text;
|
||||
border-color: colors.$tag-rating-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--spoiler)) :global(.tag) {
|
||||
background-color: colors.$tag-spoiler-background;
|
||||
color: colors.$tag-spoiler-text;
|
||||
border-color: colors.$tag-spoiler-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--origin)) :global(.tag) {
|
||||
background-color: colors.$tag-origin-background;
|
||||
color: colors.$tag-origin-text;
|
||||
border-color: colors.$tag-origin-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--oc)) :global(.tag) {
|
||||
background-color: colors.$tag-oc-background;
|
||||
color: colors.$tag-oc-text;
|
||||
border-color: colors.$tag-oc-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--error)) :global(.tag) {
|
||||
background-color: colors.$tag-error-background;
|
||||
color: colors.$tag-error-text;
|
||||
border-color: colors.$tag-error-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--character)) :global(.tag) {
|
||||
background-color: colors.$tag-character-background;
|
||||
color: colors.$tag-character-text;
|
||||
border-color: colors.$tag-character-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--content-official)) :global(.tag) {
|
||||
background-color: colors.$tag-content-official-background;
|
||||
color: colors.$tag-content-official-text;
|
||||
border-color: colors.$tag-content-official-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--content-fanmade)) :global(.tag) {
|
||||
background-color: colors.$tag-content-fanmade-background;
|
||||
color: colors.$tag-content-fanmade-text;
|
||||
border-color: colors.$tag-content-fanmade-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--species)) :global(.tag) {
|
||||
background-color: colors.$tag-species-background;
|
||||
color: colors.$tag-species-text;
|
||||
border-color: colors.$tag-species-border;
|
||||
}
|
||||
|
||||
.tag-color-container:is(:global(.tag-color-container--body-type)) :global(.tag) {
|
||||
background-color: colors.$tag-body-type-background;
|
||||
color: colors.$tag-body-type-text;
|
||||
border-color: colors.$tag-body-type-border;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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"
|
||||
]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 from "$lib/extension/EntitiesTransporter";
|
||||
import EntitiesTransporter, { type SameSiteStatus } from "$lib/extension/EntitiesTransporter";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
|
||||
@@ -10,9 +10,17 @@ type TransportersMapping = {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -23,7 +31,11 @@ export default class BulkEntitiesTransporter {
|
||||
throw new TypeError('Invalid or unsupported object!');
|
||||
}
|
||||
|
||||
return parsedObject.elements
|
||||
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);
|
||||
@@ -31,9 +43,21 @@ export default class BulkEntitiesTransporter {
|
||||
}
|
||||
|
||||
const transporter = BulkEntitiesTransporter.#transporters[importableObject.$type as keyof App.EntityNamesMap];
|
||||
return transporter.importFromObject(importableObject);
|
||||
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[] {
|
||||
@@ -45,6 +69,7 @@ export default class BulkEntitiesTransporter {
|
||||
exportToJSON(entities: StorageEntity[]): string {
|
||||
return JSON.stringify({
|
||||
$type: 'list',
|
||||
$site: __CURRENT_SITE__,
|
||||
elements: entities
|
||||
.map(entity => {
|
||||
switch (true) {
|
||||
@@ -77,4 +102,19 @@ export default class BulkEntitiesTransporter {
|
||||
profiles: new EntitiesTransporter(MaintenanceProfile),
|
||||
groups: new EntitiesTransporter(TagGroup),
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the imported object is created for the same site extension or not.
|
||||
* @param importedObject Object to check.
|
||||
* @private
|
||||
*/
|
||||
static #checkIsSameSiteImportedObject(importedObject: Record<string, any>): SameSiteStatus {
|
||||
if (!('$site' in importedObject)) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
return importedObject.$site === __CURRENT_SITE__
|
||||
? "same"
|
||||
: "different";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,33 @@ 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
|
||||
@@ -37,6 +60,8 @@ export default class EntitiesTransporter<EntityType> {
|
||||
}
|
||||
|
||||
importFromObject(importedObject: Record<string, any>): EntityType {
|
||||
this.#lastSameSiteStatus = null;
|
||||
|
||||
// TODO: There should be an auto-upgrader somewhere before the validation. So if even the older version of schema
|
||||
// was used, we still will will be able to pass the validation. For now we only have non-breaking changes.
|
||||
validateImportedEntity(
|
||||
@@ -44,6 +69,8 @@ export default class EntitiesTransporter<EntityType> {
|
||||
importedObject,
|
||||
);
|
||||
|
||||
this.#lastSameSiteStatus = EntitiesTransporter.checkIsSameSiteImportedObject(importedObject);
|
||||
|
||||
return new this.#targetEntityConstructor(
|
||||
importedObject.id,
|
||||
importedObject
|
||||
@@ -54,6 +81,7 @@ export default class EntitiesTransporter<EntityType> {
|
||||
const importedObject = this.#tryParsingAsJSON(jsonString);
|
||||
|
||||
if (!importedObject) {
|
||||
this.#lastSameSiteStatus = null;
|
||||
throw new Error('Invalid JSON!');
|
||||
}
|
||||
|
||||
@@ -108,4 +136,18 @@ export default class EntitiesTransporter<EntityType> {
|
||||
|
||||
return jsonObject
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the imported object is created for the same site extension or not.
|
||||
* @param importedObject Object to check.
|
||||
*/
|
||||
static checkIsSameSiteImportedObject(importedObject: Record<string, any>): SameSiteStatus {
|
||||
if (!('$site' in importedObject)) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
return importedObject.$site === __CURRENT_SITE__
|
||||
? "same"
|
||||
: "different";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const entitiesExporters: ExportersMap = {
|
||||
profiles: entity => {
|
||||
return {
|
||||
$type: "profiles",
|
||||
$site: __CURRENT_SITE__,
|
||||
v: 2,
|
||||
id: entity.id,
|
||||
name: entity.settings.name,
|
||||
@@ -22,6 +23,7 @@ const entitiesExporters: ExportersMap = {
|
||||
groups: entity => {
|
||||
return {
|
||||
$type: "groups",
|
||||
$site: __CURRENT_SITE__,
|
||||
v: 2,
|
||||
id: entity.id,
|
||||
name: entity.settings.name,
|
||||
|
||||
@@ -5,6 +5,10 @@ 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"> {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
|
||||
let currentSiteUrl = 'https://furbooru.org';
|
||||
|
||||
if (__CURRENT_SITE__ === 'derpibooru') {
|
||||
currentSiteUrl = 'https://derpibooru.org';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
@@ -8,18 +14,26 @@
|
||||
<hr>
|
||||
</Menu>
|
||||
<h1>
|
||||
Furbooru Tagging Assistant
|
||||
{__CURRENT_SITE_NAME__} Tagging Assistant
|
||||
</h1>
|
||||
<p>
|
||||
This is a tool made to help tag images on Furbooru more efficiently. It is currently in development and is not yet
|
||||
ready for use, but it still can provide some useful functionality.
|
||||
This is a small tool to make tagging on {__CURRENT_SITE_NAME__} just a little bit more convenient. Group tags with
|
||||
your own rules; add or remove tags from the images without opening them up; preview images and videos on click and
|
||||
a little bit more. This extension is highly unstable and might break at any point, so be aware.
|
||||
</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem href="https://furbooru.org" icon="globe" target="_blank">
|
||||
Visit Furbooru
|
||||
<MenuItem href={currentSiteUrl} icon="globe" target="_blank">
|
||||
Visit {__CURRENT_SITE_NAME__}
|
||||
</MenuItem>
|
||||
<MenuItem href="https://github.com/koloml/furbooru-tagging-assistant" icon="info-circle" target="_blank">
|
||||
GitHub Repo
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<style>
|
||||
h1, p {
|
||||
margin-top: .5em;
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
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('');
|
||||
@@ -50,6 +51,8 @@
|
||||
|
||||
const transporter = new BulkEntitiesTransporter();
|
||||
|
||||
let lastImportStatus = $state<SameSiteStatus>(null);
|
||||
|
||||
function tryBulkImport() {
|
||||
importedProfiles = [];
|
||||
importedGroups = [];
|
||||
@@ -75,6 +78,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
lastImportStatus = transporter.lastImportSameSiteStatus;
|
||||
|
||||
if (importedEntities.length) {
|
||||
for (const targetImportedEntity of importedEntities) {
|
||||
switch (targetImportedEntity.type) {
|
||||
@@ -180,7 +185,25 @@
|
||||
{:else}
|
||||
<Menu>
|
||||
<MenuItem onclick={cancelImport} icon="arrow-left">Cancel Import</MenuItem>
|
||||
<hr>
|
||||
{#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')}>
|
||||
@@ -234,7 +257,7 @@
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
|
||||
.error {
|
||||
.error, .warning {
|
||||
padding: 5px 24px;
|
||||
margin: {
|
||||
left: -24px;
|
||||
@@ -242,6 +265,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: colors.$warning-background;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: colors.$error-background;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@use 'sass:color';
|
||||
@use 'environment';
|
||||
|
||||
$background: #15121a;
|
||||
|
||||
@@ -26,27 +27,38 @@ $media-box-border: #311e49;
|
||||
$tag-background: #1b3c21;
|
||||
$tag-count-background: #2d6236;
|
||||
$tag-text: #4aa158;
|
||||
$tag-border: #2d6236;
|
||||
|
||||
$tag-rating-text: #418dd9;
|
||||
$tag-rating-background: color.adjust($tag-rating-text, $lightness: -35%);
|
||||
$tag-rating-border: color.adjust($tag-rating-text, $saturation: -10%, $lightness: -20%);
|
||||
$tag-spoiler-text: #d49b39;
|
||||
$tag-spoiler-background: color.adjust($tag-spoiler-text, $lightness: -34%);
|
||||
$tag-spoiler-border: color.adjust($tag-spoiler-text, $lightness: -23%);
|
||||
$tag-origin-text: #6f66d6;
|
||||
$tag-origin-background: color.adjust($tag-origin-text, $lightness: -40%);
|
||||
$tag-origin-border: color.adjust($tag-origin-text, $saturation: -28%, $lightness: -22%);
|
||||
$tag-oc-text: #b157b7;
|
||||
$tag-oc-background: color.adjust($tag-oc-text, $lightness: -33%);
|
||||
$tag-oc-border: color.adjust($tag-oc-text, $lightness: -15%);
|
||||
$tag-error-text: #d45460;
|
||||
$tag-error-background: color.adjust($tag-error-text, $lightness: -38%, $saturation: -6%, $space: hsl);
|
||||
$tag-error-border: color.adjust($tag-error-text, $lightness: -22%, $space: hsl);
|
||||
$tag-character-text: #4aaabf;
|
||||
$tag-character-background: color.adjust($tag-character-text, $lightness: -33%);
|
||||
$tag-character-border: color.adjust($tag-character-text, $lightness: -20%);
|
||||
$tag-content-official-text: #b9b541;
|
||||
$tag-content-official-background: color.adjust($tag-content-official-text, $lightness: -29%, $saturation: -2%, $space: hsl);
|
||||
$tag-content-official-border: color.adjust($tag-content-official-text, $lightness: -20%, $space: hsl);
|
||||
$tag-content-fanmade-text: #cc8eb5;
|
||||
$tag-content-fanmade-background: color.adjust($tag-content-fanmade-text, $lightness: -40%);
|
||||
$tag-content-fanmade-border: color.adjust($tag-content-fanmade-text, $saturation: -10%, $lightness: -20%);
|
||||
$tag-species-text: #b16b50;
|
||||
$tag-species-background: color.adjust($tag-species-text, $lightness: -35%);
|
||||
$tag-species-border: color.adjust($tag-species-text, $saturation: -10%, $lightness: -20%);
|
||||
$tag-body-type-text: #b8b8b8;
|
||||
$tag-body-type-background: color.adjust($tag-body-type-text, $lightness: -35%, $saturation: -10%, $space: hsl);
|
||||
$tag-body-type-background: color.adjust($tag-body-type-text, $lightness: -50%, $space: hsl);
|
||||
$tag-body-type-border: color.adjust($tag-body-type-text, $lightness: -37%, $saturation: -10%, $space: hsl);
|
||||
|
||||
$input-background: #26232d;
|
||||
$input-border: #5c5a61;
|
||||
@@ -55,3 +67,31 @@ $error-background: #7a2725;
|
||||
|
||||
$warning-background: #7d4825;
|
||||
$warning-border: #95562c;
|
||||
|
||||
@if environment.$current-site == 'derpibooru' {
|
||||
$background: #141a24;
|
||||
|
||||
$text: #e0e0e0;
|
||||
$text-gray: #90a1bb;
|
||||
|
||||
$link: #478acc;
|
||||
$link-hover: #b099dd;
|
||||
|
||||
$header: #284371;
|
||||
$header-toolbar: #1c3252;
|
||||
$header-hover-background: #1d3153;
|
||||
$header-mobile-link-hover: #546c99;
|
||||
|
||||
$footer: #1d242f;
|
||||
$footer-text: $text-gray;
|
||||
|
||||
$block-header: #252d3c;
|
||||
$block-border: #2d3649;
|
||||
$block-background: #1d242f;
|
||||
$block-background-alternate: #171d26;
|
||||
|
||||
$media-box-border: #3d4657;
|
||||
|
||||
$input-background: #282e39;
|
||||
$input-border: #575e6b;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@use '$styles/colors';
|
||||
@use '$styles/booru-vars';
|
||||
@use '$styles/environment';
|
||||
|
||||
// This will fix wierd misplacing of the modified media boxes in the listing.
|
||||
.js-resizable-media-container {
|
||||
@@ -66,9 +67,17 @@
|
||||
|
||||
.tag {
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
user-select: none;
|
||||
|
||||
// Derpibooru has slight differences in how tags are displayed.
|
||||
@if environment.$current-site == 'derpibooru' {
|
||||
padding: 0 5px;
|
||||
gap: 0;
|
||||
}
|
||||
@else {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: booru-vars.$resolved-tag-color;
|
||||
color: booru-vars.$resolved-tag-background;
|
||||
|
||||
20
src/styles/environment.scss
Normal file
20
src/styles/environment.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
@use 'sass:meta';
|
||||
@use 'sass:string';
|
||||
|
||||
@function get-defined-constant($constant-name, $default-value: '') {
|
||||
$resolved-value: $default-value;
|
||||
|
||||
@if meta.function-exists('vite-read-env-variable') {
|
||||
$candidate-value: meta.call(meta.get-function('vite-read-env-variable'), $constant-name);
|
||||
|
||||
@if string.length($candidate-value) != 0 {
|
||||
$resolved-value: $candidate-value
|
||||
}
|
||||
}
|
||||
|
||||
@return $resolved-value;
|
||||
}
|
||||
|
||||
$current-site: get-defined-constant('__CURRENT_SITE__', 'furbooru');
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@use '../colors';
|
||||
@use '../environment';
|
||||
|
||||
.tag {
|
||||
background: colors.$tag-background;
|
||||
@@ -9,9 +10,13 @@
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
|
||||
@if environment.$current-site == 'derpibooru' {
|
||||
border: 1px solid colors.$tag-border;
|
||||
}
|
||||
|
||||
.remove {
|
||||
content: "x";
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { ScssViteReadEnvVariableFunctionPlugin } from "./.vite/plugins/scss-read-env-variable-function";
|
||||
import { SwapDefinedVariablesPlugin } from "./.vite/plugins/swap-defined-variables";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
// SVGs imported from the FA6 don't need to be inlined!
|
||||
assetsInlineLimit: 0
|
||||
},
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
exclude: ['**/node_modules/**', '.*\\.d\\.ts$', '.*\\.spec\\.ts$'],
|
||||
coverage: {
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/lib/**/*.{js,ts}'],
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
build: {
|
||||
// SVGs imported from the FA6 don't need to be inlined!
|
||||
assetsInlineLimit: 0
|
||||
},
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
ScssViteReadEnvVariableFunctionPlugin(),
|
||||
SwapDefinedVariablesPlugin({
|
||||
envVariable: 'SITE',
|
||||
expectedValue: 'derpibooru',
|
||||
define: {
|
||||
__CURRENT_SITE__: JSON.stringify('derpibooru'),
|
||||
__CURRENT_SITE_NAME__: JSON.stringify('Derpibooru'),
|
||||
}
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
exclude: ['**/node_modules/**', '.*\\.d\\.ts$', '.*\\.spec\\.ts$'],
|
||||
coverage: {
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/lib/**/*.{js,ts}'],
|
||||
}
|
||||
},
|
||||
define: {
|
||||
__CURRENT_SITE__: JSON.stringify('furbooru'),
|
||||
__CURRENT_SITE_NAME__: JSON.stringify('Furbooru'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user