diff --git a/src/components/ui/menu/MenuCheckboxItem.svelte b/src/components/ui/menu/MenuCheckboxItem.svelte index 9591e8a..155144e 100644 --- a/src/components/ui/menu/MenuCheckboxItem.svelte +++ b/src/components/ui/menu/MenuCheckboxItem.svelte @@ -9,10 +9,19 @@ 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; + let { checked = $bindable(), name = undefined, @@ -21,16 +30,61 @@ children, onclick, oninput, + onitemclick, }: MenuCheckboxItemProps = $props(); + /** + * Prevent clicks from getting sent to the menu link if user clicked directly on the checkbox. + * @param originalEvent + */ function stopPropagationAndPassCallback(originalEvent: MouseEvent) { originalEvent.stopPropagation(); onclick?.(originalEvent as MouseEvent & { currentTarget: HTMLInputElement }); } + + /** + * Check and try to toggle checkbox if href was not provided for the menu item. + */ + function maybeToggleCheckboxOnOuterLinkClicked(event: MouseEvent) { + // Call the event handler if present. + if (onitemclick) { + onitemclick(event as MouseEvent & {currentTarget: HTMLElement}); + + // If it was prevented, then don't attempt to run checkbox toggling workaround. + if (event.defaultPrevented) { + return; + } + } + + // When menu link does not contain any link, we should just treat clicks on it as toggle action on checkbox. + if (!href) { + checked = !checked; + + // Since we've toggled it using the `checked` property and input does not trigger `onclick` when we do something + // programmatically, we should create valid event and send it back to the parent component so it will handle + // whatever it wants. + if (oninput) { + // Uhh, not sure if this is how it should be done, but we need `currentTarget` to point on the checkbox. Without + // dispatching the event, we can't fill it normally. Also, input element does not return us untrusted input + // events automatically. Probably should make the util function later in case I'd need something similar. + checkboxElement.addEventListener('input', (inputEvent: Event) => { + oninput(inputEvent as Event & { currentTarget: HTMLInputElement }); + }, { once: true }) + + checkboxElement.dispatchEvent(new InputEvent('input')); + } + } + } - - + + {@render children?.()} diff --git a/src/lib/extension/BulkEntitiesTransporter.ts b/src/lib/extension/BulkEntitiesTransporter.ts new file mode 100644 index 0000000..fb2f565 --- /dev/null +++ b/src/lib/extension/BulkEntitiesTransporter.ts @@ -0,0 +1,80 @@ +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 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) + ); + } + + 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' + && targetObject.elements + && Array.isArray(targetObject.elements); + } + + static #transporters: TransportersMapping = { + profiles: new EntitiesTransporter(MaintenanceProfile), + groups: new EntitiesTransporter(TagGroup), + } +} diff --git a/src/lib/extension/EntitiesTransporter.ts b/src/lib/extension/EntitiesTransporter.ts index 8e258e2..ac29569 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; } /** @@ -26,16 +32,16 @@ export default class EntitiesTransporter { this.#targetEntityConstructor = entityConstructor; } - importFromJSON(jsonString: string): EntityType { - const importedObject = this.#tryParsingAsJSON(jsonString); - - if (!importedObject) { - throw new Error('Invalid JSON!'); - } + isCorrectEntity(entityObject: unknown): entityObject is EntityType { + return entityObject instanceof this.#targetEntityConstructor; + } + 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, - this.#entityName ); return new this.#targetEntityConstructor( @@ -44,14 +50,24 @@ 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) ) } - exportToJSON(entityObject: EntityType): string { - if (!(entityObject instanceof this.#targetEntityConstructor)) { + exportToObject(entityObject: EntityType) { + if (!this.isCorrectEntity(entityObject)) { throw new TypeError('Transporter should be connected to the same entity to export!'); } @@ -59,12 +75,18 @@ export default class EntitiesTransporter { throw new TypeError('Only storage entities could be exported!'); } - const exportableObject = exportEntityToObject( - entityObject, - this.#entityName + return exportEntityToObject( + this.#entityName, + entityObject ); + } - return JSON.stringify(exportableObject, null, 2); + exportToJSON(entityObject: EntityType): string { + return JSON.stringify( + this.exportToObject(entityObject), + null, + 2 + ); } exportToCompressedJSON(entityObject: EntityType): string { diff --git a/src/lib/extension/base/StorageEntity.ts b/src/lib/extension/base/StorageEntity.ts index 88be756..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; } - public static readonly _entityName: string = "entity"; + get type() { + return (this.constructor as typeof StorageEntity)._entityName; + } + + public static readonly _entityName: keyof App.EntityNamesMap | "entity" = "entity"; async save() { await EntitiesController.updateEntity( - (this.constructor as typeof StorageEntity)._entityName, + this.type, this ); } async delete() { await EntitiesController.deleteEntity( - (this.constructor as typeof StorageEntity)._entityName, + this.type, this.id ); } 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"; } diff --git a/src/lib/extension/transporting/exporters.ts b/src/lib/extension/transporting/exporters.ts index 8477617..fe6886b 100644 --- a/src/lib/extension/transporting/exporters.ts +++ b/src/lib/extension/transporting/exporters.ts @@ -1,21 +1,28 @@ import StorageEntity from "$lib/extension/base/StorageEntity"; +import type { ImportableEntityObject } from "$lib/extension/transporting/importables"; + +type ExporterFunction = (entity: EntityType) => ImportableEntityObject; type ExportersMap = { - [EntityName in keyof App.EntityNamesMap]: (entity: App.EntityNamesMap[EntityName]) => Record -}; + [EntityName in keyof App.EntityNamesMap]: ExporterFunction; +} const entitiesExporters: ExportersMap = { profiles: entity => { return { - v: 1, + $type: "profiles", + v: 2, id: entity.id, name: entity.settings.name, tags: entity.settings.tags, + // Any exported profile should be considered non-temporary. + temporary: false, } }, groups: entity => { return { - v: 1, + $type: "groups", + v: 2, id: entity.id, name: entity.settings.name, tags: entity.settings.tags, @@ -27,10 +34,13 @@ const entitiesExporters: ExportersMap = { } }; -export function exportEntityToObject(entityInstance: StorageEntity, entityName: string): Record { +export function exportEntityToObject( + entityName: EntityName, + entityInstance: App.EntityNamesMap[EntityName] +): ImportableEntityObject { 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); } diff --git a/src/lib/extension/transporting/importables.ts b/src/lib/extension/transporting/importables.ts new file mode 100644 index 0000000..e60dca9 --- /dev/null +++ b/src/lib/extension/transporting/importables.ts @@ -0,0 +1,36 @@ +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 BaseImportableEntity extends ImportableElement { + /** + * Numeric version of the entity for upgrading. + */ + v: number; + /** + * Unique ID of the entity. + */ + id: string; +} + +/** + * Utility type which combines base importable object and the entity type interfaces together. It strips away any types + * defined for the properties, since imported object can not be trusted and should be type-checked by the validators. + */ +export type ImportableEntityObject = { [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 dbb1223..2efec8b 100644 --- a/src/lib/extension/transporting/validators.ts +++ b/src/lib/extension/transporting/validators.ts @@ -1,32 +1,12 @@ import type StorageEntity from "$lib/extension/base/StorageEntity"; - -/** - * Base information on the object which should be present on every entity. - */ -interface BaseImportableObject { - /** - * Numeric version of the entity for upgrading. - */ - v: number; - /** - * Unique ID of the entity. - */ - id: string; -} - -/** - * Utility type which combines base importable object and the entity type interfaces together. It strips away any types - * defined for the properties, since imported object can not be trusted and should be type-checked by the validators. - */ -type ImportableObject = { [ObjectKey in keyof BaseImportableObject]: any } - & { [SettingKey in keyof EntityType["settings"]]: any }; +import type { ImportableEntityObject } from "$lib/extension/transporting/importables"; /** * Function for validating the entities. * @todo Probably would be better to replace the throw-catch method with some kind of result-error returning type. * Errors are only properly definable in the JSDoc. */ -type ValidationFunction = (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 @@ -36,39 +16,70 @@ 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. */ const entitiesValidators: EntitiesValidationMap = { profiles: importedObject => { - if (importedObject.v !== 1) { - throw new Error('Unsupported version!'); + if (!importedObject.v || importedObject.v > 2) { + throw new Error('Unsupported profile version!'); } if ( - !importedObject.id - || typeof importedObject.id !== "string" - || !importedObject.name - || typeof importedObject.name !== "string" - || !importedObject.tags - || !Array.isArray(importedObject.tags) + !validateRequiredString(importedObject?.id) + || !validateRequiredString(importedObject?.name) + || !validateOptionalArray(importedObject?.tags) ) { throw new Error('Invalid profile format detected!'); } - } + }, + groups: importedObject => { + if (!importedObject.v || importedObject.v > 2) { + throw new Error('Unsupported group version!'); + } + + if ( + !validateRequiredString(importedObject?.id) + || !validateRequiredString(importedObject?.name) + || !validateOptionalArray(importedObject?.tags) + || !validateOptionalArray(importedObject?.prefixes) + || !validateOptionalArray(importedObject?.suffixes) + ) { + throw new Error('Invalid group format detected!'); + } + }, }; /** * Validate the structure of the entity. - * @param importedObject Object imported from JSON. * @param entityName Name of the entity to validate. Should be loaded from the entity class. + * @param importedObject Object imported from JSON. * @throws {Error} Error in case validation failed with the reason stored in the message. */ -export function validateImportedEntity(importedObject: any, entityName: string) { +export function validateImportedEntity( + entityName: 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); } 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..769ae5e --- /dev/null +++ b/src/routes/transporting/+page.svelte @@ -0,0 +1,11 @@ + + + + Back +
+ Export + Import +
diff --git a/src/routes/transporting/export/+page.svelte b/src/routes/transporting/export/+page.svelte new file mode 100644 index 0000000..be11514 --- /dev/null +++ b/src/routes/transporting/export/+page.svelte @@ -0,0 +1,135 @@ + + +{#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} 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} + +