From 371bce133ebcaf13a482d673c57d9bac87dbb9fd Mon Sep 17 00:00:00 2001 From: KoloMl Date: Wed, 19 Feb 2025 03:07:03 +0400 Subject: [PATCH 01/42] Moved base types used in validators to separate module --- src/lib/extension/transporting/importables.ts | 22 +++++++++++++++++++ src/lib/extension/transporting/validators.ts | 22 +------------------ 2 files changed, 23 insertions(+), 21 deletions(-) create mode 100644 src/lib/extension/transporting/importables.ts diff --git a/src/lib/extension/transporting/importables.ts b/src/lib/extension/transporting/importables.ts new file mode 100644 index 0000000..5109593 --- /dev/null +++ b/src/lib/extension/transporting/importables.ts @@ -0,0 +1,22 @@ +import type StorageEntity from "$lib/extension/base/StorageEntity"; + +/** + * Base information on the object which should be present on every entity. + */ +export 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. + */ +export type ImportableObject = { [ObjectKey in keyof BaseImportableObject]: any } + & { [SettingKey in keyof EntityType["settings"]]: any }; diff --git a/src/lib/extension/transporting/validators.ts b/src/lib/extension/transporting/validators.ts index dbb1223..db2ea05 100644 --- a/src/lib/extension/transporting/validators.ts +++ b/src/lib/extension/transporting/validators.ts @@ -1,25 +1,5 @@ import type StorageEntity from "$lib/extension/base/StorageEntity"; - -/** - * Base information on the object which should be present on every entity. - */ -interface BaseImportableObject { - /** - * Numeric version of the entity for upgrading. - */ - v: number; - /** - * Unique ID of the entity. - */ - id: string; -} - -/** - * Utility type which combines base importable object and the entity type interfaces together. It strips away any types - * defined for the properties, since imported object can not be trusted and should be type-checked by the validators. - */ -type ImportableObject = { [ObjectKey in keyof BaseImportableObject]: any } - & { [SettingKey in keyof EntityType["settings"]]: any }; +import type { ImportableObject } from "$lib/extension/transporting/importables"; /** * Function for validating the entities. From 07373e17d51dd48ac0d7178518c50116ad67d923 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Wed, 19 Feb 2025 03:07:52 +0400 Subject: [PATCH 02/42] Updated exporters to use importable types for more type safety --- src/lib/extension/EntitiesTransporter.ts | 4 ++-- src/lib/extension/transporting/exporters.ts | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/lib/extension/EntitiesTransporter.ts b/src/lib/extension/EntitiesTransporter.ts index 8e258e2..ccbbd74 100644 --- a/src/lib/extension/EntitiesTransporter.ts +++ b/src/lib/extension/EntitiesTransporter.ts @@ -60,8 +60,8 @@ export default class EntitiesTransporter { } const exportableObject = exportEntityToObject( - entityObject, - this.#entityName + this.#entityName as keyof App.EntityNamesMap, + entityObject ); return JSON.stringify(exportableObject, null, 2); diff --git a/src/lib/extension/transporting/exporters.ts b/src/lib/extension/transporting/exporters.ts index c376c2a..21a78e8 100644 --- a/src/lib/extension/transporting/exporters.ts +++ b/src/lib/extension/transporting/exporters.ts @@ -1,8 +1,11 @@ import StorageEntity from "$lib/extension/base/StorageEntity"; +import type { ImportableObject } from "$lib/extension/transporting/importables"; + +type ExporterFunction = (entity: EntityType) => ImportableObject; type ExportersMap = { - [EntityName in keyof App.EntityNamesMap]: (entity: App.EntityNamesMap[EntityName]) => Record -}; + [EntityName in keyof App.EntityNamesMap]: ExporterFunction; +} const entitiesExporters: ExportersMap = { profiles: entity => { @@ -24,10 +27,13 @@ const entitiesExporters: ExportersMap = { } }; -export function exportEntityToObject(entityInstance: StorageEntity, entityName: string): Record { +export function exportEntityToObject( + entityName: EntityName, + entityInstance: App.EntityNamesMap[EntityName] +): ImportableObject { 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); } From 62dc38b35a8cd85ab52e548fb5852bbe60bbe5cc Mon Sep 17 00:00:00 2001 From: KoloMl Date: Wed, 19 Feb 2025 03:08:54 +0400 Subject: [PATCH 03/42] Fixed exporters not saving categories for groups and temporary flags for profiles --- src/lib/extension/transporting/exporters.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/extension/transporting/exporters.ts b/src/lib/extension/transporting/exporters.ts index 21a78e8..16b173b 100644 --- a/src/lib/extension/transporting/exporters.ts +++ b/src/lib/extension/transporting/exporters.ts @@ -14,6 +14,8 @@ const entitiesExporters: ExportersMap = { id: entity.id, name: entity.settings.name, tags: entity.settings.tags, + // Any exported profile should be considered non-temporary. + temporary: false, } }, groups: entity => { @@ -23,6 +25,7 @@ const entitiesExporters: ExportersMap = { name: entity.settings.name, tags: entity.settings.tags, prefixes: entity.settings.prefixes, + category: entity.settings.category, } } }; From 459b1fa7793faa1fd439e73e30c8492bfef5fd61 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Wed, 19 Feb 2025 03:17:52 +0400 Subject: [PATCH 04/42] Marking entity name as key of the mapping for type safety --- src/lib/extension/base/StorageEntity.ts | 2 +- src/lib/extension/entities/MaintenanceProfile.ts | 2 +- src/lib/extension/entities/TagGroup.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/extension/base/StorageEntity.ts b/src/lib/extension/base/StorageEntity.ts index 88be756..4b84fe1 100644 --- a/src/lib/extension/base/StorageEntity.ts +++ b/src/lib/extension/base/StorageEntity.ts @@ -24,7 +24,7 @@ export default abstract class StorageEntity { return this.#settings; } - public static readonly _entityName: string = "entity"; + public static readonly _entityName: keyof App.EntityNamesMap | "entity" = "entity"; async save() { await EntitiesController.updateEntity( diff --git a/src/lib/extension/entities/MaintenanceProfile.ts b/src/lib/extension/entities/MaintenanceProfile.ts index d9509c5..fd2dc82 100644 --- a/src/lib/extension/entities/MaintenanceProfile.ts +++ b/src/lib/extension/entities/MaintenanceProfile.ts @@ -30,5 +30,5 @@ export default class MaintenanceProfile extends StorageEntity { }); } - static _entityName = 'groups'; + public static readonly _entityName: keyof App.EntityNamesMap = "groups"; } From c93c3c7bd503c728f63bcb5fc964aa2ce7d5146e Mon Sep 17 00:00:00 2001 From: KoloMl Date: Wed, 19 Feb 2025 03:19:01 +0400 Subject: [PATCH 05/42] Swapped validator arguments to meet the order on exporter function, fixing types --- src/lib/extension/EntitiesTransporter.ts | 12 +++++++++--- src/lib/extension/transporting/validators.ts | 9 ++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/lib/extension/EntitiesTransporter.ts b/src/lib/extension/EntitiesTransporter.ts index ccbbd74..8faf18b 100644 --- a/src/lib/extension/EntitiesTransporter.ts +++ b/src/lib/extension/EntitiesTransporter.ts @@ -12,7 +12,13 @@ export default class EntitiesTransporter { */ 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; } /** @@ -34,8 +40,8 @@ export default class EntitiesTransporter { } validateImportedEntity( + this.#entityName, importedObject, - this.#entityName ); return new this.#targetEntityConstructor( @@ -60,7 +66,7 @@ export default class EntitiesTransporter { } const exportableObject = exportEntityToObject( - this.#entityName as keyof App.EntityNamesMap, + this.#entityName, entityObject ); diff --git a/src/lib/extension/transporting/validators.ts b/src/lib/extension/transporting/validators.ts index db2ea05..7b8283a 100644 --- a/src/lib/extension/transporting/validators.ts +++ b/src/lib/extension/transporting/validators.ts @@ -40,15 +40,18 @@ const entitiesValidators: EntitiesValidationMap = { /** * Validate the structure of the entity. - * @param importedObject Object imported from JSON. * @param entityName Name of the entity to validate. Should be loaded from the entity class. + * @param importedObject Object imported from JSON. * @throws {Error} Error in case validation failed with the reason stored in the message. */ -export function validateImportedEntity(importedObject: any, entityName: string) { +export function validateImportedEntity( + entityName: EntityName, + importedObject: any +) { if (!entitiesValidators.hasOwnProperty(entityName)) { console.error(`Trying to validate entity without the validator present! Entity name: ${entityName}`); return; } - entitiesValidators[entityName as keyof EntitiesValidationMap]!.call(null, importedObject); + entitiesValidators[entityName]!.call(null, importedObject); } From 5dc41700b86df18a25259a3b75936a8a9b265992 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Wed, 19 Feb 2025 03:42:20 +0400 Subject: [PATCH 06/42] Introducing lists of importable entities, renaming some interfaces --- src/lib/extension/transporting/exporters.ts | 12 +++++++----- src/lib/extension/transporting/importables.ts | 18 ++++++++++++++++-- src/lib/extension/transporting/validators.ts | 4 ++-- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/lib/extension/transporting/exporters.ts b/src/lib/extension/transporting/exporters.ts index 16b173b..7e20f19 100644 --- a/src/lib/extension/transporting/exporters.ts +++ b/src/lib/extension/transporting/exporters.ts @@ -1,7 +1,7 @@ import StorageEntity from "$lib/extension/base/StorageEntity"; -import type { ImportableObject } from "$lib/extension/transporting/importables"; +import type { ImportableEntityObject } from "$lib/extension/transporting/importables"; -type ExporterFunction = (entity: EntityType) => ImportableObject; +type ExporterFunction = (entity: EntityType) => ImportableEntityObject; type ExportersMap = { [EntityName in keyof App.EntityNamesMap]: ExporterFunction; @@ -10,7 +10,8 @@ type ExportersMap = { const entitiesExporters: ExportersMap = { profiles: entity => { return { - v: 1, + $type: "profiles", + v: 2, id: entity.id, name: entity.settings.name, tags: entity.settings.tags, @@ -20,7 +21,8 @@ const entitiesExporters: ExportersMap = { }, groups: entity => { return { - v: 1, + $type: "groups", + v: 2, id: entity.id, name: entity.settings.name, tags: entity.settings.tags, @@ -33,7 +35,7 @@ const entitiesExporters: ExportersMap = { export function exportEntityToObject( entityName: EntityName, entityInstance: App.EntityNamesMap[EntityName] -): ImportableObject { +): ImportableEntityObject { if (!(entityName in entitiesExporters) || !entitiesExporters.hasOwnProperty(entityName)) { throw new Error(`Missing exporter for entity: ${entityName}`); } diff --git a/src/lib/extension/transporting/importables.ts b/src/lib/extension/transporting/importables.ts index 5109593..e60dca9 100644 --- a/src/lib/extension/transporting/importables.ts +++ b/src/lib/extension/transporting/importables.ts @@ -1,9 +1,23 @@ import type StorageEntity from "$lib/extension/base/StorageEntity"; +export interface ImportableElement { + /** + * Type of importable. Should be unique to properly import everything. + */ + $type: Type; +} + +export interface ImportableElementsList extends ImportableElement<"list"> { + /** + * List of elements inside. Elements could be of any type and should be checked and mapped. + */ + elements: ElementsType[]; +} + /** * Base information on the object which should be present on every entity. */ -export interface BaseImportableObject { +export interface BaseImportableEntity extends ImportableElement { /** * Numeric version of the entity for upgrading. */ @@ -18,5 +32,5 @@ export interface BaseImportableObject { * 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 ImportableObject = { [ObjectKey in keyof BaseImportableObject]: any } +export type ImportableEntityObject = { [ObjectKey in keyof BaseImportableEntity]: any } & { [SettingKey in keyof EntityType["settings"]]: any }; diff --git a/src/lib/extension/transporting/validators.ts b/src/lib/extension/transporting/validators.ts index 7b8283a..544029f 100644 --- a/src/lib/extension/transporting/validators.ts +++ b/src/lib/extension/transporting/validators.ts @@ -1,12 +1,12 @@ import type StorageEntity from "$lib/extension/base/StorageEntity"; -import type { ImportableObject } from "$lib/extension/transporting/importables"; +import type { ImportableEntityObject } from "$lib/extension/transporting/importables"; /** * Function for validating the entities. * @todo Probably would be better to replace the throw-catch method with some kind of result-error returning type. * Errors are only properly definable in the JSDoc. */ -type ValidationFunction = (importedObject: ImportableObject) => void; +type ValidationFunction = (importedObject: ImportableEntityObject) => void; /** * Mapping of validation functions for all entities present in the extension. Key is a name of entity and value is a From f67a321a669958b823241a8e32afec60de7956eb Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 20 Feb 2025 20:32:18 +0400 Subject: [PATCH 07/42] Extracted method to import directly from object --- src/lib/extension/EntitiesTransporter.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/lib/extension/EntitiesTransporter.ts b/src/lib/extension/EntitiesTransporter.ts index 8faf18b..2b26acd 100644 --- a/src/lib/extension/EntitiesTransporter.ts +++ b/src/lib/extension/EntitiesTransporter.ts @@ -32,13 +32,7 @@ export default class EntitiesTransporter { this.#targetEntityConstructor = entityConstructor; } - importFromJSON(jsonString: string): EntityType { - const importedObject = this.#tryParsingAsJSON(jsonString); - - if (!importedObject) { - throw new Error('Invalid JSON!'); - } - + importFromObject(importedObject: Record): EntityType { validateImportedEntity( this.#entityName, importedObject, @@ -50,6 +44,16 @@ export default class EntitiesTransporter { ); } + importFromJSON(jsonString: string): EntityType { + const importedObject = this.#tryParsingAsJSON(jsonString); + + if (!importedObject) { + throw new Error('Invalid JSON!'); + } + + return this.importFromObject(importedObject); + } + importFromCompressedJSON(compressedJsonString: string): EntityType { return this.importFromJSON( decompressFromEncodedURIComponent(compressedJsonString) From 9d7f5c0f38546a18fa95f64b7cdb280d485bae45 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 20 Feb 2025 20:36:34 +0400 Subject: [PATCH 08/42] Initial implementation of bulk transporter with import methods --- src/lib/extension/BulkEntitiesTransporter.ts | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/lib/extension/BulkEntitiesTransporter.ts diff --git a/src/lib/extension/BulkEntitiesTransporter.ts b/src/lib/extension/BulkEntitiesTransporter.ts new file mode 100644 index 0000000..762bf7b --- /dev/null +++ b/src/lib/extension/BulkEntitiesTransporter.ts @@ -0,0 +1,56 @@ +import type StorageEntity from "$lib/extension/base/StorageEntity"; +import { decompressFromEncodedURIComponent } from "lz-string"; +import type { ImportableElementsList, ImportableEntityObject } from "$lib/extension/transporting/importables"; +import EntitiesTransporter from "$lib/extension/EntitiesTransporter"; +import MaintenanceProfile from "$entities/MaintenanceProfile"; +import TagGroup from "$entities/TagGroup"; + +type TransportersMapping = { + [EntityName in keyof App.EntityNamesMap]: EntitiesTransporter; +} + +export default class BulkEntitiesTransporter { + parseAndImportFromJSON(jsonString: string): StorageEntity[] { + let parsedObject: any; + + 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!'); + } + + return 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]; + return transporter.importFromObject(importableObject); + }) + .filter(maybeEntity => !!maybeEntity); + } + + parseAndImportFromCompressedJSON(compressedJsonString: string): StorageEntity[] { + return this.parseAndImportFromJSON( + decompressFromEncodedURIComponent(compressedJsonString) + ); + } + + static isList(targetObject: any): targetObject is ImportableElementsList> { + return targetObject.$type + && targetObject.$type === 'list' + && targetObject.elements + && Array.isArray(targetObject.elements); + } + + static #transporters: TransportersMapping = { + profiles: new EntitiesTransporter(MaintenanceProfile), + groups: new EntitiesTransporter(TagGroup), + } +} From bdbe49b419a99a6240012d49d233e94272591b42 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 6 Apr 2025 15:08:14 +0400 Subject: [PATCH 09/42] Installed `amd-lite` 1.0.1 --- package-lock.json | 7 +++++++ package.json | 1 + 2 files changed, 8 insertions(+) diff --git a/package-lock.json b/package-lock.json index 1d7d0f1..fbb7b26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.4.4", "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", + "amd-lite": "^1.0.1", "lz-string": "^1.5.0" }, "devDependencies": { @@ -1388,6 +1389,12 @@ "node": ">= 14" } }, + "node_modules/amd-lite": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amd-lite/-/amd-lite-1.0.1.tgz", + "integrity": "sha512-2EqBXjF5YjgCHeb4hfhUx3iIiN/vmuXjXdT1QVDXylwH5c2oqEvGPDmyVo9yGRGEQlGRdwa9gJ68/m32yUnriw==", + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", diff --git a/package.json b/package.json index 93addac..dbc9211 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "type": "module", "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", + "amd-lite": "^1.0.1", "lz-string": "^1.5.0" } } From 7cf2730402f8ceacea5c3554d7d93805330a8bff Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 6 Apr 2025 15:12:12 +0400 Subject: [PATCH 10/42] 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. --- .vite/lib/content-scripts.js | 168 ++++++++++++++++++++++++++++------- .vite/lib/manifest.js | 32 +++++++ .vite/pack-extension.js | 82 ++++++++++------- 3 files changed, 218 insertions(+), 64 deletions(-) diff --git a/.vite/lib/content-scripts.js b/.vite/lib/content-scripts.js index 34f8653..780b612 100644 --- a/.vite/lib/content-scripts.js +++ b/.vite/lib/content-scripts.js @@ -1,5 +1,5 @@ -import {build} from "vite"; -import {createHash} from "crypto"; +import { build } from "vite"; +import { createHash } from "crypto"; import path from "path"; import fs from "fs"; @@ -56,69 +56,167 @@ function makeAliases(rootDir) { } /** - * Build the selected script separately. - * @param {AssetBuildOptions} buildOptions Building options for the script. - * @return {Promise} Result file path. + * @param {import('rollup').OutputChunk} chunk + * @param {import('rollup').OutputBundle} bundle + * @param {Set} processedChunks + * @return string[] */ -export async function buildScript(buildOptions) { - const outputBaseName = createOutputBaseName(buildOptions.input); +function collectChunkDependencies(chunk, bundle, processedChunks = new Set()) { + if (processedChunks.has(chunk) || !chunk.imports) { + return []; + } + processedChunks.add(chunk); + + return chunk.imports.concat( + chunk.imports + .map(importedChunkName => { + const module = bundle[importedChunkName]; + + if (module.type === 'chunk') { + return collectChunkDependencies(module, bundle, processedChunks); + } + + return []; + }) + .flat() + ); +} + +/** + * @param {(fileName: string, dependencies: string[]) => void} onDependencyResolvedCallback + * @returns {import('vite').Plugin} + */ +function collectDependenciesForManifestBuilding(onDependencyResolvedCallback) { + return { + name: 'extract-dependencies-for-content-scripts', + enforce: "post", + /** + * @param {any} options + * @param {import('rollup').OutputBundle} bundle + */ + writeBundle(options, bundle) { + Object.keys(bundle).forEach(fileName => { + const chunk = bundle[fileName]; + + if (chunk.type !== "chunk" || !chunk.facadeModuleId) { + return; + } + + const dependencies = Array.from( + new Set( + collectChunkDependencies(chunk, bundle) + ) + ); + + onDependencyResolvedCallback(fileName, dependencies); + }); + } + } +} + +/** + * Second revision of the building logic for the content scripts. This method tries to address duplication of + * dependencies generated with the previous method, where every single content script was built separately. + * @param {BatchBuildOptions} buildOptions + * @returns {Promise>} + */ +export async function buildScriptsAndStyles(buildOptions) { + /** @type {Map} */ + const pathsReplacement = new Map(); + /** @type {Map} */ + const pathsReplacementByOutputPath = new Map(); + + const amdScriptsInput = {}; + const libsAndStylesInput = {}; + + for (const inputPath of buildOptions.inputs) { + let outputExtension = path.extname(inputPath); + + if (outputExtension === '.scss') { + outputExtension = '.css'; + } + + if (outputExtension === '.ts') { + outputExtension = '.js'; + } + + const outputPath = createOutputBaseName(inputPath); + const replacementsArray = [`${outputPath}${outputExtension}`]; + + pathsReplacement.set(inputPath, replacementsArray); + + if (outputExtension === '.css' || inputPath.includes('/deps/')) { + libsAndStylesInput[outputPath] = inputPath; + continue; + } + + pathsReplacementByOutputPath.set(outputPath + '.js', replacementsArray); + + amdScriptsInput[outputPath] = inputPath; + } + + const aliasesSettings = makeAliases(buildOptions.rootDir); + + // 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, + } } }, emptyOutDir: false, }, resolve: { - alias: makeAliases(buildOptions.rootDir) + alias: aliasesSettings, }, plugins: [ - wrapScriptIntoIIFE() + wrapScriptIntoIIFE(), + collectDependenciesForManifestBuilding((fileName, dependencies) => { + pathsReplacementByOutputPath + .get(fileName) + ?.push(...dependencies); + }), ] }); - return path.resolve(buildOptions.outputDir, `${outputBaseName}.js`); -} - -/** - * Build the selected stylesheet. - * @param {AssetBuildOptions} buildOptions Build options for the stylesheet. - * @return {Promise} Result file path. - */ -export async function buildStyle(buildOptions) { - const outputBaseName = createOutputBaseName(buildOptions.input); - + // Build styles separately because AMD converts styles to JS files. await build({ configFile: false, publicDir: false, build: { rollupOptions: { - input: { - [outputBaseName]: buildOptions.input - }, + input: libsAndStylesInput, output: { dir: buildOptions.outputDir, entryFileNames: '[name].js', assetFileNames: '[name].[ext]', } }, - emptyOutDir: false, + emptyOutDir: false }, resolve: { - alias: makeAliases(buildOptions.rootDir) - } + alias: aliasesSettings, + }, + plugins: [ + wrapScriptIntoIIFE(), + ] }); - return path.resolve(buildOptions.outputDir, `${outputBaseName}.css`); + return pathsReplacement; } /** @@ -127,3 +225,11 @@ export async function buildStyle(buildOptions) { * @property {string} outputDir Destination folder for the script. * @property {string} rootDir Root directory of the repository. */ + +/** + * @typedef {Object} BatchBuildOptions + * @property {Set} inputs Set of all scripts and styles to build. + * @property {string} outputDir Destination folder for the assets. + * @property {string} rootDir Root directory of the repository. + * @property {(fileName: string, dependencies: string[]) => void} onDependenciesResolved Callback for dependencies. + */ diff --git a/.vite/lib/manifest.js b/.vite/lib/manifest.js index 817b5e7..dffef10 100644 --- a/.vite/lib/manifest.js +++ b/.vite/lib/manifest.js @@ -17,6 +17,38 @@ class ManifestProcessor { this.#manifestObject = parsedManifest; } + /** + * Collect all the content scripts & stylesheets for single build action. + * + * @returns {Set} + */ + collectContentScripts() { + const contentScripts = this.#manifestObject.content_scripts; + + if (!contentScripts) { + console.info('No content scripts to collect.'); + return new Set(); + } + + const entryPoints = new Set(); + + for (let entry of contentScripts) { + if (entry.js) { + for (let jsPath of entry.js) { + entryPoints.add(jsPath); + } + } + + if (entry.css) { + for (let cssPath of entry.css) { + entryPoints.add(cssPath); + } + } + } + + return entryPoints; + } + /** * Map over every content script defined in the manifest. If no content scripts defined, no calls will be made to the * callback. diff --git a/.vite/pack-extension.js b/.vite/pack-extension.js index c4d7d91..7b4d0ea 100644 --- a/.vite/pack-extension.js +++ b/.vite/pack-extension.js @@ -1,8 +1,8 @@ -import {loadManifest} from "./lib/manifest.js"; +import { loadManifest } from "./lib/manifest.js"; import path from "path"; -import {buildScript, buildStyle} from "./lib/content-scripts.js"; -import {normalizePath} from "vite"; -import {extractInlineScriptsFromIndex} from "./lib/index-file.js"; +import { buildScriptsAndStyles } from "./lib/content-scripts.js"; +import { extractInlineScriptsFromIndex } from "./lib/index-file.js"; +import { normalizePath } from "vite"; /** * Build addition assets required for the extension and pack it into the directory. @@ -11,45 +11,61 @@ 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; - }); + }) manifest.passVersionFromPackage(path.resolve(settings.rootDir, 'package.json')); manifest.saveTo(path.resolve(settings.exportDir, 'manifest.json')); From 966100d606c2d4007d66d328cae06d4ac5130446 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 6 Apr 2025 15:13:31 +0400 Subject: [PATCH 11/42] Added AMD loader initialization as separate entry, autoload everything --- manifest.json | 8 ++++++++ src/content/deps/amd.ts | 22 ++++++++++++++++++++++ src/types/amd-lite.d.ts | 23 +++++++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 src/content/deps/amd.ts create mode 100644 src/types/amd-lite.d.ts diff --git a/manifest.json b/manifest.json index 59bfd2d..34d55d7 100644 --- a/manifest.json +++ b/manifest.json @@ -18,6 +18,14 @@ "*://*.furbooru.org/" ], "content_scripts": [ + { + "matches": [ + "*://*.furbooru.org/*" + ], + "js": [ + "src/content/deps/amd.ts" + ] + }, { "matches": [ "*://*.furbooru.org/", diff --git a/src/content/deps/amd.ts b/src/content/deps/amd.ts new file mode 100644 index 0000000..9f4f525 --- /dev/null +++ b/src/content/deps/amd.ts @@ -0,0 +1,22 @@ +import { amdLite } from "amd-lite"; + +const originalDefine = amdLite.define; + +amdLite.define = (name, dependencies, originalCallback) => { + return originalDefine(name, dependencies, function () { + const callbackResult = originalCallback(...arguments); + + // Workaround for the entry script not returning anything causing AMD-Lite to send warning about modules not + // being loaded/not existing. + return typeof callbackResult !== 'undefined' ? callbackResult : {}; + }) +} + +amdLite.init({ + publicScope: window +}); + +// We don't have anything asynchronous, so it's safe to execute everything on the next frame. +requestAnimationFrame(() => { + amdLite.resolveDependencies(Object.keys(amdLite.waitingModules)) +}); diff --git a/src/types/amd-lite.d.ts b/src/types/amd-lite.d.ts new file mode 100644 index 0000000..9549e1e --- /dev/null +++ b/src/types/amd-lite.d.ts @@ -0,0 +1,23 @@ +// Types for the small untyped AMD loader. These types do not cover all the functions available in the package, only +// parts required for content scripts in extension to work. +declare module 'amd-lite' { + interface AMDLiteInitOptions { + publicScope: any; + verbosity: number; + } + + interface AMDLite { + waitingModules: Record; + readyModules: Record; + + init(options: Partial): void; + + define(name: string, dependencies: string[], callback: function): void; + + resolveDependency(dependencyPath: string); + + resolveDependencies(dependencyNames: string[], from?: string); + } + + export const amdLite: AMDLite; +} From 9c19bd70c26b5b34308ad3c3474eb16584545333 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Wed, 9 Apr 2025 00:48:40 +0400 Subject: [PATCH 12/42] Enable minification of exports for content scripts --- .vite/lib/content-scripts.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.vite/lib/content-scripts.js b/.vite/lib/content-scripts.js index 780b612..f9385ca 100644 --- a/.vite/lib/content-scripts.js +++ b/.vite/lib/content-scripts.js @@ -175,7 +175,9 @@ export async function buildScriptsAndStyles(buildOptions) { 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, From 2453bdf7b9d265e790de9c77cc7d29372e5794ad Mon Sep 17 00:00:00 2001 From: KoloMl Date: Tue, 3 Jun 2025 02:22:22 +0400 Subject: [PATCH 13/42] 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. --- manifest.json | 11 - src/content/header.ts | 7 - src/lib/components/SearchWrapper.ts | 429 ------------------- src/lib/components/SiteHeaderWrapper.ts | 22 - src/lib/extension/settings/SearchSettings.ts | 30 -- src/routes/preferences/+page.svelte | 1 - src/routes/preferences/search/+page.svelte | 32 -- src/stores/preferences/search.ts | 28 -- src/styles/content/header.scss | 9 - 9 files changed, 569 deletions(-) delete mode 100644 src/content/header.ts delete mode 100644 src/lib/components/SearchWrapper.ts delete mode 100644 src/lib/components/SiteHeaderWrapper.ts delete mode 100644 src/lib/extension/settings/SearchSettings.ts delete mode 100644 src/routes/preferences/search/+page.svelte delete mode 100644 src/stores/preferences/search.ts delete mode 100644 src/styles/content/header.scss diff --git a/manifest.json b/manifest.json index 59bfd2d..29402d2 100644 --- a/manifest.json +++ b/manifest.json @@ -33,17 +33,6 @@ "src/styles/content/listing.scss" ] }, - { - "matches": [ - "*://*.furbooru.org/*" - ], - "js": [ - "src/content/header.ts" - ], - "css": [ - "src/styles/content/header.scss" - ] - }, { "matches": [ "*://*.furbooru.org/images/*" diff --git a/src/content/header.ts b/src/content/header.ts deleted file mode 100644 index a306a89..0000000 --- a/src/content/header.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { initializeSiteHeader } from "$lib/components/SiteHeaderWrapper"; - -const siteHeader = document.querySelector('.header'); - -if (siteHeader) { - initializeSiteHeader(siteHeader); -} diff --git a/src/lib/components/SearchWrapper.ts b/src/lib/components/SearchWrapper.ts deleted file mode 100644 index aeebdde..0000000 --- a/src/lib/components/SearchWrapper.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { BaseComponent } from "$lib/components/base/BaseComponent"; -import { QueryLexer, QuotedTermToken, TermToken, Token } from "$lib/booru/search/QueryLexer"; -import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings"; - -export class SearchWrapper extends BaseComponent { - #searchField: HTMLInputElement | null = null; - #lastParsedSearchValue: string | null = null; - #cachedParsedQuery: Token[] = []; - #searchSettings: SearchSettings = new SearchSettings(); - #arePropertiesSuggestionsEnabled: boolean = false; - #propertiesSuggestionsPosition: SuggestionsPosition = "start"; - #cachedAutocompleteContainer: HTMLElement | null = null; - #lastTermToken: TermToken | QuotedTermToken | null = null; - - build() { - this.#searchField = this.container.querySelector('input[name=q]'); - } - - init() { - if (this.#searchField) { - this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this)) - } - - this.#searchSettings.resolvePropertiesSuggestionsEnabled() - .then(isEnabled => this.#arePropertiesSuggestionsEnabled = isEnabled); - this.#searchSettings.resolvePropertiesSuggestionsPosition() - .then(position => this.#propertiesSuggestionsPosition = position); - - this.#searchSettings.subscribe(settings => { - this.#arePropertiesSuggestionsEnabled = Boolean(settings.suggestProperties); - this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition || "start"; - }); - } - - /** - * Catch the user input and execute suggestions logic. - * @param event Source event to find the input element from. - */ - #onInputFindProperties(event: Event) { - // Ignore events until option is enabled. - if (!this.#arePropertiesSuggestionsEnabled || !(event.currentTarget instanceof HTMLInputElement)) { - return; - } - - const currentFragment = this.#findCurrentTagFragment(); - - if (!currentFragment) { - return; - } - - this.#renderSuggestions( - SearchWrapper.#resolveSuggestionsFromTerm(currentFragment), - event.currentTarget - ); - } - - /** - * Get the selection position in the search field. - */ - #getInputUserSelection(): number { - if (!this.#searchField) { - throw new Error('Missing search field!'); - } - - return Math.min( - this.#searchField.selectionStart ?? 0, - this.#searchField.selectionEnd ?? 0, - ); - } - - /** - * Parse the search query and return the list of parsed tokens. Result will be cached for current search query. - */ - #resolveQueryTokens(): Token[] { - if (!this.#searchField) { - throw new Error('Missing search field!'); - } - - const searchValue = this.#searchField.value; - - if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) { - return this.#cachedParsedQuery; - } - - this.#lastParsedSearchValue = searchValue; - this.#cachedParsedQuery = new QueryLexer(searchValue).parse(); - - return this.#cachedParsedQuery; - } - - /** - * Find the currently selected term. - * @return Selected term or null if none found. - */ - #findCurrentTagFragment(): string | null { - if (!this.#searchField) { - return null; - } - - let searchValue = this.#searchField.value; - - if (!searchValue) { - this.#lastTermToken = null; - return null; - } - - const token = SearchWrapper.#findActiveSearchTermPosition( - this.#resolveQueryTokens(), - this.#getInputUserSelection(), - ); - - if (token instanceof TermToken) { - this.#lastTermToken = token; - return token.value; - } - - if (token instanceof QuotedTermToken) { - this.#lastTermToken = token; - return token.decodedValue; - } - - this.#lastTermToken = null; - return searchValue; - } - - /** - * Resolve the autocomplete container from the document. Once resolved, it can be safely reused without breaking - * anything. Assuming refactored autocomplete handler is still implemented the way it is. - * - * This means, that properties will only be suggested once actual autocomplete logic was activated. - * - * @return Resolved element or nothing. - */ - #resolveAutocompleteContainer(): HTMLElement | null { - if (this.#cachedAutocompleteContainer) { - return this.#cachedAutocompleteContainer; - } - - this.#cachedAutocompleteContainer = document.querySelector('.autocomplete'); - - return this.#cachedAutocompleteContainer; - } - - /** - * Render the list of suggestions into the existing popup or create and populate a new one. - * @param suggestions List of suggestion to render the popup from. - * @param targetInput Target input to attach the popup to. - */ - #renderSuggestions(suggestions: string[], targetInput: HTMLInputElement) { - const suggestedListItems = suggestions - .map(suggestedTerm => this.#renderTermSuggestion(suggestedTerm)); - - requestAnimationFrame(() => { - const autocompleteContainer = this.#resolveAutocompleteContainer(); - - if (!autocompleteContainer) { - return; - } - - // Since the autocomplete popup was refactored to re-use the same element over and over again, we need to remove - // the options from the popup manually when autocomplete was removed from the DOM, since site is not doing that. - const termsToRemove = autocompleteContainer.isConnected - // Only removing properties when element is still connected to the DOM (popup is used by the website) - ? autocompleteContainer.querySelectorAll('.autocomplete__item--property') - // Remove everything if popup was disconnected from the DOM. - : autocompleteContainer.querySelectorAll('.autocomplete__item') - - for (let existingTerm of termsToRemove) { - existingTerm.remove(); - } - - const listContainer = autocompleteContainer.querySelector('ul'); - - if (!listContainer) { - return; - } - - switch (this.#propertiesSuggestionsPosition) { - case "start": - listContainer.prepend(...suggestedListItems); - break; - - case "end": - listContainer.append(...suggestedListItems); - break; - - default: - console.warn("Invalid position for property suggestions!"); - } - - const parentScrollTop = targetInput.parentElement?.scrollTop ?? 0; - - autocompleteContainer.style.position = 'absolute'; - autocompleteContainer.style.left = `${targetInput.offsetLeft}px`; - autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - parentScrollTop}px`; - - document.body.append(autocompleteContainer); - }) - } - - /** - * Loosely estimate where current selected search term is located and return it if found. - * @param tokens Search value to find the actively selected term from. - * @param userSelectionIndex The index of the user selection. - * @return Search term object or NULL if nothing found. - */ - static #findActiveSearchTermPosition(tokens: Token[], userSelectionIndex: number): Token | null { - return tokens.find( - token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex - ) ?? null; - } - - /** - * Regular expression to search the properties' syntax. - */ - static #propertySearchTermHeadingRegExp = /^(?[a-z\d_]+)(?\.(?[a-z]*))?(?:(?.*))?$/; - - /** - * Create a list of suggested elements using the input received from the user. - * @param searchTermValue Original decoded term received from the user. - * @return {string[]} List of suggestions. Could be empty. - */ - static #resolveSuggestionsFromTerm(searchTermValue: string): string[] { - const suggestionsList: string[] = []; - - this.#propertySearchTermHeadingRegExp.lastIndex = 0; - const parsedResult = this.#propertySearchTermHeadingRegExp.exec(searchTermValue); - - if (!parsedResult) { - return suggestionsList; - } - - const propertyName = parsedResult.groups?.name; - - if (!propertyName) { - return suggestionsList; - } - - const propertyType = this.#properties.get(propertyName); - const hasOperatorSyntax = Boolean(parsedResult.groups?.op_syntax); - const hasValueSyntax = Boolean(parsedResult.groups?.value_syntax); - - // No suggestions for values for now, maybe could add suggestions for namespaces like my:* - if (hasValueSyntax && propertyType) { - if (this.#typeValues.has(propertyType)) { - const givenValue = parsedResult.groups?.value; - const candidateValues = this.#typeValues.get(propertyType) || []; - - for (let candidateValue of candidateValues) { - if (givenValue && !candidateValue.startsWith(givenValue)) { - continue; - } - - suggestionsList.push(`${propertyName}${parsedResult.groups?.op_syntax ?? ''}:${candidateValue}`); - } - } - - return suggestionsList; - } - - // If at least one dot placed, start suggesting operators - if (hasOperatorSyntax && propertyType) { - if (this.#typeOperators.has(propertyType)) { - const operatorName = parsedResult.groups?.op; - const candidateOperators = this.#typeOperators.get(propertyType) ?? []; - - for (let candidateOperator of candidateOperators) { - if (operatorName && !candidateOperator.startsWith(operatorName)) { - continue; - } - - suggestionsList.push(`${propertyName}.${candidateOperator}:`); - } - } - - return suggestionsList; - } - - // Otherwise, search for properties with names starting with the term - for (let [candidateProperty] of this.#properties) { - if (propertyName && !candidateProperty.startsWith(propertyName)) { - continue; - } - - suggestionsList.push(candidateProperty); - } - - return suggestionsList; - } - - /** - * Render a single suggestion item and connect required events to interact with the user. - * @param suggestedTerm Term to use for suggestion item. - * @return Resulting element. - */ - #renderTermSuggestion(suggestedTerm: string): HTMLElement { - const suggestionItem = document.createElement('li'); - suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property'); - suggestionItem.dataset.value = suggestedTerm; - suggestionItem.innerText = suggestedTerm; - - const propertyIcon = document.createElement('i'); - propertyIcon.classList.add('fa', 'fa-info-circle'); - suggestionItem.insertAdjacentElement('afterbegin', propertyIcon); - - suggestionItem.addEventListener('mouseover', () => { - SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem); - suggestionItem.classList.add('autocomplete__item--selected'); - }); - - suggestionItem.addEventListener('mouseout', () => { - SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem); - }); - - suggestionItem.addEventListener('click', () => { - this.#replaceLastActiveTokenWithSuggestion(suggestedTerm); - }); - - return suggestionItem; - } - - /** - * Automatically replace the last active token stored in the variable with the new value. - * @param suggestedTerm Term to replace the value with. - */ - #replaceLastActiveTokenWithSuggestion(suggestedTerm: string) { - if (!this.#lastTermToken || !this.#searchField) { - return; - } - - const searchQuery = this.#searchField.value; - const beforeToken = searchQuery.substring(0, this.#lastTermToken.index); - const afterToken = searchQuery.substring(this.#lastTermToken.index + this.#lastTermToken.value.length); - - let replacementValue = suggestedTerm; - - if (replacementValue.includes('"')) { - replacementValue = `"${QuotedTermToken.encode(replacementValue)}"` - } - - this.#searchField.value = beforeToken + replacementValue + afterToken; - } - - /** - * Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's - * front-end. - * @param suggestedElement Target element to search from. If element is disconnected from the DOM, search will be - * halted. - */ - static #findAndResetSelectedSuggestion(suggestedElement: HTMLElement) { - if (!suggestedElement.parentElement) { - return; - } - - for (let selectedElement of suggestedElement.parentElement.querySelectorAll('.autocomplete__item--selected')) { - selectedElement.classList.remove('autocomplete__item--selected'); - } - } - - static #typeNumeric = Symbol(); - static #typeDate = Symbol(); - static #typeLiteral = Symbol(); - static #typePersonal = Symbol(); - static #typeBoolean = Symbol(); - - static #properties = new Map([ - ['animated', SearchWrapper.#typeBoolean], - ['aspect_ratio', SearchWrapper.#typeNumeric], - ['body_type_tag_count', SearchWrapper.#typeNumeric], - ['character_tag_count', SearchWrapper.#typeNumeric], - ['comment_count', SearchWrapper.#typeNumeric], - ['content_fanmade_tag_count', SearchWrapper.#typeNumeric], - ['content_official_tag_count', SearchWrapper.#typeNumeric], - ['created_at', SearchWrapper.#typeDate], - ['description', SearchWrapper.#typeLiteral], - ['downvotes', SearchWrapper.#typeNumeric], - ['duration', SearchWrapper.#typeNumeric], - ['error_tag_count', SearchWrapper.#typeNumeric], - ['faved_by', SearchWrapper.#typeLiteral], - ['faved_by_id', SearchWrapper.#typeNumeric], - ['faves', SearchWrapper.#typeNumeric], - ['file_name', SearchWrapper.#typeLiteral], - ['first_seen_at', SearchWrapper.#typeDate], - ['height', SearchWrapper.#typeNumeric], - ['id', SearchWrapper.#typeNumeric], - ['oc_tag_count', SearchWrapper.#typeNumeric], - ['orig_sha512_hash', SearchWrapper.#typeLiteral], - ['original_format', SearchWrapper.#typeLiteral], - ['pixels', SearchWrapper.#typeNumeric], - ['rating_tag_count', SearchWrapper.#typeNumeric], - ['score', SearchWrapper.#typeNumeric], - ['sha512_hash', SearchWrapper.#typeLiteral], - ['size', SearchWrapper.#typeNumeric], - ['source_count', SearchWrapper.#typeNumeric], - ['source_url', SearchWrapper.#typeLiteral], - ['species_tag_count', SearchWrapper.#typeNumeric], - ['spoiler_tag_count', SearchWrapper.#typeNumeric], - ['tag_count', SearchWrapper.#typeNumeric], - ['updated_at', SearchWrapper.#typeDate], - ['uploader', SearchWrapper.#typeLiteral], - ['uploader_id', SearchWrapper.#typeNumeric], - ['upvotes', SearchWrapper.#typeNumeric], - ['width', SearchWrapper.#typeNumeric], - ['wilson_score', SearchWrapper.#typeNumeric], - ['my', SearchWrapper.#typePersonal], - ]); - - static #comparisonOperators = ['gt', 'gte', 'lt', 'lte']; - - static #typeOperators = new Map([ - [SearchWrapper.#typeNumeric, SearchWrapper.#comparisonOperators], - [SearchWrapper.#typeDate, SearchWrapper.#comparisonOperators], - ]); - - static #typeValues = new Map([ - [SearchWrapper.#typePersonal, [ - 'comments', - 'faves', - 'posts', - 'uploads', - 'upvotes', - 'watched', - ]], - [SearchWrapper.#typeBoolean, [ - 'true', - 'false', - ]] - ]); -} diff --git a/src/lib/components/SiteHeaderWrapper.ts b/src/lib/components/SiteHeaderWrapper.ts deleted file mode 100644 index c1b22fe..0000000 --- a/src/lib/components/SiteHeaderWrapper.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { BaseComponent } from "$lib/components/base/BaseComponent"; -import { SearchWrapper } from "$lib/components/SearchWrapper"; - -class SiteHeaderWrapper extends BaseComponent { - #searchWrapper: SearchWrapper | null = null; - - build() { - const searchForm = this.container.querySelector('.header__search'); - this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null; - } - - init() { - if (this.#searchWrapper) { - this.#searchWrapper.initialize(); - } - } -} - -export function initializeSiteHeader(siteHeaderElement: HTMLElement) { - new SiteHeaderWrapper(siteHeaderElement) - .initialize(); -} diff --git a/src/lib/extension/settings/SearchSettings.ts b/src/lib/extension/settings/SearchSettings.ts deleted file mode 100644 index 373c613..0000000 --- a/src/lib/extension/settings/SearchSettings.ts +++ /dev/null @@ -1,30 +0,0 @@ -import CacheableSettings from "$lib/extension/base/CacheableSettings"; - -export type SuggestionsPosition = "start" | "end"; - -interface SearchSettingsFields { - suggestProperties: boolean; - suggestPropertiesPosition: SuggestionsPosition; -} - -export default class SearchSettings extends CacheableSettings { - constructor() { - super("search"); - } - - async resolvePropertiesSuggestionsEnabled() { - return this._resolveSetting("suggestProperties", false); - } - - async resolvePropertiesSuggestionsPosition() { - return this._resolveSetting("suggestPropertiesPosition", "start"); - } - - async setPropertiesSuggestions(isEnabled: boolean) { - return this._writeSetting("suggestProperties", isEnabled); - } - - async setPropertiesSuggestionsPosition(position: "start" | "end") { - return this._writeSetting("suggestPropertiesPosition", position); - } -} diff --git a/src/routes/preferences/+page.svelte b/src/routes/preferences/+page.svelte index 5937831..5f861a2 100644 --- a/src/routes/preferences/+page.svelte +++ b/src/routes/preferences/+page.svelte @@ -7,7 +7,6 @@ Back
Tagging - Search Misc & Tools
Debug diff --git a/src/routes/preferences/search/+page.svelte b/src/routes/preferences/search/+page.svelte deleted file mode 100644 index 5e55d90..0000000 --- a/src/routes/preferences/search/+page.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - - - Back -
-
- - - - Auto-complete properties - - - {#if $searchPropertiesSuggestionsEnabled} - - - - {/if} - diff --git a/src/stores/preferences/search.ts b/src/stores/preferences/search.ts deleted file mode 100644 index 58a8227..0000000 --- a/src/stores/preferences/search.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { type Writable, writable } from "svelte/store"; -import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings"; - -export const searchPropertiesSuggestionsEnabled = writable(false); - -export const searchPropertiesSuggestionsPosition: Writable = writable('start'); - -const searchSettings = new SearchSettings(); - -Promise.allSettled([ - // First we wait for all properties to load and save - searchSettings.resolvePropertiesSuggestionsEnabled().then(v => searchPropertiesSuggestionsEnabled.set(v)), - searchSettings.resolvePropertiesSuggestionsPosition().then(v => searchPropertiesSuggestionsPosition.set(v)) -]).then(() => { - // And then we can start reading value changes from the writable objects - searchPropertiesSuggestionsEnabled.subscribe(value => { - void searchSettings.setPropertiesSuggestions(value); - }); - - searchPropertiesSuggestionsPosition.subscribe(value => { - void searchSettings.setPropertiesSuggestionsPosition(value); - }); - - searchSettings.subscribe(settings => { - searchPropertiesSuggestionsEnabled.set(Boolean(settings.suggestProperties)); - searchPropertiesSuggestionsPosition.set(settings.suggestPropertiesPosition || 'start'); - }); -}) diff --git a/src/styles/content/header.scss b/src/styles/content/header.scss deleted file mode 100644 index 22b76e0..0000000 --- a/src/styles/content/header.scss +++ /dev/null @@ -1,9 +0,0 @@ -.autocomplete { - &__item { - &--property { - i { - margin-right: .5em; - } - } - } -} From 69dc645de28e548c9cc7a924b0102e53dde06f8f Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 27 Jul 2025 18:27:19 +0400 Subject: [PATCH 14/42] Getter for the type of the entity --- src/lib/extension/base/StorageEntity.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/extension/base/StorageEntity.ts b/src/lib/extension/base/StorageEntity.ts index 4b84fe1..60eb0bf 100644 --- a/src/lib/extension/base/StorageEntity.ts +++ b/src/lib/extension/base/StorageEntity.ts @@ -24,18 +24,22 @@ export default abstract class StorageEntity { return this.#settings; } + 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 ); } From fcca26e128c5ea0322109683e5ab233f8b709da4 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 27 Jul 2025 19:00:20 +0400 Subject: [PATCH 15/42] Make checkbox menu item toggle checkbox if no link is set --- .../ui/menu/MenuCheckboxItem.svelte | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/components/ui/menu/MenuCheckboxItem.svelte b/src/components/ui/menu/MenuCheckboxItem.svelte index 9591e8a..1385317 100644 --- a/src/components/ui/menu/MenuCheckboxItem.svelte +++ b/src/components/ui/menu/MenuCheckboxItem.svelte @@ -13,6 +13,8 @@ oninput?: FormEventHandler; } + let checkboxElement: HTMLInputElement; + let { checked = $bindable(), name = undefined, @@ -23,14 +25,48 @@ oninput, }: 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() { + // When menu link does not contain any link, we should just treat clicks on it as toggle action on checkbox. + if (!href) { + checked = !checked; + + // Since we've toggled it using the `checked` property and input does not trigger `onclick` when we do something + // programmatically, we should create valid event and send it back to the parent component so it will handle + // whatever it wants. + if (oninput) { + // Uhh, not sure if this is how it should be done, but we need `currentTarget` to point on the checkbox. Without + // dispatching the event, we can't fill it normally. Also, input element does not return us untrusted input + // events automatically. Probably should make the util function later in case I'd need something similar. + checkboxElement.addEventListener('input', (inputEvent: Event) => { + oninput(inputEvent as Event & { currentTarget: HTMLInputElement }); + }, { once: true }) + + checkboxElement.dispatchEvent(new InputEvent('input')); + } + } + } - - + + {@render children?.()} From b956b6f7bcc7a4329e4a28ffbc79e83a37040273 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 27 Jul 2025 19:02:19 +0400 Subject: [PATCH 16/42] Added separate menu for bulk exporting of all entities --- src/lib/extension/BulkEntitiesTransporter.ts | 26 +++- src/lib/extension/EntitiesTransporter.ts | 18 ++- src/routes/+page.svelte | 1 + src/routes/transporting/+page.svelte | 10 ++ src/routes/transporting/export/+page.svelte | 121 +++++++++++++++++++ 5 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 src/routes/transporting/+page.svelte create mode 100644 src/routes/transporting/export/+page.svelte diff --git a/src/lib/extension/BulkEntitiesTransporter.ts b/src/lib/extension/BulkEntitiesTransporter.ts index 762bf7b..fb2f565 100644 --- a/src/lib/extension/BulkEntitiesTransporter.ts +++ b/src/lib/extension/BulkEntitiesTransporter.ts @@ -1,5 +1,5 @@ import type StorageEntity from "$lib/extension/base/StorageEntity"; -import { decompressFromEncodedURIComponent } from "lz-string"; +import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string"; import type { ImportableElementsList, ImportableEntityObject } from "$lib/extension/transporting/importables"; import EntitiesTransporter from "$lib/extension/EntitiesTransporter"; import MaintenanceProfile from "$entities/MaintenanceProfile"; @@ -42,6 +42,30 @@ export default class BulkEntitiesTransporter { ); } + exportToJSON(entities: StorageEntity[]): string { + return JSON.stringify({ + $type: 'list', + elements: entities + .map(entity => { + switch (true) { + case entity instanceof MaintenanceProfile: + return BulkEntitiesTransporter.#transporters.profiles.exportToObject(entity); + case entity instanceof TagGroup: + return BulkEntitiesTransporter.#transporters.groups.exportToObject(entity); + } + + return null; + }) + .filter(value => !!value) + } as ImportableElementsList>, null, 2); + } + + exportToCompressedJSON(entities: StorageEntity[]): string { + return compressToEncodedURIComponent( + this.exportToJSON(entities) + ); + } + static isList(targetObject: any): targetObject is ImportableElementsList> { return targetObject.$type && targetObject.$type === 'list' diff --git a/src/lib/extension/EntitiesTransporter.ts b/src/lib/extension/EntitiesTransporter.ts index 2b26acd..54b01a9 100644 --- a/src/lib/extension/EntitiesTransporter.ts +++ b/src/lib/extension/EntitiesTransporter.ts @@ -32,6 +32,10 @@ export default class EntitiesTransporter { this.#targetEntityConstructor = entityConstructor; } + isCorrectEntity(entityObject: unknown): entityObject is EntityType { + return entityObject instanceof this.#targetEntityConstructor; + } + importFromObject(importedObject: Record): EntityType { validateImportedEntity( this.#entityName, @@ -60,8 +64,8 @@ export default class EntitiesTransporter { ) } - 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!'); } @@ -69,12 +73,18 @@ export default class EntitiesTransporter { throw new TypeError('Only storage entities could be exported!'); } - const exportableObject = exportEntityToObject( + 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 { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ac92655..3df1e07 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -24,6 +24,7 @@ Tagging Profiles Tag Groups
+ Import/Export Preferences About diff --git a/src/routes/transporting/+page.svelte b/src/routes/transporting/+page.svelte new file mode 100644 index 0000000..5bcb535 --- /dev/null +++ b/src/routes/transporting/+page.svelte @@ -0,0 +1,10 @@ + + + + Back +
+ Export +
diff --git a/src/routes/transporting/export/+page.svelte b/src/routes/transporting/export/+page.svelte new file mode 100644 index 0000000..86569e3 --- /dev/null +++ b/src/routes/transporting/export/+page.svelte @@ -0,0 +1,121 @@ + + +{#if !displayExportedString} + + Back +
+ {#if $maintenanceProfiles.length} + + Export All Profiles + + {#each $maintenanceProfiles as profile} + + Profile: {profile.settings.name} + + {/each} +
+ {/if} + {#if $tagGroups.length} + + Export All Groups + + {#each $tagGroups as group} + + Group: {group.settings.name} + + {/each} +
+ {/if} + Export Selected +
+{:else} + + Back to Selection +
+
+ + + + + + +
+ + Switch Format: + {#if shouldUseCompressed} + Base64-Encoded + {:else} + Raw JSON + {/if} + +
+{/if} From 77293ba30cd9aaf769a7c8cc9e55ffac830bcefb Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 27 Jul 2025 19:58:19 +0400 Subject: [PATCH 17/42] Fixed "Export All" checkbox toggling incorrect items --- src/routes/transporting/export/+page.svelte | 28 +++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/routes/transporting/export/+page.svelte b/src/routes/transporting/export/+page.svelte index 86569e3..be11514 100644 --- a/src/routes/transporting/export/+page.svelte +++ b/src/routes/transporting/export/+page.svelte @@ -53,11 +53,25 @@ }); } - function toggleSelectionOnUserInput() { - requestAnimationFrame(() => { - $maintenanceProfiles.forEach(profile => exportedEntities.profiles[profile.id] = exportAllProfiles); - $tagGroups.forEach(group => exportedEntities.groups[group.id] = exportAllGroups); - }); + /** + * 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() { @@ -74,7 +88,7 @@ Back
{#if $maintenanceProfiles.length} - + Export All Profiles {#each $maintenanceProfiles as profile} @@ -85,7 +99,7 @@
{/if} {#if $tagGroups.length} - + Export All Groups {#each $tagGroups as group} From e27257516d9b74e9137f3819aab4c32993f2822c Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 27 Jul 2025 21:44:52 +0400 Subject: [PATCH 18/42] Validator: Support up to version 2 of profiles entity --- src/lib/extension/transporting/validators.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/extension/transporting/validators.ts b/src/lib/extension/transporting/validators.ts index 544029f..f9b0e63 100644 --- a/src/lib/extension/transporting/validators.ts +++ b/src/lib/extension/transporting/validators.ts @@ -21,8 +21,8 @@ type EntitiesValidationMap = { */ 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 ( From 470021ee8c750727565122ce2682d30060ecf3db Mon Sep 17 00:00:00 2001 From: KoloMl Date: Mon, 4 Aug 2025 13:32:19 +0400 Subject: [PATCH 19/42] Validators: Using functions for common value checks --- src/lib/extension/transporting/validators.ts | 25 +++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/lib/extension/transporting/validators.ts b/src/lib/extension/transporting/validators.ts index f9b0e63..2a599d6 100644 --- a/src/lib/extension/transporting/validators.ts +++ b/src/lib/extension/transporting/validators.ts @@ -16,6 +16,22 @@ type EntitiesValidationMap = { [EntityKey in keyof App.EntityNamesMap]?: ValidationFunction; }; +/** + * Check if the following value is defined, not empty and is of correct type. + * @param value Value to be checked. + */ +function validateRequiredString(value: unknown): boolean { + return Boolean(value && typeof value === 'string'); +} + +/** + * Check if the following value is not set or is a valid array. + * @param value Value to be checked. + */ +function validateOptionalArray(value: unknown): boolean { + return typeof value === 'undefined' || value === null || Array.isArray(value); +} + /** * Map of validators for each entity. Function should throw the error if validation failed. */ @@ -26,12 +42,9 @@ const entitiesValidators: EntitiesValidationMap = { } 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!'); } From 6bd7116df2267d4aa99768613d56e5a9f73c5432 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Mon, 4 Aug 2025 13:32:35 +0400 Subject: [PATCH 20/42] Added validation logic for the group entity --- src/lib/extension/transporting/validators.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/lib/extension/transporting/validators.ts b/src/lib/extension/transporting/validators.ts index 2a599d6..2efec8b 100644 --- a/src/lib/extension/transporting/validators.ts +++ b/src/lib/extension/transporting/validators.ts @@ -48,7 +48,22 @@ const entitiesValidators: EntitiesValidationMap = { ) { 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!'); + } + }, }; /** From 189fda59c82c9dc4306cec14bbf88c3677c4fb0f Mon Sep 17 00:00:00 2001 From: KoloMl Date: Mon, 4 Aug 2025 13:39:06 +0400 Subject: [PATCH 21/42] Added separate event listener endpoint for menu item clicks --- .../ui/menu/MenuCheckboxItem.svelte | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/ui/menu/MenuCheckboxItem.svelte b/src/components/ui/menu/MenuCheckboxItem.svelte index 1385317..155144e 100644 --- a/src/components/ui/menu/MenuCheckboxItem.svelte +++ b/src/components/ui/menu/MenuCheckboxItem.svelte @@ -9,8 +9,15 @@ value?: string; href?: string; children?: Snippet; + /** + * Click event received by the checkbox input element. + */ onclick?: MouseEventHandler; oninput?: FormEventHandler; + /** + * Click event received by the menu item instead of the checkbox element. + */ + onitemclick?: MouseEventHandler; } let checkboxElement: HTMLInputElement; @@ -23,6 +30,7 @@ children, onclick, oninput, + onitemclick, }: MenuCheckboxItemProps = $props(); /** @@ -37,7 +45,17 @@ /** * Check and try to toggle checkbox if href was not provided for the menu item. */ - function maybeToggleCheckboxOnOuterLinkClicked() { + 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; From 19ab302b5460fff7c0cce759ba50351eb1fa5e88 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Mon, 4 Aug 2025 13:39:48 +0400 Subject: [PATCH 22/42] Implemented the bulk import interface --- src/routes/transporting/+page.svelte | 1 + src/routes/transporting/import/+page.svelte | 248 ++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 src/routes/transporting/import/+page.svelte diff --git a/src/routes/transporting/+page.svelte b/src/routes/transporting/+page.svelte index 5bcb535..769ae5e 100644 --- a/src/routes/transporting/+page.svelte +++ b/src/routes/transporting/+page.svelte @@ -7,4 +7,5 @@ Back
Export + Import diff --git a/src/routes/transporting/import/+page.svelte b/src/routes/transporting/import/+page.svelte new file mode 100644 index 0000000..8020297 --- /dev/null +++ b/src/routes/transporting/import/+page.svelte @@ -0,0 +1,248 @@ + + +{#if !hasImportedEntities} + + Back +
+
+ {#if errorMessage} +

{errorMessage}

+ +
+
+ {/if} + + + + + + +
+ Import & Preview +
+{:else if previewedEntity} + + previewedEntity = null} icon="arrow-left">Back to Selection +
+
+ {#if previewedEntity instanceof MaintenanceProfile} + + {:else if previewedEntity instanceof TagGroup} + + {/if} +{:else} + + Cancel Import +
+ {#if importedProfiles.length} +
+ + Import All Profiles + + {#each importedProfiles as candidateProfile} + + {#if existingProfilesMap.has(candidateProfile.id)} + Update: + {:else} + New: + {/if} + {candidateProfile.settings.name || 'Unnamed Profile'} + + {/each} + {/if} + {#if importedGroups.length} +
+ + Import All Groups + + {#each importedGroups as candidateGroup} + + {#if existingGroupsMap.has(candidateGroup.id)} + Update: + {:else} + New: + {/if} + {candidateGroup.settings.name || 'Unnamed Group'} + + {/each} + {/if} +
+ + Imported Selected + +
+{/if} + + From 48f278ae95cf4cb757cb2ef6a51e2a669c32e64c Mon Sep 17 00:00:00 2001 From: KoloMl Date: Mon, 4 Aug 2025 13:40:19 +0400 Subject: [PATCH 23/42] Added TODO about missing "migration" progress for entities --- src/lib/extension/EntitiesTransporter.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/extension/EntitiesTransporter.ts b/src/lib/extension/EntitiesTransporter.ts index 54b01a9..ac29569 100644 --- a/src/lib/extension/EntitiesTransporter.ts +++ b/src/lib/extension/EntitiesTransporter.ts @@ -37,6 +37,8 @@ export default class EntitiesTransporter { } importFromObject(importedObject: Record): EntityType { + // 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, From ce9a2b5f9bb8e72232f3092a7c69872ae2172085 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 8 Aug 2025 05:59:23 +0400 Subject: [PATCH 24/42] Fixed typo in content script entry --- .vite/lib/manifest.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vite/lib/manifest.js b/.vite/lib/manifest.js index dffef10..63b039c 100644 --- a/.vite/lib/manifest.js +++ b/.vite/lib/manifest.js @@ -124,7 +124,7 @@ export function loadManifest(filePath) { /** * @typedef {Object} ContentScriptsEntry - * @property {string[]} mathces + * @property {string[]} matches * @property {string[]|undefined} js * @property {string[]|undefined} css */ From b321c1049c4b0b50664ae6e0884cd341932f6231 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 8 Aug 2025 19:36:19 +0400 Subject: [PATCH 25/42] Installed `cross-env` to support quickly adding ENV variables to builder --- package-lock.json | 26 ++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 27 insertions(+) diff --git a/package-lock.json b/package-lock.json index 93ce13b..c425cde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/node": "^22.15.29", "@vitest/coverage-v8": "^3.2.0", "cheerio": "^1.0.0", + "cross-env": "^10.0.0", "jsdom": "^26.1.0", "sass": "^1.89.1", "svelte": "^5.33.14", @@ -231,6 +232,13 @@ "node": ">=18" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", @@ -1646,6 +1654,24 @@ "node": ">= 0.6" } }, + "node_modules/cross-env": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.0.0.tgz", + "integrity": "sha512-aU8qlEK/nHYtVuN4p7UQgAwVljzMg8hB4YK5ThRqD2l/ziSnryncPNn7bMLt5cFYsKVKBh8HqLqyCoTupEUu7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index 9c55a7c..c2a991f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@types/node": "^22.15.29", "@vitest/coverage-v8": "^3.2.0", "cheerio": "^1.0.0", + "cross-env": "^10.0.0", "jsdom": "^26.1.0", "sass": "^1.89.1", "svelte": "^5.33.14", From efd65225323c598afb07082d78f74b265f08cb55 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 8 Aug 2025 19:37:12 +0400 Subject: [PATCH 26/42] Implemented plugin for swaping defined build-time constants --- .vite/plugins/swap-defined-variables.js | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .vite/plugins/swap-defined-variables.js diff --git a/.vite/plugins/swap-defined-variables.js b/.vite/plugins/swap-defined-variables.js new file mode 100644 index 0000000..61aabfc --- /dev/null +++ b/.vite/plugins/swap-defined-variables.js @@ -0,0 +1,28 @@ +/** + * @param {SwapDefinedVariablesSettings} settings + * @return {import('vite').Plugin} + */ +export function SwapDefinedVariablesPlugin(settings) { + return { + name: 'koloml:swap-defined-variables', + enforce: 'post', + configResolved: (config) => { + if ( + config.define + && process.env.hasOwnProperty(settings.envVariable) + && process.env[settings.envVariable] === settings.expectedValue + ) { + for (const [key, value] of Object.entries(settings.define)) { + config.define[key] = value; + } + } + } + } +} + +/** + * @typedef {Object} SwapDefinedVariablesSettings + * @property {string} envVariable + * @property {string} expectedValue + * @property {Record} define + */ From 2eefbf96ca659d54eff3f9d580cac457c9bc4dd3 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 8 Aug 2025 19:37:48 +0400 Subject: [PATCH 27/42] Implemented plugin to provide constants to SCSS through custom function --- .../scss-read-env-variable-function.js | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .vite/plugins/scss-read-env-variable-function.js diff --git a/.vite/plugins/scss-read-env-variable-function.js b/.vite/plugins/scss-read-env-variable-function.js new file mode 100644 index 0000000..9bf94f8 --- /dev/null +++ b/.vite/plugins/scss-read-env-variable-function.js @@ -0,0 +1,46 @@ +import { SassString, Value } from "sass"; + +/** + * @return {import('vite').Plugin} + */ +export function ScssViteReadEnvVariableFunctionPlugin() { + return { + name: 'koloml:scss-read-env-variable-function', + apply: 'build', + enforce: 'post', + + configResolved: config => { + config.css.preprocessorOptions ??= {}; + config.css.preprocessorOptions.scss ??= {}; + config.css.preprocessorOptions.scss.functions ??= {}; + + /** + * @param {Value[]} args + * @return {SassString} + */ + config.css.preprocessorOptions.scss.functions['vite-read-env-variable($constant-name)'] = (args) => { + const constName = args[0].assertString('constant-name').text; + + if (config.define && config.define.hasOwnProperty(constName)) { + let returnedValue = config.define[constName]; + + try { + returnedValue = JSON.parse(returnedValue); + } catch { + returnedValue = null; + } + + if (typeof returnedValue !== 'string') { + console.warn(`Attempting to read the constant with non-string type: ${constName}`); + return new SassString(''); + } + + return new SassString(returnedValue); + } + + console.warn(`Constant does not exist: ${constName}`); + return new SassString(''); + } + } + } +} From c37f680e9f421da78c3771e61f42f54bceef18d2 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 8 Aug 2025 19:38:52 +0400 Subject: [PATCH 28/42] Provide current site name & identifier for build-time modifications --- .vite/lib/content-scripts.js | 17 ++++++++++++- package.json | 1 + vite.config.ts | 49 ++++++++++++++++++++++++------------ 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/.vite/lib/content-scripts.js b/.vite/lib/content-scripts.js index f9385ca..5a84761 100644 --- a/.vite/lib/content-scripts.js +++ b/.vite/lib/content-scripts.js @@ -2,6 +2,8 @@ import { build } from "vite"; import { createHash } from "crypto"; import path from "path"; import fs from "fs"; +import { SwapDefinedVariablesPlugin } from "../plugins/swap-defined-variables.js"; +import { ScssViteReadEnvVariableFunctionPlugin } from "../plugins/scss-read-env-variable-function.js"; /** * Create the result base file name for the file. @@ -192,6 +194,15 @@ export async function buildScriptsAndStyles(buildOptions) { .get(fileName) ?.push(...dependencies); }), + ScssViteReadEnvVariableFunctionPlugin(), + SwapDefinedVariablesPlugin({ + envVariable: 'SITE', + expectedValue: 'derpibooru', + define: { + __CURRENT_SITE__: JSON.stringify('derpibooru'), + __CURRENT_SITE_NAME__: JSON.stringify('Derpibooru'), + } + }), ] }); @@ -215,7 +226,11 @@ export async function buildScriptsAndStyles(buildOptions) { }, plugins: [ wrapScriptIntoIIFE(), - ] + ], + define: { + __CURRENT_SITE__: JSON.stringify('furbooru'), + __CURRENT_SITE_NAME__: JSON.stringify('Furbooru'), + } }); return pathsReplacement; diff --git a/package.json b/package.json index c2a991f..b7a9a94 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "build": "npm run build:popup && npm run build:extension", + "build:derpibooru": "cross-env SITE=derpibooru npm run build", "build:popup": "vite build", "build:extension": "node build-extension.js", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", diff --git a/vite.config.ts b/vite.config.ts index f8d70c5..b6d5b66 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,21 +1,38 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vitest/config'; +import { ScssViteReadEnvVariableFunctionPlugin } from "./.vite/plugins/scss-read-env-variable-function"; +import { SwapDefinedVariablesPlugin } from "./.vite/plugins/swap-defined-variables"; -export default defineConfig({ - build: { - // SVGs imported from the FA6 don't need to be inlined! - assetsInlineLimit: 0 - }, - plugins: [ - sveltekit(), - ], - test: { - globals: true, - environment: 'jsdom', - exclude: ['**/node_modules/**', '.*\\.d\\.ts$', '.*\\.spec\\.ts$'], - coverage: { - reporter: ['text', 'html'], - include: ['src/lib/**/*.{js,ts}'], +export default defineConfig(() => { + return { + build: { + // SVGs imported from the FA6 don't need to be inlined! + assetsInlineLimit: 0 + }, + plugins: [ + sveltekit(), + ScssViteReadEnvVariableFunctionPlugin(), + SwapDefinedVariablesPlugin({ + envVariable: 'SITE', + expectedValue: 'derpibooru', + define: { + __CURRENT_SITE__: JSON.stringify('derpibooru'), + __CURRENT_SITE_NAME__: JSON.stringify('Derpibooru'), + } + }), + ], + test: { + globals: true, + environment: 'jsdom', + exclude: ['**/node_modules/**', '.*\\.d\\.ts$', '.*\\.spec\\.ts$'], + coverage: { + reporter: ['text', 'html'], + include: ['src/lib/**/*.{js,ts}'], + } + }, + define: { + __CURRENT_SITE__: JSON.stringify('furbooru'), + __CURRENT_SITE_NAME__: JSON.stringify('Furbooru'), } - } + }; }); From 4837184d40a7ed94f293e60197907e5279b3b75a Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 8 Aug 2025 19:39:30 +0400 Subject: [PATCH 29/42] Reading constants in SCSS, modifying colors for Derpibooru variant --- src/styles/colors.scss | 29 +++++++++++++++++++++++++++++ src/styles/environment.scss | 20 ++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/styles/environment.scss diff --git a/src/styles/colors.scss b/src/styles/colors.scss index 75ebcbf..7bdfe9e 100644 --- a/src/styles/colors.scss +++ b/src/styles/colors.scss @@ -1,4 +1,5 @@ @use 'sass:color'; +@use 'environment'; $background: #15121a; @@ -55,3 +56,31 @@ $error-background: #7a2725; $warning-background: #7d4825; $warning-border: #95562c; + +@if environment.$current-site == 'derpibooru' { + $background: #141a24; + + $text: #e0e0e0; + $text-gray: #90a1bb; + + $link: #478acc; + $link-hover: #b099dd; + + $header: #284371; + $header-toolbar: #1c3252; + $header-hover-background: #1d3153; + $header-mobile-link-hover: #546c99; + + $footer: #1d242f; + $footer-text: $text-gray; + + $block-header: #252d3c; + $block-border: #2d3649; + $block-background: #1d242f; + $block-background-alternate: #171d26; + + $media-box-border: #3d4657; + + $input-background: #282e39; + $input-border: #575e6b; +} diff --git a/src/styles/environment.scss b/src/styles/environment.scss new file mode 100644 index 0000000..3837505 --- /dev/null +++ b/src/styles/environment.scss @@ -0,0 +1,20 @@ +@use 'sass:meta'; +@use 'sass:string'; + +@function get-defined-constant($constant-name, $default-value: '') { + $resolved-value: $default-value; + + @if meta.function-exists('vite-read-env-variable') { + $candidate-value: meta.call(meta.get-function('vite-read-env-variable'), $constant-name); + + @if string.length($candidate-value) != 0 { + $resolved-value: $candidate-value + } + } + + @return $resolved-value; +} + +$current-site: get-defined-constant('__CURRENT_SITE__', 'furbooru'); + + From 234f80b99205b865e5197c4358dba18f87565ca6 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 8 Aug 2025 19:39:48 +0400 Subject: [PATCH 30/42] Added defined constants to typedefinition for TypeScript --- src/app.d.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/app.d.ts b/src/app.d.ts index e0ceca7..ffec69b 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -4,6 +4,16 @@ import MaintenanceProfile from "$entities/MaintenanceProfile"; import type TagGroup from "$entities/TagGroup"; declare global { + /** + * Identifier of the current site this extension is built for. + */ + const __CURRENT_SITE__: string; + + /** + * Name of the site. + */ + const __CURRENT_SITE_NAME__: string; + // Helper type to not deal with differences between the setTimeout of @types/node and usual web browser's type. type Timeout = ReturnType; From c811c13b70fa64bd2925489573777a7c37ba9558 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 8 Aug 2025 19:40:13 +0400 Subject: [PATCH 31/42] Use site name constant in header component --- src/components/layout/Header.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/Header.svelte b/src/components/layout/Header.svelte index 9740212..40ab04f 100644 --- a/src/components/layout/Header.svelte +++ b/src/components/layout/Header.svelte @@ -1,5 +1,5 @@
- Furbooru Tagging Assistant + {__CURRENT_SITE_NAME__} Tagging Assistant
From ae3c77031fa646b76e31e1a07511dd23d51a3428 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 8 Aug 2025 19:41:23 +0400 Subject: [PATCH 33/42] Added modifications to manifest for Derpibooru variant of the extension --- .vite/lib/manifest.js | 61 +++++++++++++++++++++++++++++++++++++++++ .vite/pack-extension.js | 9 ++++++ 2 files changed, 70 insertions(+) diff --git a/.vite/lib/manifest.js b/.vite/lib/manifest.js index 63b039c..54aa712 100644 --- a/.vite/lib/manifest.js +++ b/.vite/lib/manifest.js @@ -86,6 +86,53 @@ class ManifestProcessor { } } + /** + * Find all patterns in content scripts and host permissions and replace the hostname to the different one. + * + * @param {string|string[]} singleOrMultipleHostnames One or multiple hostnames to replace the original hostname with. + */ + replaceHostTo(singleOrMultipleHostnames) { + if (typeof singleOrMultipleHostnames === 'string') { + singleOrMultipleHostnames = [singleOrMultipleHostnames]; + } + + this.#manifestObject.host_permissions = singleOrMultipleHostnames.map(hostname => `*://*.${hostname}/`); + + this.#manifestObject.content_scripts?.forEach(entry => { + entry.matches = entry.matches.reduce((resultMatches, originalMatchPattern) => { + for (const updatedHostname of singleOrMultipleHostnames) { + resultMatches.push( + originalMatchPattern.replace( + /\*:\/\/\*\.[a-z]+\.[a-z]+\//, + `*://*.${updatedHostname}/` + ), + ); + } + + return resultMatches; + }, []); + }) + } + + /** + * Set different identifier for Gecko-based browsers (Firefox). + * + * @param {string} id ID of the extension to use. + */ + setGeckoIdentifier(id) { + this.#manifestObject.browser_specific_settings.gecko.id = id; + } + + /** + * Set the different extension name. + * + * @param {string} booruName + */ + replaceBooruNameWith(booruName) { + this.#manifestObject.name = this.#manifestObject.name.replaceAll('Furbooru', booruName); + this.#manifestObject.description = this.#manifestObject.description.replaceAll('Furbooru', booruName); + } + /** * Save the current state of the manifest into the selected file. * @@ -118,10 +165,24 @@ export function loadManifest(filePath) { /** * @typedef {Object} Manifest + * @property {string} name + * @property {string} description * @property {string} version + * @property {BrowserSpecificSettings} browser_specific_settings + * @property {string[]} host_permissions * @property {ContentScriptsEntry[]|undefined} content_scripts */ +/** + * @typedef {Object} BrowserSpecificSettings + * @property {GeckoSettings} gecko + */ + +/** + * @typedef {Object} GeckoSettings + * @property {string} id + */ + /** * @typedef {Object} ContentScriptsEntry * @property {string[]} matches diff --git a/.vite/pack-extension.js b/.vite/pack-extension.js index 7b4d0ea..d264e9f 100644 --- a/.vite/pack-extension.js +++ b/.vite/pack-extension.js @@ -67,6 +67,15 @@ export async function packExtension(settings) { return entry; }) + if (process.env.SITE === 'derpibooru') { + manifest.replaceHostTo([ + 'derpibooru.org', + 'trixiebooru.org' + ]); + manifest.replaceBooruNameWith('Derpibooru'); + manifest.setGeckoIdentifier('derpibooru-tagging-assistant@thecore.city'); + } + manifest.passVersionFromPackage(path.resolve(settings.rootDir, 'package.json')); manifest.saveTo(path.resolve(settings.exportDir, 'manifest.json')); From 83d27cc966f8e67b0ec67413e4e0b59456841a48 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 8 Aug 2025 19:46:20 +0400 Subject: [PATCH 34/42] Add `$site` property to identify what site entity was created for --- src/lib/extension/BulkEntitiesTransporter.ts | 1 + src/lib/extension/transporting/exporters.ts | 2 ++ src/lib/extension/transporting/importables.ts | 4 ++++ 3 files changed, 7 insertions(+) diff --git a/src/lib/extension/BulkEntitiesTransporter.ts b/src/lib/extension/BulkEntitiesTransporter.ts index fb2f565..7594b25 100644 --- a/src/lib/extension/BulkEntitiesTransporter.ts +++ b/src/lib/extension/BulkEntitiesTransporter.ts @@ -45,6 +45,7 @@ export default class BulkEntitiesTransporter { exportToJSON(entities: StorageEntity[]): string { return JSON.stringify({ $type: 'list', + $site: __CURRENT_SITE__, elements: entities .map(entity => { switch (true) { diff --git a/src/lib/extension/transporting/exporters.ts b/src/lib/extension/transporting/exporters.ts index fe6886b..a0aa6c7 100644 --- a/src/lib/extension/transporting/exporters.ts +++ b/src/lib/extension/transporting/exporters.ts @@ -11,6 +11,7 @@ const entitiesExporters: ExportersMap = { profiles: entity => { return { $type: "profiles", + $site: __CURRENT_SITE__, v: 2, id: entity.id, name: entity.settings.name, @@ -22,6 +23,7 @@ const entitiesExporters: ExportersMap = { groups: entity => { return { $type: "groups", + $site: __CURRENT_SITE__, v: 2, id: entity.id, name: entity.settings.name, diff --git a/src/lib/extension/transporting/importables.ts b/src/lib/extension/transporting/importables.ts index e60dca9..e1f4c10 100644 --- a/src/lib/extension/transporting/importables.ts +++ b/src/lib/extension/transporting/importables.ts @@ -5,6 +5,10 @@ export interface ImportableElement { * Type of importable. Should be unique to properly import everything. */ $type: Type; + /** + * Identifier of the site this element is built for. + */ + $site?: string; } export interface ImportableElementsList extends ImportableElement<"list"> { From 8822a2581bfccd9d4ddbf1a61af6bc087af2f3b0 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 8 Aug 2025 20:40:57 +0400 Subject: [PATCH 35/42] Store last same site status of the imported object --- src/lib/extension/BulkEntitiesTransporter.ts | 45 ++++++++++++++++++-- src/lib/extension/EntitiesTransporter.ts | 42 ++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/lib/extension/BulkEntitiesTransporter.ts b/src/lib/extension/BulkEntitiesTransporter.ts index 7594b25..b83fa49 100644 --- a/src/lib/extension/BulkEntitiesTransporter.ts +++ b/src/lib/extension/BulkEntitiesTransporter.ts @@ -1,7 +1,7 @@ import type StorageEntity from "$lib/extension/base/StorageEntity"; import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string"; import type { ImportableElementsList, ImportableEntityObject } from "$lib/extension/transporting/importables"; -import EntitiesTransporter from "$lib/extension/EntitiesTransporter"; +import EntitiesTransporter, { type SameSiteStatus } from "$lib/extension/EntitiesTransporter"; import MaintenanceProfile from "$entities/MaintenanceProfile"; import TagGroup from "$entities/TagGroup"; @@ -10,9 +10,17 @@ type TransportersMapping = { } export default class BulkEntitiesTransporter { + #lastSameSiteStatus: SameSiteStatus = null; + + get lastImportSameSiteStatus() { + return this.#lastSameSiteStatus; + } + parseAndImportFromJSON(jsonString: string): StorageEntity[] { let parsedObject: any; + this.#lastSameSiteStatus = null; + try { parsedObject = JSON.parse(jsonString); } catch (e) { @@ -23,7 +31,11 @@ export default class BulkEntitiesTransporter { throw new TypeError('Invalid or unsupported object!'); } - return parsedObject.elements + this.#lastSameSiteStatus = EntitiesTransporter.checkIsSameSiteImportedObject(parsedObject); + + let hasDifferentStatuses = false; + + const resultEntities = parsedObject.elements .map(importableObject => { if (!(importableObject.$type in BulkEntitiesTransporter.#transporters)) { console.warn('Attempting to import unsupported entity: ' + importableObject.$type); @@ -31,9 +43,21 @@ export default class BulkEntitiesTransporter { } const transporter = BulkEntitiesTransporter.#transporters[importableObject.$type as keyof App.EntityNamesMap]; - return transporter.importFromObject(importableObject); + const resultEntity = transporter.importFromObject(importableObject); + + if (transporter.lastImportSameSiteStatus !== this.#lastSameSiteStatus) { + hasDifferentStatuses = true; + } + + return resultEntity; }) .filter(maybeEntity => !!maybeEntity); + + if (hasDifferentStatuses) { + this.#lastSameSiteStatus = 'unknown'; + } + + return resultEntities; } parseAndImportFromCompressedJSON(compressedJsonString: string): StorageEntity[] { @@ -78,4 +102,19 @@ export default class BulkEntitiesTransporter { profiles: new EntitiesTransporter(MaintenanceProfile), groups: new EntitiesTransporter(TagGroup), } + + /** + * Check if the imported object is created for the same site extension or not. + * @param importedObject Object to check. + * @private + */ + static #checkIsSameSiteImportedObject(importedObject: Record): SameSiteStatus { + if (!('$site' in importedObject)) { + return "unknown"; + } + + return importedObject.$site === __CURRENT_SITE__ + ? "same" + : "different"; + } } diff --git a/src/lib/extension/EntitiesTransporter.ts b/src/lib/extension/EntitiesTransporter.ts index ac29569..0b90cec 100644 --- a/src/lib/extension/EntitiesTransporter.ts +++ b/src/lib/extension/EntitiesTransporter.ts @@ -2,10 +2,33 @@ import { validateImportedEntity } from "$lib/extension/transporting/validators"; import { exportEntityToObject } from "$lib/extension/transporting/exporters"; import StorageEntity from "$lib/extension/base/StorageEntity"; import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string"; +import type { ImportableElement } from "$lib/extension/transporting/importables"; + +/** + * Status of the last import. + * + * - `NULL` - no import was done yet or was unsuccessful. + * - `"unknown"` — imported object was created before v0.5, when extension started to be built for multiple sites. + * - `"same"` — imported object is marked as generated by the same type of extension. + * - `"different"` — imported object is marked as generated by some other type of extension. + */ +export type SameSiteStatus = null | "unknown" | "same" | "different"; export default class EntitiesTransporter { readonly #targetEntityConstructor: new (...any: any[]) => EntityType; + #lastSameSiteStatus: SameSiteStatus = null; + + /** + * Read the status of the last successful import. This flag could be used to determine if it was for the same site as + * the current extension or when it's generated before site identity was passed to the importable object. + * + * @see {SameSiteStatus} For the list of possible statuses. + */ + get lastImportSameSiteStatus() { + return this.#lastSameSiteStatus; + } + /** * Name of the entity, exported directly from the constructor. * @private @@ -37,6 +60,8 @@ export default class EntitiesTransporter { } importFromObject(importedObject: Record): EntityType { + this.#lastSameSiteStatus = null; + // TODO: There should be an auto-upgrader somewhere before the validation. So if even the older version of schema // was used, we still will will be able to pass the validation. For now we only have non-breaking changes. validateImportedEntity( @@ -44,6 +69,8 @@ export default class EntitiesTransporter { importedObject, ); + this.#lastSameSiteStatus = EntitiesTransporter.checkIsSameSiteImportedObject(importedObject); + return new this.#targetEntityConstructor( importedObject.id, importedObject @@ -54,6 +81,7 @@ export default class EntitiesTransporter { const importedObject = this.#tryParsingAsJSON(jsonString); if (!importedObject) { + this.#lastSameSiteStatus = null; throw new Error('Invalid JSON!'); } @@ -108,4 +136,18 @@ export default class EntitiesTransporter { return jsonObject } + + /** + * Check if the imported object is created for the same site extension or not. + * @param importedObject Object to check. + */ + static checkIsSameSiteImportedObject(importedObject: Record): SameSiteStatus { + if (!('$site' in importedObject)) { + return "unknown"; + } + + return importedObject.$site === __CURRENT_SITE__ + ? "same" + : "different"; + } } From 0deafb4a003ca01c9810d2fa407247af97f0c61d Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 8 Aug 2025 20:41:55 +0400 Subject: [PATCH 36/42] Warn user of potentially different/unknown site imports --- src/routes/transporting/import/+page.svelte | 31 +++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/routes/transporting/import/+page.svelte b/src/routes/transporting/import/+page.svelte index 8020297..ddc744d 100644 --- a/src/routes/transporting/import/+page.svelte +++ b/src/routes/transporting/import/+page.svelte @@ -13,6 +13,7 @@ import ProfileView from "$components/features/ProfileView.svelte"; import GroupView from "$components/features/GroupView.svelte"; import { goto } from "$app/navigation"; + import type { SameSiteStatus } from "$lib/extension/EntitiesTransporter"; let importedString = $state(''); let errorMessage = $state(''); @@ -50,6 +51,8 @@ const transporter = new BulkEntitiesTransporter(); + let lastImportStatus = $state(null); + function tryBulkImport() { importedProfiles = []; importedGroups = []; @@ -75,6 +78,8 @@ return; } + lastImportStatus = transporter.lastImportSameSiteStatus; + if (importedEntities.length) { for (const targetImportedEntity of importedEntities) { switch (targetImportedEntity.type) { @@ -180,7 +185,25 @@ {:else} Cancel Import -
+ {#if lastImportStatus !== 'same'} +
+ {/if} +
+ {#if lastImportStatus === "different"} +

+ Warning! + Looks like these entities were exported for the different extension! There are many differences between tagging + systems of Furobooru and Derpibooru, so make sure to check if these settings are correct before using them! +

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

+ Warning! + We couldn't verify if these settings are meant for this site or not. There are many differences between tagging + systems of Furbooru and Derpibooru, so make sure to check if these settings are correct before using them. +

+ {/if} + {#if importedProfiles.length}
@@ -234,7 +257,7 @@ diff --git a/src/styles/colors.scss b/src/styles/colors.scss index 7bdfe9e..40e72bb 100644 --- a/src/styles/colors.scss +++ b/src/styles/colors.scss @@ -27,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; diff --git a/src/styles/injectable/tag.scss b/src/styles/injectable/tag.scss index 68dd70c..bbec107 100644 --- a/src/styles/injectable/tag.scss +++ b/src/styles/injectable/tag.scss @@ -1,4 +1,5 @@ @use '../colors'; +@use '../environment'; .tag { background: colors.$tag-background; @@ -9,9 +10,13 @@ padding: 0 4px; display: flex; + @if environment.$current-site == 'derpibooru' { + border: 1px solid colors.$tag-border; + } + .remove { content: "x"; margin-left: 6px; cursor: pointer; } -} \ No newline at end of file +} From 7b532735ec151d11ff931b5bd92600ae635e9aae Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sat, 9 Aug 2025 16:00:58 +0400 Subject: [PATCH 42/42] Bumped version to 0.5.0 --- manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/manifest.json b/manifest.json index 1fdb8c2..f61bc04 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "Furbooru Tagging Assistant", "description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.", - "version": "0.4.5", + "version": "0.5.0", "browser_specific_settings": { "gecko": { "id": "furbooru-tagging-assistant@thecore.city" diff --git a/package-lock.json b/package-lock.json index c425cde..2acab45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "furbooru-tagging-assistant", - "version": "0.4.5", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "furbooru-tagging-assistant", - "version": "0.4.5", + "version": "0.5.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", "amd-lite": "^1.0.1", diff --git a/package.json b/package.json index b7a9a94..ef887c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "furbooru-tagging-assistant", - "version": "0.4.5", + "version": "0.5.0", "private": true, "scripts": { "build": "npm run build:popup && npm run build:extension",