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

86 Commits

Author SHA1 Message Date
ec41ba5030 Furbooru: Added screenshots for tag groups feature 2025-11-04 19:26:47 -05:00
55624285e1 Merge pull request #138 from koloml/feature/release-build-pipeline
Added GitHub action for building the project in CI
2025-10-02 13:41:29 +04:00
b97255ccd6 Release CI action
Builds the extension for both sites and uploads them as artifacts. This
will make it possible to just release the project and then grab the
ready-to-be-deployed archive for publishing.

This change was made using Cursor. Just a small test run to check how
useful it is for my workflows.
2025-10-02 13:40:02 +04:00
ef76560bfb Merge pull request #136 from koloml/release/0.5.2
Release: 0.5.2
2025-09-27 22:24:38 +04:00
faa909a0db Properly rearranging non-dev dependencies 2025-09-27 22:22:40 +04:00
3955e3191e Bumped version to 0.5.2 2025-09-27 22:15:38 +04:00
17dab5854c Merge pull request #137 from koloml/feature/reduce-gap-in-tag-category-titles
Furbooru: Applying styling changes previously used only for Derpibooru
2025-09-24 13:13:22 +04:00
a20632e58e Furbooru: Updated tags appearance in media box popups 2025-09-22 22:50:48 +04:00
5f4a1a6c00 Furbooru: Use margin for tag category titles used for Derpibooru 2025-09-22 22:49:33 +04:00
48fc58f042 Merge pull request #135 from koloml/feature/updated-tag-dropdown
Making dropdown link icon active for both boorus
2025-09-22 05:09:45 +04:00
8356956b2e Making dropdown link icon active for both boorus
Furbooru was updated to the latest version of Philomena a few days ago.
Now it uses icons in tag dropdown just like on Derpibooru.
2025-09-22 05:05:52 +04:00
3833cada1e Bumping dependencies (#134)
* Updated `vite` from 6.3.5 to 7.1.2

* Updated `@sveltejs/kit` and `@sveltejs/vite-plugin-svelte`

These are updated together, since they're interconnected with Vite

* Updated `svelte` from 5.33.14 to 5.38.1

* Updated `@sveltejs/adapter-static` from 3.0.8 to 3.0.9

* Updated `vitest` and `@vitest/coverage-v8` from 3.2.0 to 3.2.4

* Updated `svelte-check` from 4.2.1 to 4.3.1

* Updated `typescript` from 5.8.3 to 5.9.2

* Updated `sass` from 1.89.1 to 1.90.0

* Updated `@types/node` from 22.15.29 to 22.17.2

* Updated `cheerio` from 1.0.0 to 1.1.2

* Updated `@sveltejs/kit` from 2.31.1 to 2.42.2

* Updated `vite` from 7.1.2 to 7.1.6

* Updated `@sveltejs/vite-plugin-svelte` from 6.1.2 to 6.2.0

* Updated `svelte` from 5.38.1 to 5.39.4

* Updated `sass` from 1.90.0 to 1.93.0

* Updated `@types/node` from 22.17.2 to 22.18.6

* Updated `jsdom` from 26.1.0 to 27.0.0
2025-09-21 20:49:49 -04:00
f3d80b58b1 Merge pull request #130 from koloml/release/0.5.1
Release: 0.5.1
2025-08-13 18:23:14 +04:00
d567ab4dec Merge pull request #133 from koloml/feature/derpibooru-smaller-tags
Derpibooru: Making tags slightly smaller in height to fit the styling used by the site
2025-08-13 17:55:39 +04:00
e4322b3021 Derpibooru: Making tags slightly smaller inside popup 2025-08-13 17:54:40 +04:00
4907efdaab Bumped version to 0.5.1 2025-08-13 17:50:31 +04:00
c6b9250d71 Merge pull request #132 from koloml/feature/add-to-profile-icon
Derpibooru: Added icon to the tag dropdown option
2025-08-13 17:27:05 +04:00
c330aa303a Derpibooru: Added icon to the tag dropdown option 2025-08-13 17:24:36 +04:00
9ed3f6939d Merge pull request #129 from koloml/bugfix/derpibooru-tag-editor-styling
Fixed tag categories headlines having inconsistent spacing between Derpibooru and Furbooru
2025-08-13 16:52:39 +04:00
5584733b17 Merge pull request #131 from koloml/bugfix/inconsistent-auto-run
Firefox: Fixed content scripts randomly loading asynchronously and not auto-running
2025-08-13 16:52:23 +04:00
91947b8cc7 Merge remote-tracking branch 'origin/release/0.5.1' into bugfix/inconsistent-auto-run
# Conflicts:
#	src/content/deps/amd.ts
2025-08-13 16:49:18 +04:00
df61c812fe Updated autorun logic to resolve issues with loading modules on Firefox
Sometimes Firefox decides to load different groups of content scripts
asynchronously, causing our trick with `requestAnimationFrame` to miss
everything. To prevent this, I decided to just attempt to autorun
everything on each definition using `setTimeout`.

I've also tried to use `queueMicrotask` to put autorun logic right
between different groups of modules, but this trick was only working on
Firefox and completely breaking on Chromium. I sure love browsers!
2025-08-13 16:48:27 +04:00
65c420c36c Merge pull request #128 from koloml/bugfix/ignore-duplicated-modules
Firefox: Fixed an error message appearing when single chunk is trying to execute multiple times
2025-08-13 16:42:16 +04:00
79cd9bc44d Reduced the space used by the tag category headline
This is mainly affecting the Derpibooru version of the extension. Tags
list on Derpibooru is using flex with gaps instead of flex with margins
appearing like gaps (what currently Furbooru uses). This change would
likely be applied to the Furbooru as well.
2025-08-13 15:56:31 +04:00
cf28d2d131 AMD Loader: Ignore duplicated module definitions
This fixes an error appearing when chunk is mention multiple times for
different entry content scripts.
2025-08-13 15:27:25 +04:00
50238d8ef4 Added links to the Derpibooru extension 2025-08-13 14:56:44 +04:00
98b5311cfc Derpibooru: Added screenshot about tag colors in editor 2025-08-12 13:58:34 +04:00
e60d20fd60 Added showcase screenshots for Derpibooru 2025-08-11 09:11:52 +04:00
50a6d8ce32 Merge pull request #120 from koloml/release/0.5
Release: 0.5
2025-08-09 16:05:02 +04:00
7b532735ec Bumped version to 0.5.0 2025-08-09 16:01:23 +04:00
f139b76276 Merge pull request #127 from koloml/feature/multiple-sites-build
Support building the extension for both Derpibooru and Furbooru
2025-08-09 15:58:07 +04:00
eba81b72d4 Derpibooru: Added border to tags, added border colors variables 2025-08-09 15:51:33 +04:00
5b93eb413b Derpibooru: Different list of black-listed tags 2025-08-09 15:35:36 +04:00
81247c1761 Updating README with instructions and showcases
This extension will now be built and released for both Furbooru and
Derpibooru as 2 separate extensions.
2025-08-09 15:25:15 +04:00
8d042a80a0 Derpibooru: Fixed incorrect padding on media box tags 2025-08-09 01:23:06 +04:00
9c162bc2ce Fixed defines not being correctly passed to content scripts & styles 2025-08-09 01:21:02 +04:00
0deafb4a00 Warn user of potentially different/unknown site imports 2025-08-08 20:41:55 +04:00
8822a2581b Store last same site status of the imported object 2025-08-08 20:40:57 +04:00
83d27cc966 Add $site property to identify what site entity was created for 2025-08-08 19:47:44 +04:00
ae3c77031f Added modifications to manifest for Derpibooru variant of the extension 2025-08-08 19:41:23 +04:00
f39e060a6a Updating manifest description, updated About page in popup 2025-08-08 19:40:48 +04:00
c811c13b70 Use site name constant in header component 2025-08-08 19:40:13 +04:00
234f80b992 Added defined constants to typedefinition for TypeScript 2025-08-08 19:39:48 +04:00
4837184d40 Reading constants in SCSS, modifying colors for Derpibooru variant 2025-08-08 19:39:30 +04:00
c37f680e9f Provide current site name & identifier for build-time modifications 2025-08-08 19:38:52 +04:00
2eefbf96ca Implemented plugin to provide constants to SCSS through custom function 2025-08-08 19:37:48 +04:00
efd6522532 Implemented plugin for swaping defined build-time constants 2025-08-08 19:37:12 +04:00
b321c1049c Installed cross-env to support quickly adding ENV variables to builder 2025-08-08 19:36:19 +04:00
ce9a2b5f9b Fixed typo in content script entry 2025-08-08 06:05:13 +04:00
8fe1ca4914 Merge pull request #98 from koloml/feature/bulk-import-and-export
Bulk import/export of tagging profiles and groups
2025-08-04 13:46:03 +04:00
48f278ae95 Added TODO about missing "migration" progress for entities 2025-08-04 13:40:19 +04:00
19ab302b54 Implemented the bulk import interface 2025-08-04 13:39:48 +04:00
189fda59c8 Added separate event listener endpoint for menu item clicks 2025-08-04 13:39:06 +04:00
6bd7116df2 Added validation logic for the group entity 2025-08-04 13:32:35 +04:00
470021ee8c Validators: Using functions for common value checks 2025-08-04 13:32:19 +04:00
e27257516d Validator: Support up to version 2 of profiles entity 2025-07-27 21:44:52 +04:00
77293ba30c Fixed "Export All" checkbox toggling incorrect items 2025-07-27 19:58:19 +04:00
b956b6f7bc Added separate menu for bulk exporting of all entities 2025-07-27 19:02:19 +04:00
fcca26e128 Make checkbox menu item toggle checkbox if no link is set 2025-07-27 19:00:20 +04:00
69dc645de2 Getter for the type of the entity 2025-07-27 18:27:19 +04:00
0781742dab Merge remote-tracking branch 'origin/master' into feature/bulk-import-and-export 2025-07-12 19:24:49 +04:00
71b067a77d Merge branch 'master' into release/0.5 2025-06-25 18:42:44 +04:00
6098a11115 Bumped version to 0.4.5
Woops, forgot to bump the version.
2025-06-03 13:48:49 +04:00
a87d8b94b8 Merge pull request #125 from koloml/release/0.4.5
Release: 0.4.5
2025-06-03 13:46:57 +04:00
c283b96285 Updating dependencies (#126)
* Updated `sass` from 1.86.3 to 1.89.1

* Updated `svelte` from 5.25.6 to 5.33.14

* Updated `svelte-check` from 4.1.5 to 4.2.1

* Updated `@sveltejs/kit` from 2.20.3 to 2.21.1

* Removed `@sveltejs/adapter-auto`

Looks like it was left over from initial commit. We're using static
adapter.

* Updated `vite` from 6.2.5 to 6.3.5

* Updated `vitest` from 3.1.1 to 3.2.0

* Updated `@vitest/coverage-v8` from 3.1.1 to 3.2.0

* Updated `typescript` from 5.8.2 to 5.8.3

* Updated `jsdom` from 26.0.0 to 26.1.0

* Updated `@types/node` from 22.14.0 to 22.15.29

* Updated `@types/chrome` from 0.0.313 to 0.0.326
2025-06-03 13:45:17 +04:00
02478f0bf0 Merge pull request #124 from koloml/bugfix/popup-header-z-index
Fixed header in popup having wrong z-index
2025-06-03 02:41:23 +04:00
59c15f27eb Merge pull request #123 from koloml/feature/quick-query-untagged-implications
Tag Page: Added link for quick search of untagged implications
2025-06-03 02:41:01 +04:00
a58d8f0e15 Merge pull request #122 from koloml/feature/removing-properties-suggestions
Properties Suggestions: Removing feature since it'll be integrated into Philomena
2025-06-03 02:29:53 +04:00
2453bdf7b9 Removing the properties suggestions feature
This feature is moved directly into Philomena. This implementation is
now outdated and depends on the previous version of suggestions popup.
2025-06-03 02:24:20 +04:00
134e96bc4c Added link for a quick search of untagged implications 2025-06-03 02:14:00 +04:00
1c05159ddf Fixed popup's header z-index position appearing behind some elements 2025-04-15 23:50:02 +04:00
39c3f97846 Merge pull request #121 from koloml/feature/minify-amd-exports-in-content-scripts
Content Scripts: Enabled minification of exported properties
2025-04-09 01:01:45 +04:00
9c19bd70c2 Enable minification of exports for content scripts 2025-04-09 00:48:40 +04:00
d158b46dc6 Merge pull request #119 from koloml/feature/amd-loaded-content-scripts
Reworked build step for content scripts & styles to use AMD as module system
2025-04-06 15:55:24 +04:00
966100d606 Added AMD loader initialization as separate entry, autoload everything 2025-04-06 15:13:31 +04:00
7cf2730402 Reworked build step for the content scripts
Main changes:

- Scripts are now built in 2 steps instead of building every script and
style one at a time;
- Scripts are built as AMD modules;
- Dependencies are automatically injected into resulting manifest.json
file.
2025-04-06 15:12:12 +04:00
bdbe49b419 Installed amd-lite 1.0.1 2025-04-06 15:08:14 +04:00
ed779a8481 Merge remote-tracking branch 'origin/master' into feature/bulk-import-and-export
# Conflicts:
#	src/lib/extension/transporting/exporters.ts
2025-04-04 18:25:27 +04:00
9d7f5c0f38 Initial implementation of bulk transporter with import methods 2025-02-20 20:36:34 +04:00
f67a321a66 Extracted method to import directly from object 2025-02-20 20:32:18 +04:00
5dc41700b8 Introducing lists of importable entities, renaming some interfaces 2025-02-19 03:42:20 +04:00
c93c3c7bd5 Swapped validator arguments to meet the order on exporter function, fixing types 2025-02-19 03:19:01 +04:00
459b1fa779 Marking entity name as key of the mapping for type safety 2025-02-19 03:17:52 +04:00
62dc38b35a Fixed exporters not saving categories for groups and temporary flags for profiles 2025-02-19 03:08:54 +04:00
07373e17d5 Updated exporters to use importable types for more type safety 2025-02-19 03:07:52 +04:00
371bce133e Moved base types used in validators to separate module 2025-02-19 03:07:03 +04:00
60 changed files with 2420 additions and 1510 deletions

BIN
.github/assets/colors-in-editor.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

BIN
.github/assets/groups-showcase-0.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

BIN
.github/assets/groups-showcase-1.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

63
.github/workflows/build-extensions.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: Build Extensions
on:
push:
branches: [ master ]
jobs:
build-extensions:
runs-on: ubuntu-latest
strategy:
matrix:
site: [furbooru, derpibooru]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build extension for ${{ matrix.site }}
run: |
if [ "${{ matrix.site }}" = "derpibooru" ]; then
npm run build:derpibooru
else
npm run build
fi
- name: Create extension zip
run: |
cd build
zip -r "../${{ matrix.site }}-tagging-assistant-extension.zip" .
- name: Upload extension artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.site }}-tagging-assistant-extension
path: ${{ matrix.site }}-tagging-assistant-extension.zip
retention-days: 30
create-release-artifacts:
runs-on: ubuntu-latest
needs: build-extensions
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create combined artifact
uses: actions/upload-artifact@v4
with:
name: all-extensions
path: artifacts/
retention-days: 90

View File

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

View File

@@ -17,6 +17,38 @@ class ManifestProcessor {
this.#manifestObject = parsedManifest;
}
/**
* Collect all the content scripts & stylesheets for single build action.
*
* @returns {Set<string>}
*/
collectContentScripts() {
const contentScripts = this.#manifestObject.content_scripts;
if (!contentScripts) {
console.info('No content scripts to collect.');
return new Set();
}
const entryPoints = new Set();
for (let entry of contentScripts) {
if (entry.js) {
for (let jsPath of entry.js) {
entryPoints.add(jsPath);
}
}
if (entry.css) {
for (let cssPath of entry.css) {
entryPoints.add(cssPath);
}
}
}
return entryPoints;
}
/**
* Map over every content script defined in the manifest. If no content scripts defined, no calls will be made to the
* callback.
@@ -54,6 +86,53 @@ class ManifestProcessor {
}
}
/**
* Find all patterns in content scripts and host permissions and replace the hostname to the different one.
*
* @param {string|string[]} singleOrMultipleHostnames One or multiple hostnames to replace the original hostname with.
*/
replaceHostTo(singleOrMultipleHostnames) {
if (typeof singleOrMultipleHostnames === 'string') {
singleOrMultipleHostnames = [singleOrMultipleHostnames];
}
this.#manifestObject.host_permissions = singleOrMultipleHostnames.map(hostname => `*://*.${hostname}/`);
this.#manifestObject.content_scripts?.forEach(entry => {
entry.matches = entry.matches.reduce((resultMatches, originalMatchPattern) => {
for (const updatedHostname of singleOrMultipleHostnames) {
resultMatches.push(
originalMatchPattern.replace(
/\*:\/\/\*\.[a-z]+\.[a-z]+\//,
`*://*.${updatedHostname}/`
),
);
}
return resultMatches;
}, []);
})
}
/**
* Set different identifier for Gecko-based browsers (Firefox).
*
* @param {string} id ID of the extension to use.
*/
setGeckoIdentifier(id) {
this.#manifestObject.browser_specific_settings.gecko.id = id;
}
/**
* Set the different extension name.
*
* @param {string} booruName
*/
replaceBooruNameWith(booruName) {
this.#manifestObject.name = this.#manifestObject.name.replaceAll('Furbooru', booruName);
this.#manifestObject.description = this.#manifestObject.description.replaceAll('Furbooru', booruName);
}
/**
* Save the current state of the manifest into the selected file.
*
@@ -86,13 +165,27 @@ export function loadManifest(filePath) {
/**
* @typedef {Object} Manifest
* @property {string} name
* @property {string} description
* @property {string} version
* @property {BrowserSpecificSettings} browser_specific_settings
* @property {string[]} host_permissions
* @property {ContentScriptsEntry[]|undefined} content_scripts
*/
/**
* @typedef {Object} BrowserSpecificSettings
* @property {GeckoSettings} gecko
*/
/**
* @typedef {Object} GeckoSettings
* @property {string} id
*/
/**
* @typedef {Object} ContentScriptsEntry
* @property {string[]} mathces
* @property {string[]} matches
* @property {string[]|undefined} js
* @property {string[]|undefined} css
*/

View File

@@ -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'));

View 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('');
}
}
}
}

View 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
*/

View File

@@ -1,10 +1,48 @@
# 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
[![Get the Add-on on Firefox](.github/assets/firefox.png)](https://addons.mozilla.org/en-US/firefox/addon/derpibooru-tagging-assistant/)
[![Get the extension on Chrome](.github/assets/chrome.png)](https://chromewebstore.google.com/detail/pnmbomcdbfcghgmegklfofncfigdielb)
## Features
### Tagging Profiles
Select a set of tags and add/remove them from images without opening them. Just hover over image, click on tags and
you're done!
![Tagging Profiles Showcase](.github/assets/profiles-showcase.png)
### Custom Tag Groups
Customize the list of tags with your own custom tag groups. Apply custom colors to different groups or even separate
them from each other with group titles.
![Tag Groups Showcase](.github/assets/groups-showcase.png)
### Fullscreen Viewer
Open up the specific image or video in fullscreen mode by clicking 🔍 icon in the bottom left corner of the image. This
feature is opt-in and should be enabled in the settings first.
![Fullscreen Viewer Icon](.github/assets/fullscreen-viewer-icon.png)
![Fullscreen Viewer Showcase](.github/assets/fullscreen-viewer-showcase.png)
## Building
@@ -19,11 +57,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.

View File

@@ -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.4",
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
"version": "0.5.2",
"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,23 +41,15 @@
"src/styles/content/listing.scss"
]
},
{
"matches": [
"*://*.furbooru.org/*"
],
"js": [
"src/content/header.ts"
],
"css": [
"src/styles/content/header.scss"
]
},
{
"matches": [
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/tags-editor.ts"
],
"css": [
"src/styles/content/tags-editor.scss"
]
},
{

1489
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.4.4",
"version": "0.5.2",
"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",
@@ -12,25 +13,26 @@
"test:watch": "vitest watch --coverage"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.20.3",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.313",
"@types/node": "^22.14.0",
"@vitest/coverage-v8": "^3.1.1",
"cheerio": "^1.0.0",
"jsdom": "^26.0.0",
"sass": "^1.86.3",
"svelte": "^5.25.6",
"svelte-check": "^4.1.5",
"typescript": "^5.8.2",
"vite": "^6.2.5",
"vitest": "^3.1.1"
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/chrome": "^0.0.326",
"@types/node": "^22.18.6",
"@vitest/coverage-v8": "^3.2.4",
"cheerio": "^1.1.2",
"cross-env": "^10.0.0",
"jsdom": "^27.0.0",
"svelte-check": "^4.3.1",
"typescript": "^5.9.2",
"vite": "^7.1.6",
"vitest": "^3.2.4"
},
"type": "module",
"dependencies": {
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.42.2",
"@fortawesome/fontawesome-free": "^6.7.2",
"lz-string": "^1.5.0"
"amd-lite": "^1.0.1",
"lz-string": "^1.5.0",
"sass": "^1.93.0",
"svelte": "^5.39.4"
}
}

10
src/app.d.ts vendored
View File

@@ -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>;

View File

@@ -1,5 +1,5 @@
<header>
<a href="/">Furbooru Tagging Assistant</a>
<a href="/">{__CURRENT_SITE_NAME__} Tagging Assistant</a>
</header>
<style lang="scss">
@@ -13,6 +13,7 @@
top: 0;
left: 0;
right: 0;
z-index: 10;
a {
color: colors.$text;

View File

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

View File

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

View File

@@ -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"
]);

53
src/content/deps/amd.ts Normal file
View File

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

View File

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

View File

@@ -3,8 +3,10 @@ import { createMediaBoxTools } from "$lib/components/MediaBoxTools";
import { calculateMediaBoxesPositions, initializeMediaBox } from "$lib/components/MediaBoxWrapper";
import { createMaintenanceStatusIcon } from "$lib/components/MaintenanceStatusIcon";
import { createImageShowFullscreenButton } from "$lib/components/ImageShowFullscreenButton";
import { initializeImageListContainer } from "$lib/components/listing/ImageListContainer";
const mediaBoxes = document.querySelectorAll<HTMLElement>('.media-box');
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
mediaBoxes.forEach(mediaBoxElement => {
initializeMediaBox(mediaBoxElement, [
@@ -22,3 +24,7 @@ mediaBoxes.forEach(mediaBoxElement => {
});
calculateMediaBoxesPositions(mediaBoxes);
if (imageListContainer) {
initializeImageListContainer(imageListContainer);
}

View File

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

View File

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

View File

@@ -148,7 +148,12 @@ export class TagDropdownWrapper extends BaseComponent {
profileSpecificButtonText = `Remove from profile "${profileName}"`;
}
this.#toggleOnExistingButton.innerText = profileSpecificButtonText;
if (this.#toggleOnExistingButton.lastChild instanceof Text) {
this.#toggleOnExistingButton.lastChild.textContent = ` ${profileSpecificButtonText}`;
} else {
// Just in case last child is missing, then update the text on the full element.
this.#toggleOnExistingButton.textContent = profileSpecificButtonText;
}
if (!this.#toggleOnExistingButton.isConnected) {
this.#dropdownContainer?.append(this.#toggleOnExistingButton);
@@ -243,9 +248,14 @@ export class TagDropdownWrapper extends BaseComponent {
static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement {
const dropdownLink = document.createElement('a');
dropdownLink.href = '#';
dropdownLink.innerText = text;
dropdownLink.className = 'tag__dropdown__link';
const dropdownLinkIcon = document.createElement('i');
dropdownLinkIcon.classList.add('fa', 'fa-tags');
dropdownLink.textContent = ` ${text}`;
dropdownLink.insertAdjacentElement('afterbegin', dropdownLinkIcon);
dropdownLink.addEventListener('click', event => {
event.preventDefault();
onClickHandler(event);

View File

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

View File

@@ -0,0 +1,19 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { ImageListInfo } from "$lib/components/listing/ImageListInfo";
export class ImageListContainer extends BaseComponent {
#info: ImageListInfo | null = null;
protected build() {
const imageListInfoContainer = this.container.querySelector<HTMLElement>('.js-imagelist-info');
if (imageListInfoContainer) {
this.#info = new ImageListInfo(imageListInfoContainer);
this.#info.initialize();
}
}
}
export function initializeImageListContainer(element: HTMLElement) {
new ImageListContainer(element).initialize();
}

View File

@@ -0,0 +1,75 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
export class ImageListInfo extends BaseComponent {
#tagElement: HTMLElement | null = null;
#impliedTags: string[] = [];
#showUntaggedImplicationsButton: HTMLAnchorElement = document.createElement('a');
protected build() {
const sectionAfterImage = this.container.querySelector('.tag-info__image + .flex__grow');
this.#tagElement = sectionAfterImage?.querySelector<HTMLElement>('.tag.dropdown') ?? null;
const labels = this.container
.querySelectorAll<HTMLElement>('.tag-info__image + .flex__grow strong');
let targetElementToInsertBefore: HTMLElement | null = null;
for (const potentialListStarter of labels) {
if (potentialListStarter.innerText === ImageListInfo.#implicationsStarterText) {
targetElementToInsertBefore = potentialListStarter;
this.#collectImplicationsFromListStarter(potentialListStarter);
break;
}
}
if (this.#impliedTags.length && targetElementToInsertBefore) {
this.#showUntaggedImplicationsButton.href = '#';
this.#showUntaggedImplicationsButton.innerText = '(Q)';
this.#showUntaggedImplicationsButton.title =
'Query untagged implications\n\n' +
'This will open the search results with all untagged implications for the current tag.';
this.#showUntaggedImplicationsButton.classList.add('detail-link');
targetElementToInsertBefore.insertAdjacentElement('beforebegin', this.#showUntaggedImplicationsButton);
}
}
protected init() {
this.#showUntaggedImplicationsButton.addEventListener('click', this.#onShowUntaggedImplicationsClicked.bind(this));
}
#collectImplicationsFromListStarter(listStarter: HTMLElement) {
let targetElement: Element | null = listStarter.nextElementSibling;
while (targetElement) {
if (targetElement instanceof HTMLAnchorElement) {
this.#impliedTags.push(targetElement.innerText.trim());
}
// First line break is considered the end of the list.
if (targetElement instanceof HTMLBRElement) {
break;
}
targetElement = targetElement.nextElementSibling;
}
}
#onShowUntaggedImplicationsClicked(event: Event) {
event.preventDefault();
const url = new URL(window.location.href);
url.pathname = '/search';
url.search = '';
const currentTagName = this.#tagElement?.dataset.tagName;
url.searchParams.set('q', `${currentTagName}, !(${this.#impliedTags.join(", ")})`);
location.assign(url.href);
}
static #implicationsStarterText = 'Implies:';
}

View File

@@ -0,0 +1,120 @@
import type StorageEntity from "$lib/extension/base/StorageEntity";
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
import type { ImportableElementsList, ImportableEntityObject } from "$lib/extension/transporting/importables";
import EntitiesTransporter, { type SameSiteStatus } from "$lib/extension/EntitiesTransporter";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import TagGroup from "$entities/TagGroup";
type TransportersMapping = {
[EntityName in keyof App.EntityNamesMap]: EntitiesTransporter<App.EntityNamesMap[EntityName]>;
}
export default class BulkEntitiesTransporter {
#lastSameSiteStatus: SameSiteStatus = null;
get lastImportSameSiteStatus() {
return this.#lastSameSiteStatus;
}
parseAndImportFromJSON(jsonString: string): StorageEntity[] {
let parsedObject: any;
this.#lastSameSiteStatus = null;
try {
parsedObject = JSON.parse(jsonString);
} catch (e) {
throw new TypeError('Invalid JSON!', {cause: e});
}
if (!BulkEntitiesTransporter.isList(parsedObject)) {
throw new TypeError('Invalid or unsupported object!');
}
this.#lastSameSiteStatus = EntitiesTransporter.checkIsSameSiteImportedObject(parsedObject);
let hasDifferentStatuses = false;
const resultEntities = parsedObject.elements
.map(importableObject => {
if (!(importableObject.$type in BulkEntitiesTransporter.#transporters)) {
console.warn('Attempting to import unsupported entity: ' + importableObject.$type);
return null;
}
const transporter = BulkEntitiesTransporter.#transporters[importableObject.$type as keyof App.EntityNamesMap];
const resultEntity = transporter.importFromObject(importableObject);
if (transporter.lastImportSameSiteStatus !== this.#lastSameSiteStatus) {
hasDifferentStatuses = true;
}
return resultEntity;
})
.filter(maybeEntity => !!maybeEntity);
if (hasDifferentStatuses) {
this.#lastSameSiteStatus = 'unknown';
}
return resultEntities;
}
parseAndImportFromCompressedJSON(compressedJsonString: string): StorageEntity[] {
return this.parseAndImportFromJSON(
decompressFromEncodedURIComponent(compressedJsonString)
);
}
exportToJSON(entities: StorageEntity[]): string {
return JSON.stringify({
$type: 'list',
$site: __CURRENT_SITE__,
elements: entities
.map(entity => {
switch (true) {
case entity instanceof MaintenanceProfile:
return BulkEntitiesTransporter.#transporters.profiles.exportToObject(entity);
case entity instanceof TagGroup:
return BulkEntitiesTransporter.#transporters.groups.exportToObject(entity);
}
return null;
})
.filter(value => !!value)
} as ImportableElementsList<ImportableEntityObject<StorageEntity>>, null, 2);
}
exportToCompressedJSON(entities: StorageEntity[]): string {
return compressToEncodedURIComponent(
this.exportToJSON(entities)
);
}
static isList(targetObject: any): targetObject is ImportableElementsList<ImportableEntityObject<StorageEntity>> {
return targetObject.$type
&& targetObject.$type === 'list'
&& targetObject.elements
&& Array.isArray(targetObject.elements);
}
static #transporters: TransportersMapping = {
profiles: new EntitiesTransporter(MaintenanceProfile),
groups: new EntitiesTransporter(TagGroup),
}
/**
* Check if the imported object is created for the same site extension or not.
* @param importedObject Object to check.
* @private
*/
static #checkIsSameSiteImportedObject(importedObject: Record<string, any>): SameSiteStatus {
if (!('$site' in importedObject)) {
return "unknown";
}
return importedObject.$site === __CURRENT_SITE__
? "same"
: "different";
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
import type StorageEntity from "$lib/extension/base/StorageEntity";
export interface ImportableElement<Type extends string = string> {
/**
* Type of importable. Should be unique to properly import everything.
*/
$type: Type;
/**
* Identifier of the site this element is built for.
*/
$site?: string;
}
export interface ImportableElementsList<ElementsType extends ImportableElement = ImportableElement> extends ImportableElement<"list"> {
/**
* List of elements inside. Elements could be of any type and should be checked and mapped.
*/
elements: ElementsType[];
}
/**
* Base information on the object which should be present on every entity.
*/
export interface BaseImportableEntity extends ImportableElement<keyof App.EntityNamesMap> {
/**
* Numeric version of the entity for upgrading.
*/
v: number;
/**
* Unique ID of the entity.
*/
id: string;
}
/**
* Utility type which combines base importable object and the entity type interfaces together. It strips away any types
* defined for the properties, since imported object can not be trusted and should be type-checked by the validators.
*/
export type ImportableEntityObject<EntityType extends StorageEntity> = { [ObjectKey in keyof BaseImportableEntity]: any }
& { [SettingKey in keyof EntityType["settings"]]: any };

View File

@@ -1,32 +1,12 @@
import type StorageEntity from "$lib/extension/base/StorageEntity";
/**
* Base information on the object which should be present on every entity.
*/
interface BaseImportableObject {
/**
* Numeric version of the entity for upgrading.
*/
v: number;
/**
* Unique ID of the entity.
*/
id: string;
}
/**
* Utility type which combines base importable object and the entity type interfaces together. It strips away any types
* defined for the properties, since imported object can not be trusted and should be type-checked by the validators.
*/
type ImportableObject<EntityType extends StorageEntity> = { [ObjectKey in keyof BaseImportableObject]: any }
& { [SettingKey in keyof EntityType["settings"]]: any };
import type { ImportableEntityObject } from "$lib/extension/transporting/importables";
/**
* Function for validating the entities.
* @todo Probably would be better to replace the throw-catch method with some kind of result-error returning type.
* Errors are only properly definable in the JSDoc.
*/
type ValidationFunction<EntityType extends StorageEntity> = (importedObject: ImportableObject<EntityType>) => void;
type ValidationFunction<EntityType extends StorageEntity> = (importedObject: ImportableEntityObject<EntityType>) => void;
/**
* Mapping of validation functions for all entities present in the extension. Key is a name of entity and value is a
@@ -36,39 +16,70 @@ type EntitiesValidationMap = {
[EntityKey in keyof App.EntityNamesMap]?: ValidationFunction<App.EntityNamesMap[EntityKey]>;
};
/**
* Check if the following value is defined, not empty and is of correct type.
* @param value Value to be checked.
*/
function validateRequiredString(value: unknown): boolean {
return Boolean(value && typeof value === 'string');
}
/**
* Check if the following value is not set or is a valid array.
* @param value Value to be checked.
*/
function validateOptionalArray(value: unknown): boolean {
return typeof value === 'undefined' || value === null || Array.isArray(value);
}
/**
* Map of validators for each entity. Function should throw the error if validation failed.
*/
const entitiesValidators: EntitiesValidationMap = {
profiles: importedObject => {
if (importedObject.v !== 1) {
throw new Error('Unsupported version!');
if (!importedObject.v || importedObject.v > 2) {
throw new Error('Unsupported profile version!');
}
if (
!importedObject.id
|| typeof importedObject.id !== "string"
|| !importedObject.name
|| typeof importedObject.name !== "string"
|| !importedObject.tags
|| !Array.isArray(importedObject.tags)
!validateRequiredString(importedObject?.id)
|| !validateRequiredString(importedObject?.name)
|| !validateOptionalArray(importedObject?.tags)
) {
throw new Error('Invalid profile format detected!');
}
}
},
groups: importedObject => {
if (!importedObject.v || importedObject.v > 2) {
throw new Error('Unsupported group version!');
}
if (
!validateRequiredString(importedObject?.id)
|| !validateRequiredString(importedObject?.name)
|| !validateOptionalArray(importedObject?.tags)
|| !validateOptionalArray(importedObject?.prefixes)
|| !validateOptionalArray(importedObject?.suffixes)
) {
throw new Error('Invalid group format detected!');
}
},
};
/**
* Validate the structure of the entity.
* @param importedObject Object imported from JSON.
* @param entityName Name of the entity to validate. Should be loaded from the entity class.
* @param importedObject Object imported from JSON.
* @throws {Error} Error in case validation failed with the reason stored in the message.
*/
export function validateImportedEntity(importedObject: any, entityName: string) {
export function validateImportedEntity<EntityName extends keyof App.EntityNamesMap>(
entityName: EntityName,
importedObject: any
) {
if (!entitiesValidators.hasOwnProperty(entityName)) {
console.error(`Trying to validate entity without the validator present! Entity name: ${entityName}`);
return;
}
entitiesValidators[entityName as keyof EntitiesValidationMap]!.call(null, importedObject);
entitiesValidators[entityName]!.call(null, importedObject);
}

View File

@@ -24,6 +24,7 @@
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
<MenuItem href="/features/groups">Tag Groups</MenuItem>
<hr>
<MenuItem href="/transporting">Import/Export</MenuItem>
<MenuItem href="/preferences">Preferences</MenuItem>
<MenuItem href="/about">About</MenuItem>
</Menu>

View File

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

View File

@@ -7,7 +7,6 @@
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/preferences/tags">Tagging</MenuItem>
<MenuItem href="/preferences/search">Search</MenuItem>
<MenuItem href="/preferences/misc">Misc & Tools</MenuItem>
<hr>
<MenuItem href="/preferences/debug">Debug</MenuItem>

View File

@@ -1,32 +0,0 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import { searchPropertiesSuggestionsEnabled, searchPropertiesSuggestionsPosition } from "$stores/preferences/search";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
import SelectField from "$components/ui/forms/SelectField.svelte";
const propertiesPositions = {
start: "At the start of the list",
end: "At the end of the list",
}
</script>
<Menu>
<MenuItem href="/preferences" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl>
<CheckboxField bind:checked={$searchPropertiesSuggestionsEnabled}>
Auto-complete properties
</CheckboxField>
</FormControl>
{#if $searchPropertiesSuggestionsEnabled}
<FormControl label="Show completed properties:">
<SelectField bind:value={$searchPropertiesSuggestionsPosition}
options="{propertiesPositions}"></SelectField>
</FormControl>
{/if}
</FormContainer>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
</script>
<Menu>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/transporting/export">Export</MenuItem>
<MenuItem href="/transporting/import">Import</MenuItem>
</Menu>

View File

@@ -0,0 +1,135 @@
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
import { tagGroups } from "$stores/entities/tag-groups";
import BulkEntitiesTransporter from "$lib/extension/BulkEntitiesTransporter";
import type StorageEntity from "$lib/extension/base/StorageEntity";
import FormControl from "$components/ui/forms/FormControl.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
const bulkTransporter = new BulkEntitiesTransporter();
let exportAllProfiles = $state(false);
let exportAllGroups = $state(false);
let displayExportedString = $state(false);
let shouldUseCompressed = $state(true);
let compressedExport = $state('');
let plainExport = $state('');
let selectedExportString = $derived(shouldUseCompressed ? compressedExport : plainExport);
const exportedEntities: Record<keyof App.EntityNamesMap, Record<string, boolean>> = $state({
profiles: {},
groups: {},
});
$effect(() => {
if (displayExportedString) {
const elementsToExport: StorageEntity[] = [];
$maintenanceProfiles.forEach(profile => {
if (exportedEntities.profiles[profile.id]) {
elementsToExport.push(profile);
}
});
$tagGroups.forEach(group => {
if (exportedEntities.groups[group.id]) {
elementsToExport.push(group);
}
});
plainExport = bulkTransporter.exportToJSON(elementsToExport);
compressedExport = bulkTransporter.exportToCompressedJSON(elementsToExport);
}
});
function refreshAreAllEntitiesChecked() {
requestAnimationFrame(() => {
exportAllProfiles = $maintenanceProfiles.every(profile => exportedEntities.profiles[profile.id]);
exportAllGroups = $tagGroups.every(group => exportedEntities.groups[group.id]);
});
}
/**
* Create a handler to toggle on or off specific entity type.
* @param targetEntity Code of the entity.
*/
function createToggleAllOnUserInput(targetEntity: keyof App.EntityNamesMap) {
return () => {
requestAnimationFrame(() => {
switch (targetEntity) {
case "profiles":
$maintenanceProfiles.forEach(profile => exportedEntities.profiles[profile.id] = exportAllProfiles);
break;
case "groups":
$tagGroups.forEach(group => exportedEntities.groups[group.id] = exportAllGroups);
break;
default:
console.warn(`Trying to toggle unsupported entity type: ${targetEntity}`);
}
});
}
}
function toggleExportedStringDisplay() {
displayExportedString = !displayExportedString;
}
function toggleExportedStringType() {
shouldUseCompressed = !shouldUseCompressed;
}
</script>
{#if !displayExportedString}
<Menu>
<MenuItem href="/transporting" icon="arrow-left">Back</MenuItem>
<hr>
{#if $maintenanceProfiles.length}
<MenuCheckboxItem bind:checked={exportAllProfiles} oninput={createToggleAllOnUserInput('profiles')}>
Export All Profiles
</MenuCheckboxItem>
{#each $maintenanceProfiles as profile}
<MenuCheckboxItem bind:checked={exportedEntities.profiles[profile.id]} oninput={refreshAreAllEntitiesChecked}>
Profile: {profile.settings.name}
</MenuCheckboxItem>
{/each}
<hr>
{/if}
{#if $tagGroups.length}
<MenuCheckboxItem bind:checked={exportAllGroups} oninput={createToggleAllOnUserInput('groups')}>
Export All Groups
</MenuCheckboxItem>
{#each $tagGroups as group}
<MenuCheckboxItem bind:checked={exportedEntities.groups[group.id]} oninput={refreshAreAllEntitiesChecked}>
Group: {group.settings.name}
</MenuCheckboxItem>
{/each}
<hr>
{/if}
<MenuItem icon="file-export" onclick={toggleExportedStringDisplay}>Export Selected</MenuItem>
</Menu>
{:else}
<Menu>
<MenuItem onclick={toggleExportedStringDisplay} icon="arrow-left">Back to Selection</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl label="Export string">
<textarea readonly rows="6">{selectedExportString}</textarea>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem onclick={toggleExportedStringType}>
Switch Format:
{#if shouldUseCompressed}
Base64-Encoded
{:else}
Raw JSON
{/if}
</MenuItem>
</Menu>
{/if}

View File

@@ -0,0 +1,275 @@
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import TagGroup from "$entities/TagGroup";
import BulkEntitiesTransporter from "$lib/extension/BulkEntitiesTransporter";
import type StorageEntity from "$lib/extension/base/StorageEntity";
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import { tagGroups } from "$stores/entities/tag-groups";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
import ProfileView from "$components/features/ProfileView.svelte";
import GroupView from "$components/features/GroupView.svelte";
import { goto } from "$app/navigation";
import type { SameSiteStatus } from "$lib/extension/EntitiesTransporter";
let importedString = $state('');
let errorMessage = $state('');
let importedProfiles = $state<MaintenanceProfile[]>([]);
let importedGroups = $state<TagGroup[]>([]);
let saveAllProfiles = $state(false);
let saveAllGroups = $state(false);
let selectedEntities: Record<keyof App.EntityNamesMap, Record<string, boolean>> = $state({
profiles: {},
groups: {},
});
let previewedEntity = $state<StorageEntity | null>(null);
const existingProfilesMap = $derived(
$maintenanceProfiles.reduce((map, profile) => {
map.set(profile.id, profile);
return map;
}, new Map<string, MaintenanceProfile>())
);
const existingGroupsMap = $derived(
$tagGroups.reduce((map, group) => {
map.set(group.id, group);
return map;
}, new Map<string, TagGroup>())
);
const hasImportedEntities = $derived(
Boolean(importedProfiles.length || importedGroups.length)
);
const transporter = new BulkEntitiesTransporter();
let lastImportStatus = $state<SameSiteStatus>(null);
function tryBulkImport() {
importedProfiles = [];
importedGroups = [];
errorMessage = '';
importedString = importedString.trim();
if (!importedString) {
errorMessage = 'Nothing to import.';
return;
}
let importedEntities: StorageEntity[] = [];
try {
if (importedString.startsWith('{')) {
importedEntities = transporter.parseAndImportFromJSON(importedString);
} else {
importedEntities = transporter.parseAndImportFromCompressedJSON(importedString);
}
} catch (importError) {
errorMessage = importError instanceof Error ? importError.message : 'Unknown error!';
return;
}
lastImportStatus = transporter.lastImportSameSiteStatus;
if (importedEntities.length) {
for (const targetImportedEntity of importedEntities) {
switch (targetImportedEntity.type) {
case "profiles":
importedProfiles.push(targetImportedEntity as MaintenanceProfile);
break;
case "groups":
importedGroups.push(targetImportedEntity as TagGroup);
break;
default:
console.warn(`Unprocessed entity type detected: ${targetImportedEntity.type}`, targetImportedEntity);
}
}
} else {
errorMessage = "Import string contains nothing!";
}
}
function cancelImport() {
importedProfiles = [];
importedGroups = [];
}
function refreshAreAllEntitiesChecked() {
requestAnimationFrame(() => {
saveAllProfiles = importedProfiles.every(profile => selectedEntities.profiles[profile.id]);
saveAllGroups = importedGroups.every(group => selectedEntities.groups[group.id]);
});
}
function createToggleAllOnUserInput(entityType: keyof App.EntityNamesMap) {
return () => {
requestAnimationFrame(() => {
switch (entityType) {
case "profiles":
importedProfiles.forEach(profile => selectedEntities.profiles[profile.id] = saveAllProfiles);
break;
case "groups":
importedGroups.forEach(group => selectedEntities.groups[group.id] = saveAllGroups);
break;
default:
console.warn(`Trying to toggle unsupported entity type: ${entityType}`);
}
});
};
}
function createShowPreviewForEntity(entity: StorageEntity) {
return (event: MouseEvent) => {
event.preventDefault();
previewedEntity = entity;
}
}
function saveSelectedEntities() {
Promise.allSettled([
Promise.allSettled(
importedProfiles
.filter(profile => selectedEntities.profiles[profile.id])
.map(profile => profile.save())
),
Promise.allSettled(
importedGroups
.filter(group => selectedEntities.groups[group.id])
.map(group => group.save())
),
]).then(() => {
goto("/transporting");
});
}
</script>
{#if !hasImportedEntities}
<Menu>
<MenuItem href="/transporting" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if errorMessage}
<p class="error">{errorMessage}</p>
<Menu>
<hr>
</Menu>
{/if}
<FormContainer>
<FormControl label="Import string">
<textarea bind:value={importedString} rows="6"></textarea>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem onclick={tryBulkImport}>Import & Preview</MenuItem>
</Menu>
{:else if previewedEntity}
<Menu>
<MenuItem onclick={() => previewedEntity = null} icon="arrow-left">Back to Selection</MenuItem>
<hr>
</Menu>
{#if previewedEntity instanceof MaintenanceProfile}
<ProfileView profile={previewedEntity}></ProfileView>
{:else if previewedEntity instanceof TagGroup}
<GroupView group={previewedEntity}></GroupView>
{/if}
{:else}
<Menu>
<MenuItem onclick={cancelImport} icon="arrow-left">Cancel Import</MenuItem>
{#if lastImportStatus !== 'same'}
<hr>
{/if}
</Menu>
{#if lastImportStatus === "different"}
<p class="warning">
<b>Warning!</b>
Looks like these entities were exported for the different extension! There are many differences between tagging
systems of Furobooru and Derpibooru, so make sure to check if these settings are correct before using them!
</p>
{/if}
{#if lastImportStatus === 'unknown'}
<p class="warning">
<b>Warning!</b>
We couldn't verify if these settings are meant for this site or not. There are many differences between tagging
systems of Furbooru and Derpibooru, so make sure to check if these settings are correct before using them.
</p>
{/if}
<Menu>
{#if importedProfiles.length}
<hr>
<MenuCheckboxItem bind:checked={saveAllProfiles} oninput={createToggleAllOnUserInput('profiles')}>
Import All Profiles
</MenuCheckboxItem>
{#each importedProfiles as candidateProfile}
<MenuCheckboxItem
bind:checked={selectedEntities.profiles[candidateProfile.id]}
oninput={refreshAreAllEntitiesChecked}
onitemclick={createShowPreviewForEntity(candidateProfile)}
>
{#if existingProfilesMap.has(candidateProfile.id)}
Update:
{:else}
New:
{/if}
{candidateProfile.settings.name || 'Unnamed Profile'}
</MenuCheckboxItem>
{/each}
{/if}
{#if importedGroups.length}
<hr>
<MenuCheckboxItem
bind:checked={saveAllGroups}
oninput={createToggleAllOnUserInput('groups')}
>
Import All Groups
</MenuCheckboxItem>
{#each importedGroups as candidateGroup}
<MenuCheckboxItem
bind:checked={selectedEntities.groups[candidateGroup.id]}
oninput={refreshAreAllEntitiesChecked}
onitemclick={createShowPreviewForEntity(candidateGroup)}
>
{#if existingGroupsMap.has(candidateGroup.id)}
Update:
{:else}
New:
{/if}
{candidateGroup.settings.name || 'Unnamed Group'}
</MenuCheckboxItem>
{/each}
{/if}
<hr>
<MenuItem onclick={saveSelectedEntities}>
Imported Selected
</MenuItem>
</Menu>
{/if}
<style lang="scss">
@use '$styles/colors';
.error, .warning {
padding: 5px 24px;
margin: {
left: -24px;
right: -24px;
}
}
.warning {
background: colors.$warning-background;
}
.error {
background: colors.$error-background;
}
</style>

View File

@@ -1,28 +0,0 @@
import { type Writable, writable } from "svelte/store";
import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings";
export const searchPropertiesSuggestionsEnabled = writable(false);
export const searchPropertiesSuggestionsPosition: Writable<SuggestionsPosition> = writable('start');
const searchSettings = new SearchSettings();
Promise.allSettled([
// First we wait for all properties to load and save
searchSettings.resolvePropertiesSuggestionsEnabled().then(v => searchPropertiesSuggestionsEnabled.set(v)),
searchSettings.resolvePropertiesSuggestionsPosition().then(v => searchPropertiesSuggestionsPosition.set(v))
]).then(() => {
// And then we can start reading value changes from the writable objects
searchPropertiesSuggestionsEnabled.subscribe(value => {
void searchSettings.setPropertiesSuggestions(value);
});
searchPropertiesSuggestionsPosition.subscribe(value => {
void searchSettings.setPropertiesSuggestionsPosition(value);
});
searchSettings.subscribe(settings => {
searchPropertiesSuggestionsEnabled.set(Boolean(settings.suggestProperties));
searchPropertiesSuggestionsPosition.set(settings.suggestPropertiesPosition || 'start');
});
})

View File

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

View File

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

View File

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

View File

@@ -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,8 +67,9 @@
.tag {
cursor: pointer;
padding: 5px;
user-select: none;
padding: 0 5px;
gap: 0;
&:hover {
background: booru-vars.$resolved-tag-color;

View File

@@ -0,0 +1,13 @@
@use '$styles/booru-vars';
@use '$styles/environment';
h2.tag-category-headline {
// Basic margin top and bottom values gathered from Chrome.
$base-margin-top: .83em;
$base-margin-bottom: .62em;
margin: {
top: calc(#{$base-margin-top} - #{booru-vars.$padding-small});
bottom: calc(#{$base-margin-bottom} - #{booru-vars.$padding-small});
}
}

View 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');

View File

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

23
src/types/amd-lite.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
// Types for the small untyped AMD loader. These types do not cover all the functions available in the package, only
// parts required for content scripts in extension to work.
declare module 'amd-lite' {
interface AMDLiteInitOptions {
publicScope: any;
verbosity: number;
}
interface AMDLite {
waitingModules: Record<string, any>;
readyModules: Record<string, any>;
init(options: Partial<AMDLiteInitOptions>): void;
define(name: string, dependencies: string[], callback: function): void;
resolveDependency(dependencyPath: string);
resolveDependencies(dependencyNames: string[], from?: string);
}
export const amdLite: AMDLite;
}

View File

@@ -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'),
}
}
};
});