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

123 Commits
0.4.2 ... 0.5.1

Author SHA1 Message Date
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
bb14492578 Merge pull request #116 from koloml/release/0.4.4
Release: 0.4.4
2025-04-04 14:38:37 +04:00
30320e7283 Bumped version to 0.4.4 2025-04-04 14:37:20 +04:00
8839373292 Updating dependencies (#118)
* Updated `vite` to 6.2.5

* Updated `sass` to 1.86.3

* Updated `svelte` to 5.25.6

* Updated `@sveltejs/kit` to 2.20.3

* Updated `vitest` to 3.1.1

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

* Updated `@sveltejs/adapter-auto` to 6.0.0

* Updated `@types/chrome` to 0.0.313

* Updated `@types/node` to 22.14.0
2025-04-04 14:35:54 +04:00
0e35d1d0ba Merge pull request #117 from koloml/feature/name-events-as-constants
Changing the naming of custom events to be different from the usual variables
2025-04-04 14:15:45 +04:00
bca21da6d1 Merge remote-tracking branch 'origin/release/0.4.4' into feature/name-events-as-constants
# Conflicts:
#	src/lib/components/TagsListBlock.ts
2025-04-04 14:15:02 +04:00
60491f57d4 Merge pull request #115 from koloml/feature/grouping-button
Tag Groups: Added button to the tags list component to quckly toggle the sepeartion on and off
2025-03-26 21:06:25 +04:00
c26c4bcf62 Merge pull request #114 from koloml/bugfix/missing-re-initialization-for-tags-list
Tags List: Fixed re-initialization of the component after tags were submitted
2025-03-26 21:06:15 +04:00
1b4b646024 Merge pull request #113 from koloml/bugfix/last-mediabox-position
Fixed last media box on the page showing its popup outside of the viewport
2025-03-26 21:05:29 +04:00
928fe5ddb0 Removed unnecessary import 2025-03-26 20:46:51 +04:00
6586141134 Fixed missed re-initialization of tags list after tag form was submitted 2025-03-26 20:43:19 +04:00
d587bd2453 Added button to the tags list to toggle separation of groups 2025-03-26 20:39:30 +04:00
e2eb8a0ca7 Fixed last media box on the page not being marked as the last in a row 2025-03-26 20:03:04 +04:00
0876e5f001 Changed naming for event name constants to differentiate them with variables 2025-03-26 19:01:18 +04:00
d5ad66d902 Merge pull request #112 from koloml/release/0.4.3.1
Release: 0.4.3.1
2025-03-12 19:34:05 +04:00
cb6b5f4f9d Bumped version to 0.4.3.1 2025-03-12 19:32:27 +04:00
193941b0ac Merge pull request #111 from koloml/hotfix/tag-groups-not-applying-for-tag-editor
Fixed tag group colors & grouping not applying in Firefox
2025-03-12 19:31:52 +04:00
562274b3d8 Fixed Firefox not applying tag groups due to invalid scripts order 2025-03-12 19:27:24 +04:00
6faf5c8582 Merge pull request #105 from koloml/release/0.4.3
Release: 0.4.3
2025-03-12 18:55:40 +04:00
e591751406 Bumped version to 0.4.3 2025-03-12 18:48:50 +04:00
c9347c375d Merge pull request #110 from koloml/bugfix/group-editor-back-link
Tag Groups: Fixed incorrect path on the "Back" link for group editor view
2025-03-12 18:46:50 +04:00
68e134f2e4 Updated dependencies (#109)
* Updated `@sveltejs/kit` to 2.19.0

* Updated `svelte` to 5.23.0

* Updated `typescript` to 5.8.2

* Updated `svelte-check` to 4.1.5

* Updated `sass` to 1.85.1

* Updated `@types/chrome` to 0.0.309

* Updated `vite` to 6.1.1

* Updated `vitest` to 3.0.8

* Updated `@vitest/coverage-v8` to 3.0.8

* Updated `@types/node` to 22.13.10
2025-03-12 18:45:21 +04:00
338eb2bbb1 Fixed incorrect path on the "Back" link for group editor 2025-03-12 18:43:41 +04:00
2933cd379e Merge pull request #108 from koloml/feature/option-to-display-groups-separately
Tag Groups: Added option to display the tags captured by the group in the separate list
2025-03-10 23:58:56 +04:00
8fe2d718ff Default global group separation to turned on 2025-03-10 06:49:41 +04:00
b1ca67fc5b Implemented grouping of tags marked for separation in settings 2025-03-10 06:48:56 +04:00
37095a2f22 Fixed instances not resolving on different content scripts 2025-03-10 06:08:08 +04:00
c1ed23dee5 Added event for resolved categories, coloring from the tag wrapper 2025-03-10 06:07:36 +04:00
8c51d2d482 Store references to the tag group instead of category name 2025-03-10 03:39:43 +04:00
16b72300a9 Added option to separate the specific group of choice in tags 2025-03-10 03:24:23 +04:00
11af0f6484 Fixed category missing in the export for groups 2025-03-10 03:22:55 +04:00
4f302faf45 Added option to turn on/off separation of tags by custom category 2025-03-10 03:18:03 +04:00
bedb18a6aa Merge pull request #107 from koloml/feature/groups-suffix
Tag Groups: Support matching custom categories by suffix
2025-03-09 03:58:19 +04:00
ea791838bf Display stars in the tag editor for prefixes/suffixes 2025-03-02 18:55:51 +04:00
ff16c62e26 Added support for suffix-matching for groups 2025-03-02 18:44:53 +04:00
45cc5b0eb3 Merge pull request #106 from koloml/feature/workaronud-for-opening-in-new-tab
Fixed popup links being unusable when opened in new tab
2025-02-28 03:57:38 +04:00
a2d884c969 Added tests for the link replacement logic 2025-02-28 03:50:08 +04:00
74f987b5c9 Merge remote-tracking branch 'refs/remotes/origin/release/0.4.3' into feature/workaronud-for-opening-in-new-tab 2025-02-28 03:28:13 +04:00
f687389516 Implemented routing to be more compatible for extension popup 2025-02-28 03:18:18 +04:00
92854f4d6b Renamed hooks to TS 2025-02-28 02:40:19 +04:00
4ca9ff029b Merge pull request #104 from koloml/feature/testing-configuration-controller
Added tests for ConfigurationController class
2025-02-28 02:01:00 +04:00
70e573ddc8 Merge pull request #103 from koloml/bugfix/fixing-type-errors
Fixed type errors reported by the TypeScript
2025-02-28 02:00:30 +04:00
8e843c2b19 Fixed element types not being set up for queries 2025-02-27 00:54:00 +04:00
76e7bf1542 Fixed missing empty checks for required components 2025-02-27 00:53:44 +04:00
d5ed86fb40 Exposing timer return type globally 2025-02-27 00:50:05 +04:00
dc0a9f0aa8 Imported utils function for random string 2025-02-25 03:39:30 +04:00
09edc44af8 Added tests for configuration controller 2025-02-25 03:38:49 +04:00
a9d53afdbe Mocked storage change events for mocked storage area 2025-02-25 03:20:43 +04:00
ed263d2da4 Installed types for NodeJS for testing 2025-02-25 03:19:49 +04:00
9586d121e4 Moved storage definition to constructor for testability 2025-02-25 03:19:22 +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
91 changed files with 3360 additions and 1382 deletions

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

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.2",
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
"version": "0.5.1",
"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/",
@@ -35,13 +43,13 @@
},
{
"matches": [
"*://*.furbooru.org/*"
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/header.ts"
"src/content/tags-editor.ts"
],
"css": [
"src/styles/content/header.scss"
"src/styles/content/tags-editor.scss"
]
},
{
@@ -61,14 +69,6 @@
"js": [
"src/content/tags.ts"
]
},
{
"matches": [
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/tags-editor.ts"
]
}
],
"action": {

1369
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.2",
"version": "0.5.1",
"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,24 +13,26 @@
"test:watch": "vitest watch --coverage"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.2",
"@sveltejs/kit": "^2.21.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.304",
"@vitest/coverage-v8": "^3.0.6",
"@types/chrome": "^0.0.326",
"@types/node": "^22.15.29",
"@vitest/coverage-v8": "^3.2.0",
"cheerio": "^1.0.0",
"jsdom": "^26.0.0",
"sass": "^1.85.0",
"svelte": "^5.20.1",
"svelte-check": "^4.1.4",
"typescript": "^5.7.3",
"vite": "^6.1.0",
"vitest": "^3.0.6"
"cross-env": "^10.0.0",
"jsdom": "^26.1.0",
"sass": "^1.89.1",
"svelte": "^5.33.14",
"svelte-check": "^4.2.1",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.2.0"
},
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"amd-lite": "^1.0.1",
"lz-string": "^1.5.0"
}
}

13
src/app.d.ts vendored
View File

@@ -4,6 +4,19 @@ 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>;
namespace App {
// interface Error {}
// interface Locals {}

View File

@@ -9,7 +9,8 @@
let { group }: GroupViewProps = $props();
let sortedTagsList = $derived<string[]>(group.settings.tags.sort((a, b) => a.localeCompare(b))),
sortedPrefixes = $derived<string[]>(group.settings.prefixes.sort((a, b) => a.localeCompare(b)));
sortedPrefixes = $derived<string[]>(group.settings.prefixes.sort((a, b) => a.localeCompare(b))),
sortedSuffixes = $derived<string[]>(group.settings.suffixes.sort((a, b) => a.localeCompare(b)));
</script>
@@ -41,6 +42,18 @@
</TagsColorContainer>
</div>
{/if}
{#if sortedSuffixes.length}
<div class="block">
<strong>Suffixes:</strong>
<TagsColorContainer targetCategory={group.settings.category}>
<div class="tags-list">
{#each sortedSuffixes as suffixName}
<span class="tag">*{suffixName}</span>
{/each}
</div>
</TagsColorContainer>
</div>
{/if}
<style lang="scss">
.tags-list {

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

@@ -4,10 +4,12 @@
interface TagEditorProps {
// List of tags to edit. Any duplicated tags present in the array will be removed on the first edit.
tags?: string[];
mapTagNames?: (tagName: string) => string;
}
let {
tags = $bindable([])
tags = $bindable([]),
mapTagNames,
}: TagEditorProps = $props();
let uniqueTags = $state<Set<string>>(new Set());
@@ -87,7 +89,7 @@
<div class="tags-editor">
{#each uniqueTags.values() as tagName}
<div class="tag">
{tagName}
{mapTagNames?.(tagName) ?? tagName}
<span class="remove" onclick={createTagRemoveHandler(tagName)}
onkeydown={createTagRemoveHandler(tagName)}
role="button" tabindex="0">x</span>

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('.header');
if (siteHeader) {
initializeSiteHeader(siteHeader);
}

View File

@@ -3,9 +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";
/** @type {NodeListOf<HTMLElement>} */
const mediaBoxes = document.querySelectorAll('.media-box');
const mediaBoxes = document.querySelectorAll<HTMLElement>('.media-box');
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
mediaBoxes.forEach(mediaBoxElement => {
initializeMediaBox(mediaBoxElement, [
@@ -23,3 +24,7 @@ mediaBoxes.forEach(mediaBoxElement => {
});
calculateMediaBoxesPositions(mediaBoxes);
if (imageListContainer) {
initializeImageListContainer(imageListContainer);
}

View File

@@ -1,3 +1,6 @@
import { TagsForm } from "$lib/components/TagsForm";
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$lib/components/TagsListBlock";
initializeAllTagsLists();
watchForUpdatedTagLists();
TagsForm.watchForEditors();

View File

@@ -1,6 +1,6 @@
import { watchTagDropdownsInTagsEditor, wrapTagDropdown } from "$lib/components/TagDropdownWrapper";
for (let tagDropdownElement of document.querySelectorAll('.tag.dropdown')) {
for (let tagDropdownElement of document.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}

View File

@@ -1,8 +1,13 @@
/** @type {import('@sveltejs/kit').Reroute} */
export function reroute({url}) {
import type { Reroute } from "@sveltejs/kit";
export const reroute: Reroute = ({url}) => {
// Reroute index.html as just / for the root.
// Browser extension starts from with the index.html file in the pathname which is not correct for the router.
if (url.pathname === '/index.html') {
if (url.searchParams.has('path')) {
return url.searchParams.get('path')!;
}
return "/";
}
}
};

View File

@@ -1,7 +1,7 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
import { emit, on } from "$lib/components/events/comms";
import { eventSizeLoaded } from "$lib/components/events/fullscreen-viewer-events";
import { EVENT_SIZE_LOADED } from "$lib/components/events/fullscreen-viewer-events";
export class FullscreenViewer extends BaseComponent {
#videoElement: HTMLVideoElement = document.createElement('video');
@@ -173,7 +173,7 @@ export class FullscreenViewer extends BaseComponent {
this.#sizeSelectorElement.value = size;
this.#isSizeFetched = true;
emit(this.container, eventSizeLoaded, size);
emit(this.container, EVENT_SIZE_LOADED, size);
}
#watchForSizeSelectionChanges() {
@@ -224,7 +224,7 @@ export class FullscreenViewer extends BaseComponent {
await new Promise(
resolve => on(
this.container,
eventSizeLoaded,
EVENT_SIZE_LOADED,
resolve
),
);

View File

@@ -47,7 +47,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
}
#onButtonClicked() {
const imageLinks = this.#mediaBoxTools?.mediaBox.imageLinks;
const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks;
if (!imageLinks) {
throw new Error('Failed to resolve image links from media box tools!');

View File

@@ -6,9 +6,9 @@ import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI";
import { tagsBlacklist } from "$config/tags";
import { emitterAt } from "$lib/components/events/comms";
import {
eventActiveProfileChanged,
eventMaintenanceStateChanged,
eventTagsUpdated
EVENT_ACTIVE_PROFILE_CHANGED,
EVENT_MAINTENANCE_STATE_CHANGED,
EVENT_TAGS_UPDATED
} from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
@@ -30,7 +30,7 @@ export class MaintenancePopup extends BaseComponent {
#tagsToAdd: Set<string> = new Set();
#isPlanningToSubmit: boolean = false;
#isSubmitting: boolean = false;
#tagsSubmissionTimer: number | null = null;
#tagsSubmissionTimer: Timeout | null = null;
#emitter = emitterAt(this);
/**
@@ -70,6 +70,10 @@ export class MaintenancePopup extends BaseComponent {
const mediaBox = this.#mediaBoxTools.mediaBox;
if (!mediaBox) {
throw new Error('Media box component not found!');
}
mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this));
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
}
@@ -79,11 +83,11 @@ export class MaintenancePopup extends BaseComponent {
this.container.classList.toggle('is-active', activeProfile !== null);
this.#refreshTagsList();
this.#emitter.emit(eventActiveProfileChanged, activeProfile);
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
}
#refreshTagsList() {
if (!this.#mediaBoxTools) {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
@@ -109,11 +113,11 @@ export class MaintenancePopup extends BaseComponent {
this.#tagsList[index] = tagElement;
this.#tagsListElement.appendChild(tagElement);
const isPresent = currentPostTags.has(tagName);
const isPresent = currentPostTags?.has(tagName);
tagElement.classList.toggle('is-present', isPresent);
tagElement.classList.toggle('is-missing', !isPresent);
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags.get(tagName) !== tagName);
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags?.get(tagName) !== tagName);
// Just to prevent duplication, we need to include this tag to the map of suggested invalid tags
if (tagsBlacklist.includes(tagName)) {
@@ -173,7 +177,7 @@ export class MaintenancePopup extends BaseComponent {
}
this.#isPlanningToSubmit = true;
this.#emitter.emit(eventMaintenanceStateChanged, 'waiting');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting');
}
}
@@ -193,14 +197,14 @@ export class MaintenancePopup extends BaseComponent {
}
async #onSubmissionTimerPassed() {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools) {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) {
return;
}
this.#isPlanningToSubmit = false;
this.#isSubmitting = true;
this.#emitter.emit(eventMaintenanceStateChanged, 'processing');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing');
let maybeTagsAndAliasesAfterUpdate;
@@ -242,17 +246,17 @@ export class MaintenancePopup extends BaseComponent {
MaintenancePopup.#notifyAboutPendingSubmission(false);
this.#emitter.emit(eventMaintenanceStateChanged, 'failed');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
this.#isSubmitting = false;
return;
}
if (maybeTagsAndAliasesAfterUpdate) {
this.#emitter.emit(eventTagsUpdated, maybeTagsAndAliasesAfterUpdate);
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
}
this.#emitter.emit(eventMaintenanceStateChanged, 'complete');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete');
this.#tagsToAdd.clear();
this.#tagsToRemove.clear();
@@ -264,7 +268,7 @@ export class MaintenancePopup extends BaseComponent {
}
#revealInvalidTags() {
if (!this.#mediaBoxTools) {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}

View File

@@ -1,7 +1,7 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { on } from "$lib/components/events/comms";
import { eventMaintenanceStateChanged } from "$lib/components/events/maintenance-popup-events";
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
export class MaintenanceStatusIcon extends BaseComponent {
@@ -22,7 +22,7 @@ export class MaintenanceStatusIcon extends BaseComponent {
throw new Error('Status icon element initialized outside of the media box!');
}
on(this.#mediaBoxTools, eventMaintenanceStateChanged, this.#onMaintenanceStateChanged.bind(this));
on(this.#mediaBoxTools, EVENT_MAINTENANCE_STATE_CHANGED, this.#onMaintenanceStateChanged.bind(this));
}
#onMaintenanceStateChanged(stateChangeEvent: CustomEvent<string>) {

View File

@@ -2,7 +2,7 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { MaintenancePopup } from "$lib/components/MaintenancePopup";
import { on } from "$lib/components/events/comms";
import { eventActiveProfileChanged } from "$lib/components/events/maintenance-popup-events";
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxWrapper } from "$lib/components/MediaBoxWrapper";
import type MaintenanceProfile from "$entities/MaintenanceProfile";
@@ -39,7 +39,7 @@ export class MediaBoxTools extends BaseComponent {
}
}
on(this, eventActiveProfileChanged, this.#onActiveProfileChanged.bind(this));
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
}
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {

View File

@@ -2,7 +2,7 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
import { on } from "$lib/components/events/comms";
import { eventTagsUpdated } from "$lib/components/events/maintenance-popup-events";
import { EVENT_TAGS_UPDATED } from "$lib/components/events/maintenance-popup-events";
export class MediaBoxWrapper extends BaseComponent {
#thumbnailContainer: HTMLElement | null = null;
@@ -13,7 +13,7 @@ export class MediaBoxWrapper extends BaseComponent {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
on(this, eventTagsUpdated, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
@@ -90,5 +90,10 @@ export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf<HTMLElem
lastMediaBox = mediaBoxElement;
lastMediaBoxPosition = yPosition;
}
// Last-ever media box is checked separately
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
lastMediaBox.classList.add('media-box--last');
}
})
}

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

@@ -4,7 +4,9 @@ import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
import { getComponent } from "$lib/components/base/component-utils";
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
import { on } from "$lib/components/events/comms";
import { eventFormEditorUpdated } from "$lib/components/events/tags-form-events";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
import type TagGroup from "$entities/TagGroup";
const categoriesResolver = new CustomCategoriesResolver();
@@ -51,6 +53,23 @@ export class TagDropdownWrapper extends BaseComponent {
this.#updateButtons();
}
});
on(this, EVENT_TAG_GROUP_RESOLVED, this.#onTagGroupResolved.bind(this));
}
#onTagGroupResolved(resolvedGroupEvent: CustomEvent<TagGroup | null>) {
if (this.originalCategory) {
return;
}
const maybeTagGroup = resolvedGroupEvent.detail;
if (!maybeTagGroup) {
this.tagCategory = this.originalCategory;
return;
}
this.tagCategory = maybeTagGroup.settings.category;
}
get tagName() {
@@ -129,7 +148,13 @@ export class TagDropdownWrapper extends BaseComponent {
profileSpecificButtonText = `Remove from profile "${profileName}"`;
}
this.#toggleOnExistingButton.innerText = profileSpecificButtonText;
// Derpibooru has icons in dropdown. Make sure to only edit text and keep the icon untouched. Also, add the space
// before the text to make space between text and icon.
if (__CURRENT_SITE__ === 'derpibooru' && this.#toggleOnExistingButton.lastChild instanceof Text) {
this.#toggleOnExistingButton.lastChild.textContent = ` ${profileSpecificButtonText}`;
} else {
this.#toggleOnExistingButton.textContent = profileSpecificButtonText;
}
if (!this.#toggleOnExistingButton.isConnected) {
this.#dropdownContainer?.append(this.#toggleOnExistingButton);
@@ -188,7 +213,7 @@ export class TagDropdownWrapper extends BaseComponent {
* @param onActiveProfileChange Callback to call when profile was
* changed.
*/
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile|null) => void) {
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile | null) => void) {
let lastActiveProfile: string | null = null;
this.#maintenanceSettings.subscribe((settings) => {
@@ -224,9 +249,19 @@ export class TagDropdownWrapper extends BaseComponent {
static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement {
const dropdownLink = document.createElement('a');
dropdownLink.href = '#';
dropdownLink.innerText = text;
dropdownLink.className = 'tag__dropdown__link';
// Derpibooru has an icon in dropdown item. Create the icon and place the text with additional space in front of it.
if (__CURRENT_SITE__ === 'derpibooru') {
const dropdownLinkIcon = document.createElement('i');
dropdownLinkIcon.classList.add('fa', 'fa-tags');
dropdownLink.textContent = ` ${text}`;
dropdownLink.insertAdjacentElement('afterbegin', dropdownLinkIcon);
} else {
dropdownLink.textContent = text;
}
dropdownLink.addEventListener('click', event => {
event.preventDefault();
onClickHandler(event);
@@ -283,7 +318,7 @@ export function watchTagDropdownsInTagsEditor() {
});
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
on(document.body, eventFormEditorUpdated, event => {
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
for (const tagDropdownElement of event.detail.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}

View File

@@ -1,15 +1,15 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { emit, on, type UnsubscribeFunction } from "$lib/components/events/comms";
import { eventFetchComplete } from "$lib/components/events/booru-events";
import { eventFormEditorUpdated } from "$lib/components/events/tags-form-events";
import { EVENT_FETCH_COMPLETE } from "$lib/components/events/booru-events";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
export class TagsForm extends BaseComponent {
protected init() {
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
const unsubscribe = on(
this.container,
eventFetchComplete,
EVENT_FETCH_COMPLETE,
() => this.#waitAndDetectUpdatedForm(unsubscribe),
);
}
@@ -36,7 +36,7 @@ export class TagsForm extends BaseComponent {
const fullTagEditor = tagFormComponent.parentTagEditorElement;
if (fullTagEditor) {
emit(document.body, eventFormEditorUpdated, fullTagEditor);
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
} else {
console.info('Tag form is not in the tag editor. Event is not sent.');
}

View File

@@ -0,0 +1,244 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import type TagGroup from "$entities/TagGroup";
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
import { on } from "$lib/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
import { getComponent } from "$lib/components/base/component-utils";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
import TagSettings from "$lib/extension/settings/TagSettings";
export class TagsListBlock extends BaseComponent {
#tagsListButtonsContainer: HTMLElement | null = null;
#tagsListContainer: HTMLElement | null = null;
#toggleGroupingButton = document.createElement('a');
#toggleGroupingButtonIcon = document.createElement('i');
#tagSettings = new TagSettings();
#shouldDisplaySeparation = false;
#separatedGroups = new Map<string, TagGroup>();
#separatedHeaders = new Map<string, HTMLElement>();
#groupsCount = new Map<string, number>();
#lastTagGroup = new WeakMap<TagDropdownWrapper, TagGroup | null>;
#isReorderingPlanned = false;
protected build() {
this.#tagsListButtonsContainer = this.container.querySelector('.block.tagsauce .block__header__buttons');
this.#tagsListContainer = this.container.querySelector('.tag-list');
this.#toggleGroupingButton.innerText = ' Grouping';
this.#toggleGroupingButton.href = 'javascript:void(0)';
this.#toggleGroupingButton.classList.add('button', 'button--link', 'button--inline');
this.#toggleGroupingButton.title = 'Toggle the global groups separation option. This will only toggle global ' +
'setting without changing the separation of specific groups.';
this.#toggleGroupingButtonIcon.classList.add('fas', TagsListBlock.#iconGroupingDisabled);
this.#toggleGroupingButton.prepend(this.#toggleGroupingButtonIcon);
if (this.#tagsListButtonsContainer) {
this.#tagsListButtonsContainer.append(this.#toggleGroupingButton);
}
}
init() {
this.#tagSettings.resolveGroupSeparation().then(this.#onTagSeparationChange.bind(this));
this.#tagSettings.subscribe(settings => {
this.#onTagSeparationChange(Boolean(settings.groupSeparation))
});
on(
this,
EVENT_TAG_GROUP_RESOLVED,
this.#onTagDropdownCustomGroupResolved.bind(this)
);
this.#toggleGroupingButton.addEventListener('click', this.#onToggleGroupingClicked.bind(this));
}
#onTagSeparationChange(isSeparationEnabled: boolean) {
if (this.#shouldDisplaySeparation === isSeparationEnabled) {
return;
}
this.#shouldDisplaySeparation = isSeparationEnabled;
this.#reorderSeparatedGroups();
this.#updateToggleSeparationButton();
}
#updateToggleSeparationButton() {
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingEnabled, this.#shouldDisplaySeparation);
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingDisabled, !this.#shouldDisplaySeparation);
}
#onTagDropdownCustomGroupResolved(resolvedCustomGroupEvent: CustomEvent<TagGroup | null>) {
const maybeDropdownElement = resolvedCustomGroupEvent.target;
if (!(maybeDropdownElement instanceof HTMLElement)) {
return;
}
const tagDropdown = getComponent<TagDropdownWrapper>(maybeDropdownElement);
if (!tagDropdown) {
return;
}
const tagGroup = resolvedCustomGroupEvent.detail;
if (tagGroup) {
this.#handleTagGroupChanges(tagGroup);
}
this.#handleResolvedTagGroup(tagGroup, tagDropdown);
if (!this.#isReorderingPlanned) {
this.#isReorderingPlanned = true;
requestAnimationFrame(this.#reorderSeparatedGroups.bind(this));
}
}
#onToggleGroupingClicked(event: Event) {
event.preventDefault();
void this.#tagSettings.setGroupSeparation(!this.#shouldDisplaySeparation);
}
#handleTagGroupChanges(tagGroup: TagGroup) {
const groupId = tagGroup.id;
const processedGroup = this.#separatedGroups.get(groupId);
if (!tagGroup.settings.separate && processedGroup) {
this.#separatedGroups.delete(groupId);
this.#separatedHeaders.get(groupId)?.remove();
this.#separatedHeaders.delete(groupId);
return;
}
// Every time group is updated, a new object is being initialized
if (tagGroup !== processedGroup) {
this.#createOrUpdateHeaderForGroup(tagGroup);
this.#separatedGroups.set(groupId, tagGroup);
}
}
#createOrUpdateHeaderForGroup(group: TagGroup) {
let heading = this.#separatedHeaders.get(group.id);
if (!heading) {
heading = document.createElement('h2');
// Heading is hidden by default and shown on next frame if there are tags to show in the section.
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.
this.#tagsListContainer?.insertAdjacentElement('afterbegin', heading);
this.#separatedHeaders.set(group.id, heading);
}
heading.innerText = group.settings.name;
}
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdownWrapper) {
const previousGroupId = this.#lastTagGroup.get(tagComponent)?.id;
const currentGroupId = resolvedGroup?.id;
const isDifferentId = currentGroupId !== previousGroupId;
const isSeparationEnabled = resolvedGroup?.settings.separate;
if (isDifferentId) {
// Make sure to subtract the element from counters if there was a count before.
if (previousGroupId && this.#groupsCount.has(previousGroupId)) {
this.#groupsCount.set(previousGroupId, this.#groupsCount.get(previousGroupId)! - 1);
}
// We only need to count groups which have separation enabled.
if (currentGroupId && isSeparationEnabled) {
const count = this.#groupsCount.get(resolvedGroup.id) ?? 0;
this.#groupsCount.set(currentGroupId, count + 1);
}
}
// We're adding the CSS order for the tag as the CSS variable. This variable is updated later.
if (currentGroupId && isSeparationEnabled) {
tagComponent.container.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(currentGroupId)}, 0)`;
} else {
tagComponent.container.style.removeProperty('order');
}
// If separation is disabled in the new group, then we should remove the tag from map, so it can be recaptured
// when tag group is getting enabled later.
if (currentGroupId && !isSeparationEnabled) {
this.#lastTagGroup.delete(tagComponent);
return;
}
// Mark this tag component as related to the following group.
this.#lastTagGroup.set(tagComponent, resolvedGroup);
}
#reorderSeparatedGroups() {
this.#isReorderingPlanned = false;
const tagGroups = Array.from(this.#separatedGroups.values())
.toSorted((a, b) => a.settings.name.localeCompare(b.settings.name));
for (let index = 0; index < tagGroups.length; index++) {
const tagGroup = tagGroups[index];
const groupId = tagGroup.id;
const usedCount = this.#groupsCount.get(groupId);
const relatedHeading = this.#separatedHeaders.get(groupId);
if (this.#shouldDisplaySeparation) {
this.container.style.setProperty(TagsListBlock.#orderCssVariableForGroup(groupId), (index + 1).toString());
} else {
this.container.style.removeProperty(TagsListBlock.#orderCssVariableForGroup(groupId));
}
if (relatedHeading) {
if (!this.#shouldDisplaySeparation || !usedCount || usedCount <= 0) {
relatedHeading.style.display = 'none';
} else {
relatedHeading.style.removeProperty('display');
}
}
}
}
static #orderCssVariableForGroup(groupId: string): string {
return `--ta-order-${groupId}`;
}
static #iconGroupingDisabled = 'fa-folder';
static #iconGroupingEnabled = 'fa-folder-tree';
}
export function initializeAllTagsLists() {
for (let element of document.querySelectorAll<HTMLElement>('#image_tags_and_source')) {
if (getComponent(element)) {
return;
}
new TagsListBlock(element)
.initialize();
}
}
export function watchForUpdatedTagLists() {
on(document, EVENT_FORM_EDITOR_UPDATED, event => {
const tagsListElement = event.detail.closest<HTMLElement>('#image_tags_and_source');
if (!tagsListElement || getComponent(tagsListElement)) {
return;
}
new TagsListBlock(tagsListElement)
.initialize();
});
}

View File

@@ -1,6 +1,6 @@
import type { BaseComponent } from "$lib/components/base/BaseComponent";
const instanceSymbol = Symbol('instance');
const instanceSymbol = Symbol.for('instance');
interface ElementWithComponent<T> extends HTMLElement {
[instanceSymbol]?: T;

View File

@@ -1,5 +1,5 @@
export const eventFetchComplete = 'fetchcomplete';
export const EVENT_FETCH_COMPLETE = 'fetchcomplete';
export interface BooruEventsMap {
[eventFetchComplete]: null; // Site sends the response, but extension will not get it due to isolation.
[EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation.
}

View File

@@ -3,12 +3,14 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events";
import type { BooruEventsMap } from "$lib/components/events/booru-events";
import type { TagsFormEventsMap } from "$lib/components/events/tags-form-events";
import type { TagDropdownEvents } from "$lib/components/events/tag-dropdown-events";
type EventsMapping =
MaintenancePopupEventsMap
& FullscreenViewerEventsMap
& BooruEventsMap
& TagsFormEventsMap;
& TagsFormEventsMap
& TagDropdownEvents;
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
export type UnsubscribeFunction = () => void;

View File

@@ -1,7 +1,7 @@
import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
export const eventSizeLoaded = 'size-loaded';
export const EVENT_SIZE_LOADED = 'size-loaded';
export interface FullscreenViewerEventsMap {
[eventSizeLoaded]: FullscreenViewerSize;
[EVENT_SIZE_LOADED]: FullscreenViewerSize;
}

View File

@@ -1,13 +1,13 @@
import type MaintenanceProfile from "$entities/MaintenanceProfile";
export const eventActiveProfileChanged = 'active-profile-changed';
export const eventMaintenanceStateChanged = 'maintenance-state-change';
export const eventTagsUpdated = 'tags-updated';
export const EVENT_ACTIVE_PROFILE_CHANGED = 'active-profile-changed';
export const EVENT_MAINTENANCE_STATE_CHANGED = 'maintenance-state-change';
export const EVENT_TAGS_UPDATED = 'tags-updated';
type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting';
export interface MaintenancePopupEventsMap {
[eventActiveProfileChanged]: MaintenanceProfile | null;
[eventMaintenanceStateChanged]: MaintenanceState;
[eventTagsUpdated]: Map<string, string> | null;
[EVENT_ACTIVE_PROFILE_CHANGED]: MaintenanceProfile | null;
[EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState;
[EVENT_TAGS_UPDATED]: Map<string, string> | null;
}

View File

@@ -0,0 +1,7 @@
import type TagGroup from "$entities/TagGroup";
export const EVENT_TAG_GROUP_RESOLVED = 'tag-group-resolved';
export interface TagDropdownEvents {
[EVENT_TAG_GROUP_RESOLVED]: TagGroup | null;
}

View File

@@ -1,5 +1,5 @@
export const eventFormEditorUpdated = 'tags-form-updated';
export const EVENT_FORM_EDITOR_UPDATED = 'tags-form-updated';
export interface TagsFormEventsMap {
[eventFormEditorUpdated]: HTMLElement;
[EVENT_FORM_EDITOR_UPDATED]: HTMLElement;
}

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,12 +2,16 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag
export default class ConfigurationController {
readonly #configurationName: string;
readonly #storage: StorageHelper;
/**
* @param {string} configurationName Name of the configuration to work with.
* @param {StorageHelper} [storage] Selected storage where the settings are stored in. If not provided, local storage
* is used.
*/
constructor(configurationName: string) {
constructor(configurationName: string, storage: StorageHelper = new StorageHelper(chrome.storage.local)) {
this.#configurationName = configurationName;
this.#storage = storage;
}
/**
@@ -19,7 +23,7 @@ export default class ConfigurationController {
* @return The setting value or the default value if the setting does not exist.
*/
async readSetting<Type = any, DefaultType = any>(settingName: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
return settings[settingName] ?? defaultValue;
}
@@ -32,11 +36,11 @@ export default class ConfigurationController {
* @return {Promise<void>}
*/
async writeSetting(settingName: string, value: any): Promise<void> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
settings[settingName] = value;
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
this.#storage.write(this.#configurationName, settings);
}
/**
@@ -45,11 +49,11 @@ export default class ConfigurationController {
* @param {string} settingName Setting name to delete.
*/
async deleteSetting(settingName: string): Promise<void> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
delete settings[settingName];
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
this.#storage.write(this.#configurationName, settings);
}
/**
@@ -69,10 +73,8 @@ export default class ConfigurationController {
callback(changes[this.#configurationName].newValue);
}
ConfigurationController.#storageHelper.subscribe(subscriber);
this.#storage.subscribe(subscriber);
return () => ConfigurationController.#storageHelper.unsubscribe(subscriber);
return () => this.#storage.unsubscribe(subscriber);
}
static #storageHelper = new StorageHelper(chrome.storage.local);
}

View File

@@ -1,12 +1,14 @@
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
import TagGroup from "$entities/TagGroup";
import { escapeRegExp } from "$lib/utils";
import { emit } from "$lib/components/events/comms";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
export default class CustomCategoriesResolver {
#tagCategories = new Map<string, string>();
#compiledRegExps = new Map<RegExp, string>();
#exactGroupMatches = new Map<string, TagGroup>();
#regExpGroupMatches = new Map<RegExp, TagGroup>();
#tagDropdowns: TagDropdownWrapper[] = [];
#nextQueuedUpdate = -1;
#nextQueuedUpdate: Timeout | null = null;
constructor() {
TagGroup.subscribe(this.#onTagGroupsReceived.bind(this));
@@ -16,7 +18,7 @@ export default class CustomCategoriesResolver {
public addElement(tagDropdown: TagDropdownWrapper): void {
this.#tagDropdowns.push(tagDropdown);
if (!this.#tagCategories.size && !this.#compiledRegExps.size) {
if (!this.#exactGroupMatches.size && !this.#regExpGroupMatches.size) {
return;
}
@@ -24,7 +26,9 @@ export default class CustomCategoriesResolver {
}
#queueUpdatingTags() {
clearTimeout(this.#nextQueuedUpdate);
if (this.#nextQueuedUpdate) {
clearTimeout(this.#nextQueuedUpdate);
}
this.#nextQueuedUpdate = setTimeout(
this.#updateUnprocessedTags.bind(this),
@@ -34,7 +38,6 @@ export default class CustomCategoriesResolver {
#updateUnprocessedTags() {
this.#tagDropdowns
.filter(CustomCategoriesResolver.#skipTagsWithOriginalCategory)
.filter(this.#applyCustomCategoryForExactMatches.bind(this))
.filter(this.#matchCustomCategoryByRegExp.bind(this))
.forEach(CustomCategoriesResolver.#resetToOriginalCategory);
@@ -49,23 +52,33 @@ export default class CustomCategoriesResolver {
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
const tagName = tagDropdown.tagName!;
if (!this.#tagCategories.has(tagName)) {
if (!this.#exactGroupMatches.has(tagName)) {
return true;
}
tagDropdown.tagCategory = this.#tagCategories.get(tagName)!;
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
this.#exactGroupMatches.get(tagName)!
);
return false;
}
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
const tagName = tagDropdown.tagName!;
for (const targetRegularExpression of this.#compiledRegExps.keys()) {
for (const targetRegularExpression of this.#regExpGroupMatches.keys()) {
if (!targetRegularExpression.test(tagName)) {
continue;
}
tagDropdown.tagCategory = this.#compiledRegExps.get(targetRegularExpression)!;
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
this.#regExpGroupMatches.get(targetRegularExpression)!
);
return false;
}
@@ -73,24 +86,29 @@ export default class CustomCategoriesResolver {
}
#onTagGroupsReceived(tagGroups: TagGroup[]) {
this.#tagCategories.clear();
this.#compiledRegExps.clear();
this.#exactGroupMatches.clear();
this.#regExpGroupMatches.clear();
if (!tagGroups.length) {
return;
}
for (const tagGroup of tagGroups) {
const categoryName = tagGroup.settings.category;
for (const tagName of tagGroup.settings.tags) {
this.#tagCategories.set(tagName, categoryName);
this.#exactGroupMatches.set(tagName, tagGroup);
}
for (const tagPrefix of tagGroup.settings.prefixes) {
this.#compiledRegExps.set(
this.#regExpGroupMatches.set(
new RegExp(`^${escapeRegExp(tagPrefix)}`),
categoryName
tagGroup,
);
}
for (let tagSuffix of tagGroup.settings.suffixes) {
this.#regExpGroupMatches.set(
new RegExp(`${escapeRegExp(tagSuffix)}$`),
tagGroup,
);
}
}
@@ -98,12 +116,12 @@ export default class CustomCategoriesResolver {
this.#queueUpdatingTags();
}
static #skipTagsWithOriginalCategory(tagDropdown: TagDropdownWrapper): boolean {
return !tagDropdown.originalCategory;
}
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
tagDropdown.tagCategory = tagDropdown.originalCategory;
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
null,
);
}
static #unprocessedTagsTimeout = 0;

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

@@ -4,7 +4,9 @@ export interface TagGroupSettings {
name: string;
tags: string[];
prefixes: string[];
suffixes: string[];
category: string;
separate: boolean;
}
export default class TagGroup extends StorageEntity<TagGroupSettings> {
@@ -13,9 +15,11 @@ export default class TagGroup extends StorageEntity<TagGroupSettings> {
name: settings.name || '',
tags: settings.tags || [],
prefixes: settings.prefixes || [],
category: settings.category || ''
suffixes: settings.suffixes || [],
category: settings.category || '',
separate: Boolean(settings.separate),
});
}
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

@@ -0,0 +1,19 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
interface TagSettingsFields {
groupSeparation: boolean;
}
export default class TagSettings extends CacheableSettings<TagSettingsFields> {
constructor() {
super("tag");
}
async resolveGroupSeparation() {
return this._resolveSetting("groupSeparation", true);
}
async setGroupSeparation(value: boolean) {
return this._writeSetting("groupSeparation", Boolean(value));
}
}

View File

@@ -1,33 +1,48 @@
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,
prefixes: entity.settings.prefixes,
suffixes: entity.settings.suffixes,
category: entity.settings.category,
separate: entity.settings.separate,
}
}
};
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);
}

48
src/lib/popup-links.ts Normal file
View File

@@ -0,0 +1,48 @@
function resolveReplaceableLink(target: EventTarget | null = null): HTMLAnchorElement | null {
if (!(target instanceof HTMLElement)) {
return null;
}
const closestLink = target.closest('a');
if (
closestLink instanceof HTMLAnchorElement
&& !closestLink.search
&& closestLink.origin === location.origin
) {
return closestLink;
}
return null;
}
function replaceLink(linkElement: HTMLAnchorElement) {
const params = new URLSearchParams([
['path', linkElement.pathname]
]);
linkElement.search = params.toString();
linkElement.pathname = "/index.html";
}
export function initializeLinksReplacement(): () => void {
const abortController = new AbortController();
const replacementHandler = (event: Event) => {
const closestLink = resolveReplaceableLink(event.target);
if (closestLink) {
replaceLink(closestLink);
}
}
// Dynamically replace the links from the Svelte default links to the links usable for the popup.
document.body.addEventListener('mousedown', replacementHandler, {
signal: abortController.signal,
});
document.body.addEventListener('click', replacementHandler, {
signal: abortController.signal,
})
return () => abortController.abort();
}

View File

@@ -2,6 +2,8 @@
import "../styles/popup.scss";
import Header from "$components/layout/Header.svelte";
import Footer from "$components/layout/Footer.svelte";
import { initializeLinksReplacement } from "$lib/popup-links";
import { onDestroy } from "svelte";
interface Props {
children?: import('svelte').Snippet;
@@ -12,6 +14,12 @@
// Sort of a hack, detect if we rendered in the browser tab or in the popup.
// Popup will always should have fixed 320px size, otherwise we consider it opened in the tab.
document.body.classList.toggle('is-in-tab', window.innerWidth > 320);
const disconnectLinkReplacement = initializeLinksReplacement();
onDestroy(() => {
disconnectLinkReplacement();
})
</script>
<Header/>

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

@@ -11,6 +11,7 @@
import TagsEditor from "$components/tags/TagsEditor.svelte";
import TagGroup from "$entities/TagGroup";
import { tagGroups } from "$stores/entities/tag-groups";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
let groupId = $derived(page.params.id);
@@ -25,7 +26,9 @@
let groupName = $state<string>('');
let tagsList = $state<string[]>([]);
let prefixesList = $state<string[]>([]);
let tagCategory = $state<string>('');
let suffixesList = $state<string[]>([]);
let tagCategory = $state<string>('')
let separateGroup = $state<boolean>(false);
$effect(() => {
if (groupId === 'new') {
@@ -40,7 +43,9 @@
groupName = targetGroup.settings.name;
tagsList = [...targetGroup.settings.tags].sort((a, b) => a.localeCompare(b));
prefixesList = [...targetGroup.settings.prefixes].sort((a, b) => a.localeCompare(b));
suffixesList = [...targetGroup.settings.suffixes].sort((a, b) => a.localeCompare(b));
tagCategory = targetGroup.settings.category;
separateGroup = targetGroup.settings.separate;
});
async function saveGroup() {
@@ -52,21 +57,36 @@
targetGroup.settings.name = groupName;
targetGroup.settings.tags = [...tagsList];
targetGroup.settings.prefixes = [...prefixesList];
targetGroup.settings.suffixes = [...suffixesList];
targetGroup.settings.category = tagCategory;
targetGroup.settings.separate = separateGroup;
await targetGroup.save();
await goto(`/features/groups/${targetGroup.id}`);
}
function mapPrefixNames(tagName: string): string {
return `${tagName}*`;
}
function mapSuffixNames(tagName: string): string {
return `*${tagName}`;
}
</script>
<Menu>
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/groups/{groupId === 'new' ? '' : groupId}" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl label="Group Name">
<TextField bind:value={groupName} placeholder="Group Name"></TextField>
</FormControl>
<FormControl>
<CheckboxField bind:checked={separateGroup}>
Display tags found by this group in separate list after all other tags.
</CheckboxField>
</FormControl>
<FormControl label="Group Color">
<TagCategorySelectField bind:value={tagCategory}/>
</FormControl>
@@ -77,7 +97,12 @@
</TagsColorContainer>
<TagsColorContainer targetCategory={tagCategory}>
<FormControl label="Tag Prefixes">
<TagsEditor bind:tags={prefixesList}/>
<TagsEditor bind:tags={prefixesList} mapTagNames={mapPrefixNames}/>
</FormControl>
</TagsColorContainer>
<TagsColorContainer targetCategory={tagCategory}>
<FormControl label="Tag Suffixes">
<TagsEditor bind:tags={suffixesList} mapTagNames={mapSuffixNames}/>
</FormControl>
</TagsColorContainer>
</FormContainer>

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

@@ -5,6 +5,7 @@
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { stripBlacklistedTagsEnabled } from "$stores/preferences/maintenance";
import { shouldSeparateTagGroups } from "$stores/preferences/tag";
</script>
<Menu>
@@ -17,4 +18,9 @@
Automatically remove black-listed tags from the images
</CheckboxField>
</FormControl>
<FormControl>
<CheckboxField bind:checked={$shouldSeparateTagGroups}>
Enable separation of custom tag groups on the image pages
</CheckboxField>
</FormControl>
</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

@@ -0,0 +1,18 @@
import { writable } from "svelte/store";
import TagSettings from "$lib/extension/settings/TagSettings";
const tagSettings = new TagSettings();
export const shouldSeparateTagGroups = writable(false);
tagSettings.resolveGroupSeparation()
.then(value => shouldSeparateTagGroups.set(value))
.then(() => {
shouldSeparateTagGroups.subscribe(value => {
void tagSettings.setGroupSeparation(value);
});
tagSettings.subscribe(settings => {
shouldSeparateTagGroups.set(Boolean(settings.groupSeparation));
});
})

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,9 +67,17 @@
.tag {
cursor: pointer;
padding: 5px;
user-select: none;
// Derpibooru has slight differences in how tags are displayed.
@if environment.$current-site == 'derpibooru' {
padding: 0 5px;
gap: 0;
}
@else {
padding: 5px;
}
&:hover {
background: booru-vars.$resolved-tag-color;
color: booru-vars.$resolved-tag-background;

View File

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

View File

@@ -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,9 +1,6 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
function randomString() {
return crypto.randomUUID();
}
import { randomString } from "$tests/utils";
describe('BaseComponent', () => {
it('should bind the component to the element', () => {

View File

@@ -0,0 +1,186 @@
import ConfigurationController from "$lib/extension/ConfigurationController";
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
import StorageHelper from "$lib/browser/StorageHelper";
import { randomString } from "$tests/utils";
describe('ConfigurationController', () => {
const mockedStorageArea = new ChromeStorageArea();
const mockedStorageHelper = new StorageHelper(mockedStorageArea);
beforeEach(() => {
mockedStorageArea.clear();
});
it('should read setting from the field inside the configuration object', async () => {
const name = randomString();
const field = randomString();
const value = randomString();
mockedStorageArea.insertMockedData({
[name]: {
[field]: value
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
const returnedValue = await controller.readSetting(field);
expect(returnedValue).toBe(value);
});
it('should return fallback value if configuration field does not exist', async () => {
const controller = new ConfigurationController(randomString(), mockedStorageHelper);
const fallbackValue = randomString();
const returnedValue = await controller.readSetting(randomString(), fallbackValue);
expect(returnedValue).toBe(fallbackValue);
});
it('should treat existing falsy values as existing values', async () => {
const name = randomString();
const falsyValuesStorage = [0, false, ''].reduce((record, value) => {
record[randomString()] = value;
return record;
}, {} as Record<string, any>);
mockedStorageArea.insertMockedData({
[name]: falsyValuesStorage
});
const controller = new ConfigurationController(name, mockedStorageHelper);
for (const fieldName of Object.keys(falsyValuesStorage)) {
const returnedValue = await controller.readSetting(fieldName, randomString());
expect(returnedValue).toBe(falsyValuesStorage[fieldName]);
}
});
it('should write data to storage', async () => {
const name = randomString();
const field = randomString();
const value = randomString();
const controller = new ConfigurationController(name, mockedStorageHelper);
await controller.writeSetting(field, value);
const expectedStructure = {
[name]: {
[field]: value,
}
};
expect(mockedStorageArea.mockedData).toEqual(expectedStructure);
});
it('should update existing object without touching other entries', async () => {
const name = randomString();
const existingField = randomString();
const existingValue = randomString();
const addedField = randomString();
const addedValue = randomString();
mockedStorageArea.insertMockedData({
[name]: {
[existingField]: existingValue,
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
await controller.writeSetting(addedField, addedValue);
const expectedStructure = {
[name]: {
[existingField]: existingValue,
[addedField]: addedValue,
}
}
expect(mockedStorageArea.mockedData).toEqual(expectedStructure);
});
it('should delete setting from storage', async () => {
const name = randomString();
const field = randomString();
const value = randomString();
mockedStorageArea.insertMockedData({
[name]: {
[field]: value
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
await controller.deleteSetting(field);
expect(mockedStorageArea.mockedData).toEqual({
[name]: {},
});
});
it('should return updated settings contents on changes', async () => {
const name = randomString();
const initialField = randomString();
const initialValue = randomString();
const addedField = randomString();
const addedValue = randomString();
const updatedInitialValue = randomString();
const receivedData: Record<string, string>[] = [];
mockedStorageArea.insertMockedData({
[name]: {
[initialField]: initialValue,
}
});
const controller = new ConfigurationController(name, mockedStorageHelper);
const subscriber = vi.fn((storageState: Record<string, string>) => {
receivedData.push(JSON.parse(JSON.stringify(storageState)));
});
controller.subscribeToChanges(subscriber);
await controller.writeSetting(addedField, addedValue);
await controller.writeSetting(initialField, updatedInitialValue);
await controller.deleteSetting(initialField);
expect(subscriber).toBeCalledTimes(3);
const expectedData: Record<string, string>[] = [
// First, initial data and added field are present
{
[initialField]: initialValue,
[addedField]: addedValue,
},
// Then we get new value on initial field
{
[initialField]: updatedInitialValue,
[addedField]: addedValue,
},
// And then the initial value is dropped
{
[addedField]: addedValue,
}
];
expect(receivedData).toEqual(expectedData);
});
it('should stop listening once unsubscribe called', async () => {
const controller = new ConfigurationController(randomString(), mockedStorageHelper);
const subscriber = vi.fn();
const unsubscribe = controller.subscribeToChanges(subscriber);
await controller.writeSetting(randomString(), randomString());
expect(subscriber).toBeCalledTimes(1);
unsubscribe();
subscriber.mockReset();
await controller.writeSetting(randomString(), randomString())
expect(subscriber).not.toBeCalled();
});
});

View File

@@ -0,0 +1,75 @@
import { randomString } from "$tests/utils";
import { initializeLinksReplacement } from "$lib/popup-links";
describe('popup-links', () => {
let expectedPath = '';
let testLink: HTMLAnchorElement = document.createElement('a');
let disconnectCallback: (() => void) | null = null;
function fireEventAt(target: EventTarget, eventName: string) {
target.dispatchEvent(new Event(eventName, {bubbles: true}));
}
beforeEach(() => {
expectedPath = `/test/${randomString()}`;
testLink.href = expectedPath;
document.body.append(testLink);
});
afterEach(() => {
if (disconnectCallback) {
disconnectCallback();
disconnectCallback = null;
}
});
it('should replace link on any mouse button down', () => {
disconnectCallback = initializeLinksReplacement();
fireEventAt(testLink, "mousedown");
const resultUrl = new URL(testLink.href);
expect(resultUrl.searchParams.get('path')).toBe(expectedPath);
});
it('should replace link when link is pressed by keyboard or clicked', () => {
disconnectCallback = initializeLinksReplacement();
fireEventAt(testLink, "click");
const resultUrl = new URL(testLink.href);
expect(resultUrl.searchParams.get('path')).toBe(expectedPath);
});
it('should not replace already replaced links', () => {
disconnectCallback = initializeLinksReplacement();
fireEventAt(testLink, "click");
const hrefAfterFirstClick = testLink.href;
fireEventAt(testLink, "click");
const hrefAfterSecondClick = testLink.href;
expect(hrefAfterFirstClick).toBe(hrefAfterSecondClick);
});
it('should stop replacing links once disconnect is called', () => {
const hrefBefore = testLink.href;
disconnectCallback = initializeLinksReplacement();
disconnectCallback();
fireEventAt(testLink, "mousedown");
fireEventAt(testLink, "click");
expect(hrefBefore).toBe(testLink.href);
});
it('should not touch links with different origin', () => {
testLink.href = "https://external.example.com/" + randomString() + "/";
const hrefBefore = testLink.href;
disconnectCallback = initializeLinksReplacement();
fireEventAt(testLink, "click");
expect(testLink.href).toBe(hrefBefore);
});
});

View File

@@ -1,9 +1,9 @@
export default class ChromeEvent<T extends Function> implements chrome.events.Event<T> {
addListener = vi.fn();
getRules = vi.fn();
hasListener = vi.fn();
removeRules = vi.fn();
addRules = vi.fn();
removeListener = vi.fn();
hasListeners = vi.fn();
addListener = vi.fn();
getRules = vi.fn();
hasListener = vi.fn();
removeRules = vi.fn();
addRules = vi.fn();
removeListener = vi.fn();
hasListeners = vi.fn();
}

View File

@@ -1,5 +1,5 @@
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
export class ChromeLocalStorageArea extends ChromeStorageArea implements chrome.storage.LocalStorageArea {
QUOTA_BYTES = 100000;
QUOTA_BYTES = 100000;
}

View File

@@ -1,4 +1,4 @@
import ChromeEvent from "./ChromeEvent";
import ChromeStorageChangeEvent from "$tests/mocks/ChromeStorageChangeEvent";
type ChangedEventCallback = (changes: chrome.storage.StorageChange) => void
@@ -13,8 +13,20 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea {
})
});
set = vi.fn((...args: any[]): Promise<void> => {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
const change: Record<string, chrome.storage.StorageChange> = {};
const setter = args[0];
for (let targetKey of Object.keys(setter)) {
change[targetKey] = {
oldValue: this.#mockedData[targetKey] ?? undefined,
newValue: setter[targetKey],
};
}
this.#mockedData = Object.assign(this.#mockedData, args[0]);
this.onChanged.mockEmitStorageChange(change);
resolve();
})
});
@@ -23,7 +35,16 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea {
const key = args[0];
if (typeof key === 'string') {
const change: chrome.storage.StorageChange = {
oldValue: this.#mockedData[key],
};
delete this.#mockedData[key];
this.onChanged.mockEmitStorageChange({
[key]: change
});
resolve();
}
@@ -58,7 +79,7 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea {
});
});
setAccessLevel = vi.fn();
onChanged = new ChromeEvent<ChangedEventCallback>();
onChanged = new ChromeStorageChangeEvent();
getKeys = vi.fn();
insertMockedData(data: Record<string, any>) {

View File

@@ -0,0 +1,27 @@
import ChromeEvent from "$tests/mocks/ChromeEvent";
import { EventEmitter } from "node:events";
type MockedStorageChanges = Record<string, chrome.storage.StorageChange>;
type IncomingStorageChangeListener = (changes: MockedStorageChanges) => void;
const storageChangeEvent = Symbol();
interface StorageChangeEventMap {
[storageChangeEvent]: [MockedStorageChanges];
}
export default class ChromeStorageChangeEvent extends ChromeEvent<IncomingStorageChangeListener> {
#emitter = new EventEmitter<StorageChangeEventMap>();
addListener = vi.fn((actualListener: IncomingStorageChangeListener) => {
this.#emitter.addListener(storageChangeEvent, actualListener);
});
removeListener = vi.fn((actualListener: IncomingStorageChangeListener) => {
this.#emitter.removeListener(storageChangeEvent, actualListener);
});
mockEmitStorageChange(changes: MockedStorageChanges) {
this.#emitter.emit(storageChangeEvent, changes);
}
}

7
tests/utils.ts Normal file
View File

@@ -0,0 +1,7 @@
export function randomString(): string {
return crypto.randomUUID();
}
export function copyValue<T>(object: T): T {
return JSON.parse(JSON.stringify(object));
}

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