From 371bce133ebcaf13a482d673c57d9bac87dbb9fd Mon Sep 17 00:00:00 2001 From: KoloMl Date: Wed, 19 Feb 2025 03:07:03 +0400 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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 69dc645de28e548c9cc7a924b0102e53dde06f8f Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 27 Jul 2025 18:27:19 +0400 Subject: [PATCH 09/18] 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 10/18] 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 11/18] 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 12/18] 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 13/18] 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 14/18] 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 15/18] 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 16/18] 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 17/18] 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 18/18] 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,