mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2025-12-23 23:02:58 +00:00
Merge pull request #98 from koloml/feature/bulk-import-and-export
Bulk import/export of tagging profiles and groups
This commit is contained in:
@@ -9,10 +9,19 @@
|
||||
value?: string;
|
||||
href?: string;
|
||||
children?: Snippet;
|
||||
/**
|
||||
* Click event received by the checkbox input element.
|
||||
*/
|
||||
onclick?: MouseEventHandler<HTMLInputElement>;
|
||||
oninput?: FormEventHandler<HTMLInputElement>;
|
||||
/**
|
||||
* Click event received by the menu item instead of the checkbox element.
|
||||
*/
|
||||
onitemclick?: MouseEventHandler<HTMLElement>;
|
||||
}
|
||||
|
||||
let checkboxElement: HTMLInputElement;
|
||||
|
||||
let {
|
||||
checked = $bindable(),
|
||||
name = undefined,
|
||||
@@ -21,16 +30,61 @@
|
||||
children,
|
||||
onclick,
|
||||
oninput,
|
||||
onitemclick,
|
||||
}: MenuCheckboxItemProps = $props();
|
||||
|
||||
/**
|
||||
* Prevent clicks from getting sent to the menu link if user clicked directly on the checkbox.
|
||||
* @param originalEvent
|
||||
*/
|
||||
function stopPropagationAndPassCallback(originalEvent: MouseEvent) {
|
||||
originalEvent.stopPropagation();
|
||||
onclick?.(originalEvent as MouseEvent & { currentTarget: HTMLInputElement });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and try to toggle checkbox if href was not provided for the menu item.
|
||||
*/
|
||||
function maybeToggleCheckboxOnOuterLinkClicked(event: MouseEvent) {
|
||||
// Call the event handler if present.
|
||||
if (onitemclick) {
|
||||
onitemclick(event as MouseEvent & {currentTarget: HTMLElement});
|
||||
|
||||
// If it was prevented, then don't attempt to run checkbox toggling workaround.
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// When menu link does not contain any link, we should just treat clicks on it as toggle action on checkbox.
|
||||
if (!href) {
|
||||
checked = !checked;
|
||||
|
||||
// Since we've toggled it using the `checked` property and input does not trigger `onclick` when we do something
|
||||
// programmatically, we should create valid event and send it back to the parent component so it will handle
|
||||
// whatever it wants.
|
||||
if (oninput) {
|
||||
// Uhh, not sure if this is how it should be done, but we need `currentTarget` to point on the checkbox. Without
|
||||
// dispatching the event, we can't fill it normally. Also, input element does not return us untrusted input
|
||||
// events automatically. Probably should make the util function later in case I'd need something similar.
|
||||
checkboxElement.addEventListener('input', (inputEvent: Event) => {
|
||||
oninput(inputEvent as Event & { currentTarget: HTMLInputElement });
|
||||
}, { once: true })
|
||||
|
||||
checkboxElement.dispatchEvent(new InputEvent('input'));
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<MenuLink {href}>
|
||||
<input bind:checked={checked} {name} onclick={stopPropagationAndPassCallback} {oninput} type="checkbox" {value}>
|
||||
<MenuLink {href} onclick={maybeToggleCheckboxOnOuterLinkClicked}>
|
||||
<input bind:this={checkboxElement}
|
||||
bind:checked={checked}
|
||||
{name}
|
||||
onclick={stopPropagationAndPassCallback}
|
||||
{oninput}
|
||||
type="checkbox"
|
||||
{value}>
|
||||
{@render children?.()}
|
||||
</MenuLink>
|
||||
|
||||
|
||||
80
src/lib/extension/BulkEntitiesTransporter.ts
Normal file
80
src/lib/extension/BulkEntitiesTransporter.ts
Normal file
@@ -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<App.EntityNamesMap[EntityName]>;
|
||||
}
|
||||
|
||||
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<ImportableEntityObject<StorageEntity>>, null, 2);
|
||||
}
|
||||
|
||||
exportToCompressedJSON(entities: StorageEntity[]): string {
|
||||
return compressToEncodedURIComponent(
|
||||
this.exportToJSON(entities)
|
||||
);
|
||||
}
|
||||
|
||||
static isList(targetObject: any): targetObject is ImportableElementsList<ImportableEntityObject<StorageEntity>> {
|
||||
return targetObject.$type
|
||||
&& targetObject.$type === 'list'
|
||||
&& targetObject.elements
|
||||
&& Array.isArray(targetObject.elements);
|
||||
}
|
||||
|
||||
static #transporters: TransportersMapping = {
|
||||
profiles: new EntitiesTransporter(MaintenanceProfile),
|
||||
groups: new EntitiesTransporter(TagGroup),
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,13 @@ export default class EntitiesTransporter<EntityType> {
|
||||
*/
|
||||
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<EntityType> {
|
||||
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<string, any>): 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<EntityType> {
|
||||
);
|
||||
}
|
||||
|
||||
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<EntityType> {
|
||||
throw new TypeError('Only storage entities could be exported!');
|
||||
}
|
||||
|
||||
const exportableObject = exportEntityToObject(
|
||||
entityObject,
|
||||
this.#entityName
|
||||
return exportEntityToObject(
|
||||
this.#entityName,
|
||||
entityObject
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.stringify(exportableObject, null, 2);
|
||||
exportToJSON(entityObject: EntityType): string {
|
||||
return JSON.stringify(
|
||||
this.exportToObject(entityObject),
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
exportToCompressedJSON(entityObject: EntityType): string {
|
||||
|
||||
@@ -24,18 +24,22 @@ export default abstract class StorageEntity<SettingsType extends Object = {}> {
|
||||
return this.#settings;
|
||||
}
|
||||
|
||||
public static readonly _entityName: string = "entity";
|
||||
get type() {
|
||||
return (this.constructor as typeof StorageEntity)._entityName;
|
||||
}
|
||||
|
||||
public static readonly _entityName: keyof App.EntityNamesMap | "entity" = "entity";
|
||||
|
||||
async save() {
|
||||
await EntitiesController.updateEntity(
|
||||
(this.constructor as typeof StorageEntity)._entityName,
|
||||
this.type,
|
||||
this
|
||||
);
|
||||
}
|
||||
|
||||
async delete() {
|
||||
await EntitiesController.deleteEntity(
|
||||
(this.constructor as typeof StorageEntity)._entityName,
|
||||
this.type,
|
||||
this.id
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,5 +30,5 @@ export default class MaintenanceProfile extends StorageEntity<MaintenanceProfile
|
||||
return super.save();
|
||||
}
|
||||
|
||||
public static readonly _entityName = "profiles";
|
||||
public static readonly _entityName: keyof App.EntityNamesMap = "profiles";
|
||||
}
|
||||
|
||||
@@ -21,5 +21,5 @@ export default class TagGroup extends StorageEntity<TagGroupSettings> {
|
||||
});
|
||||
}
|
||||
|
||||
static _entityName = 'groups';
|
||||
public static readonly _entityName: keyof App.EntityNamesMap = "groups";
|
||||
}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
import type { ImportableEntityObject } from "$lib/extension/transporting/importables";
|
||||
|
||||
type ExporterFunction<EntityType extends StorageEntity> = (entity: EntityType) => ImportableEntityObject<EntityType>;
|
||||
|
||||
type ExportersMap = {
|
||||
[EntityName in keyof App.EntityNamesMap]: (entity: App.EntityNamesMap[EntityName]) => Record<string, any>
|
||||
};
|
||||
[EntityName in keyof App.EntityNamesMap]: ExporterFunction<App.EntityNamesMap[EntityName]>;
|
||||
}
|
||||
|
||||
const entitiesExporters: ExportersMap = {
|
||||
profiles: entity => {
|
||||
return {
|
||||
v: 1,
|
||||
$type: "profiles",
|
||||
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<any>, entityName: string): Record<string, any> {
|
||||
export function exportEntityToObject<EntityName extends keyof App.EntityNamesMap>(
|
||||
entityName: EntityName,
|
||||
entityInstance: App.EntityNamesMap[EntityName]
|
||||
): ImportableEntityObject<App.EntityNamesMap[EntityName]> {
|
||||
if (!(entityName in entitiesExporters) || !entitiesExporters.hasOwnProperty(entityName)) {
|
||||
throw new Error(`Missing exporter for entity: ${entityName}`);
|
||||
}
|
||||
|
||||
return entitiesExporters[entityName as keyof App.EntityNamesMap].call(null, entityInstance);
|
||||
return entitiesExporters[entityName].call(null, entityInstance);
|
||||
}
|
||||
|
||||
36
src/lib/extension/transporting/importables.ts
Normal file
36
src/lib/extension/transporting/importables.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export interface ImportableElement<Type extends string = string> {
|
||||
/**
|
||||
* Type of importable. Should be unique to properly import everything.
|
||||
*/
|
||||
$type: Type;
|
||||
}
|
||||
|
||||
export interface ImportableElementsList<ElementsType extends ImportableElement = ImportableElement> extends ImportableElement<"list"> {
|
||||
/**
|
||||
* List of elements inside. Elements could be of any type and should be checked and mapped.
|
||||
*/
|
||||
elements: ElementsType[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Base information on the object which should be present on every entity.
|
||||
*/
|
||||
export interface BaseImportableEntity extends ImportableElement<keyof App.EntityNamesMap> {
|
||||
/**
|
||||
* Numeric version of the entity for upgrading.
|
||||
*/
|
||||
v: number;
|
||||
/**
|
||||
* Unique ID of the entity.
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility type which combines base importable object and the entity type interfaces together. It strips away any types
|
||||
* defined for the properties, since imported object can not be trusted and should be type-checked by the validators.
|
||||
*/
|
||||
export type ImportableEntityObject<EntityType extends StorageEntity> = { [ObjectKey in keyof BaseImportableEntity]: any }
|
||||
& { [SettingKey in keyof EntityType["settings"]]: any };
|
||||
@@ -1,32 +1,12 @@
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
/**
|
||||
* Base information on the object which should be present on every entity.
|
||||
*/
|
||||
interface BaseImportableObject {
|
||||
/**
|
||||
* Numeric version of the entity for upgrading.
|
||||
*/
|
||||
v: number;
|
||||
/**
|
||||
* Unique ID of the entity.
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility type which combines base importable object and the entity type interfaces together. It strips away any types
|
||||
* defined for the properties, since imported object can not be trusted and should be type-checked by the validators.
|
||||
*/
|
||||
type ImportableObject<EntityType extends StorageEntity> = { [ObjectKey in keyof BaseImportableObject]: any }
|
||||
& { [SettingKey in keyof EntityType["settings"]]: any };
|
||||
import type { ImportableEntityObject } from "$lib/extension/transporting/importables";
|
||||
|
||||
/**
|
||||
* Function for validating the entities.
|
||||
* @todo Probably would be better to replace the throw-catch method with some kind of result-error returning type.
|
||||
* Errors are only properly definable in the JSDoc.
|
||||
*/
|
||||
type ValidationFunction<EntityType extends StorageEntity> = (importedObject: ImportableObject<EntityType>) => void;
|
||||
type ValidationFunction<EntityType extends StorageEntity> = (importedObject: ImportableEntityObject<EntityType>) => void;
|
||||
|
||||
/**
|
||||
* Mapping of validation functions for all entities present in the extension. Key is a name of entity and value is a
|
||||
@@ -36,39 +16,70 @@ type EntitiesValidationMap = {
|
||||
[EntityKey in keyof App.EntityNamesMap]?: ValidationFunction<App.EntityNamesMap[EntityKey]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the following value is defined, not empty and is of correct type.
|
||||
* @param value Value to be checked.
|
||||
*/
|
||||
function validateRequiredString(value: unknown): boolean {
|
||||
return Boolean(value && typeof value === 'string');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the following value is not set or is a valid array.
|
||||
* @param value Value to be checked.
|
||||
*/
|
||||
function validateOptionalArray(value: unknown): boolean {
|
||||
return typeof value === 'undefined' || value === null || Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of validators for each entity. Function should throw the error if validation failed.
|
||||
*/
|
||||
const entitiesValidators: EntitiesValidationMap = {
|
||||
profiles: importedObject => {
|
||||
if (importedObject.v !== 1) {
|
||||
throw new Error('Unsupported version!');
|
||||
if (!importedObject.v || importedObject.v > 2) {
|
||||
throw new Error('Unsupported profile version!');
|
||||
}
|
||||
|
||||
if (
|
||||
!importedObject.id
|
||||
|| typeof importedObject.id !== "string"
|
||||
|| !importedObject.name
|
||||
|| typeof importedObject.name !== "string"
|
||||
|| !importedObject.tags
|
||||
|| !Array.isArray(importedObject.tags)
|
||||
!validateRequiredString(importedObject?.id)
|
||||
|| !validateRequiredString(importedObject?.name)
|
||||
|| !validateOptionalArray(importedObject?.tags)
|
||||
) {
|
||||
throw new Error('Invalid profile format detected!');
|
||||
}
|
||||
}
|
||||
},
|
||||
groups: importedObject => {
|
||||
if (!importedObject.v || importedObject.v > 2) {
|
||||
throw new Error('Unsupported group version!');
|
||||
}
|
||||
|
||||
if (
|
||||
!validateRequiredString(importedObject?.id)
|
||||
|| !validateRequiredString(importedObject?.name)
|
||||
|| !validateOptionalArray(importedObject?.tags)
|
||||
|| !validateOptionalArray(importedObject?.prefixes)
|
||||
|| !validateOptionalArray(importedObject?.suffixes)
|
||||
) {
|
||||
throw new Error('Invalid group format detected!');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the structure of the entity.
|
||||
* @param importedObject Object imported from JSON.
|
||||
* @param entityName Name of the entity to validate. Should be loaded from the entity class.
|
||||
* @param importedObject Object imported from JSON.
|
||||
* @throws {Error} Error in case validation failed with the reason stored in the message.
|
||||
*/
|
||||
export function validateImportedEntity(importedObject: any, entityName: string) {
|
||||
export function validateImportedEntity<EntityName extends keyof App.EntityNamesMap>(
|
||||
entityName: EntityName,
|
||||
importedObject: any
|
||||
) {
|
||||
if (!entitiesValidators.hasOwnProperty(entityName)) {
|
||||
console.error(`Trying to validate entity without the validator present! Entity name: ${entityName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
entitiesValidators[entityName as keyof EntitiesValidationMap]!.call(null, importedObject);
|
||||
entitiesValidators[entityName]!.call(null, importedObject);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
|
||||
<MenuItem href="/features/groups">Tag Groups</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/transporting">Import/Export</MenuItem>
|
||||
<MenuItem href="/preferences">Preferences</MenuItem>
|
||||
<MenuItem href="/about">About</MenuItem>
|
||||
</Menu>
|
||||
|
||||
11
src/routes/transporting/+page.svelte
Normal file
11
src/routes/transporting/+page.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/transporting/export">Export</MenuItem>
|
||||
<MenuItem href="/transporting/import">Import</MenuItem>
|
||||
</Menu>
|
||||
135
src/routes/transporting/export/+page.svelte
Normal file
135
src/routes/transporting/export/+page.svelte
Normal file
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import BulkEntitiesTransporter from "$lib/extension/BulkEntitiesTransporter";
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
|
||||
const bulkTransporter = new BulkEntitiesTransporter();
|
||||
|
||||
let exportAllProfiles = $state(false);
|
||||
let exportAllGroups = $state(false);
|
||||
|
||||
let displayExportedString = $state(false);
|
||||
let shouldUseCompressed = $state(true);
|
||||
let compressedExport = $state('');
|
||||
let plainExport = $state('');
|
||||
let selectedExportString = $derived(shouldUseCompressed ? compressedExport : plainExport);
|
||||
|
||||
const exportedEntities: Record<keyof App.EntityNamesMap, Record<string, boolean>> = $state({
|
||||
profiles: {},
|
||||
groups: {},
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (displayExportedString) {
|
||||
const elementsToExport: StorageEntity[] = [];
|
||||
|
||||
$maintenanceProfiles.forEach(profile => {
|
||||
if (exportedEntities.profiles[profile.id]) {
|
||||
elementsToExport.push(profile);
|
||||
}
|
||||
});
|
||||
|
||||
$tagGroups.forEach(group => {
|
||||
if (exportedEntities.groups[group.id]) {
|
||||
elementsToExport.push(group);
|
||||
}
|
||||
});
|
||||
|
||||
plainExport = bulkTransporter.exportToJSON(elementsToExport);
|
||||
compressedExport = bulkTransporter.exportToCompressedJSON(elementsToExport);
|
||||
}
|
||||
});
|
||||
|
||||
function refreshAreAllEntitiesChecked() {
|
||||
requestAnimationFrame(() => {
|
||||
exportAllProfiles = $maintenanceProfiles.every(profile => exportedEntities.profiles[profile.id]);
|
||||
exportAllGroups = $tagGroups.every(group => exportedEntities.groups[group.id]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a handler to toggle on or off specific entity type.
|
||||
* @param targetEntity Code of the entity.
|
||||
*/
|
||||
function createToggleAllOnUserInput(targetEntity: keyof App.EntityNamesMap) {
|
||||
return () => {
|
||||
requestAnimationFrame(() => {
|
||||
switch (targetEntity) {
|
||||
case "profiles":
|
||||
$maintenanceProfiles.forEach(profile => exportedEntities.profiles[profile.id] = exportAllProfiles);
|
||||
break;
|
||||
case "groups":
|
||||
$tagGroups.forEach(group => exportedEntities.groups[group.id] = exportAllGroups);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Trying to toggle unsupported entity type: ${targetEntity}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExportedStringDisplay() {
|
||||
displayExportedString = !displayExportedString;
|
||||
}
|
||||
|
||||
function toggleExportedStringType() {
|
||||
shouldUseCompressed = !shouldUseCompressed;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !displayExportedString}
|
||||
<Menu>
|
||||
<MenuItem href="/transporting" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
{#if $maintenanceProfiles.length}
|
||||
<MenuCheckboxItem bind:checked={exportAllProfiles} oninput={createToggleAllOnUserInput('profiles')}>
|
||||
Export All Profiles
|
||||
</MenuCheckboxItem>
|
||||
{#each $maintenanceProfiles as profile}
|
||||
<MenuCheckboxItem bind:checked={exportedEntities.profiles[profile.id]} oninput={refreshAreAllEntitiesChecked}>
|
||||
Profile: {profile.settings.name}
|
||||
</MenuCheckboxItem>
|
||||
{/each}
|
||||
<hr>
|
||||
{/if}
|
||||
{#if $tagGroups.length}
|
||||
<MenuCheckboxItem bind:checked={exportAllGroups} oninput={createToggleAllOnUserInput('groups')}>
|
||||
Export All Groups
|
||||
</MenuCheckboxItem>
|
||||
{#each $tagGroups as group}
|
||||
<MenuCheckboxItem bind:checked={exportedEntities.groups[group.id]} oninput={refreshAreAllEntitiesChecked}>
|
||||
Group: {group.settings.name}
|
||||
</MenuCheckboxItem>
|
||||
{/each}
|
||||
<hr>
|
||||
{/if}
|
||||
<MenuItem icon="file-export" onclick={toggleExportedStringDisplay}>Export Selected</MenuItem>
|
||||
</Menu>
|
||||
{:else}
|
||||
<Menu>
|
||||
<MenuItem onclick={toggleExportedStringDisplay} icon="arrow-left">Back to Selection</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Export string">
|
||||
<textarea readonly rows="6">{selectedExportString}</textarea>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem onclick={toggleExportedStringType}>
|
||||
Switch Format:
|
||||
{#if shouldUseCompressed}
|
||||
Base64-Encoded
|
||||
{:else}
|
||||
Raw JSON
|
||||
{/if}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
{/if}
|
||||
248
src/routes/transporting/import/+page.svelte
Normal file
248
src/routes/transporting/import/+page.svelte
Normal file
@@ -0,0 +1,248 @@
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import BulkEntitiesTransporter from "$lib/extension/BulkEntitiesTransporter";
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
import ProfileView from "$components/features/ProfileView.svelte";
|
||||
import GroupView from "$components/features/GroupView.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
let importedString = $state('');
|
||||
let errorMessage = $state('');
|
||||
|
||||
let importedProfiles = $state<MaintenanceProfile[]>([]);
|
||||
let importedGroups = $state<TagGroup[]>([]);
|
||||
|
||||
let saveAllProfiles = $state(false);
|
||||
let saveAllGroups = $state(false);
|
||||
|
||||
let selectedEntities: Record<keyof App.EntityNamesMap, Record<string, boolean>> = $state({
|
||||
profiles: {},
|
||||
groups: {},
|
||||
});
|
||||
|
||||
let previewedEntity = $state<StorageEntity | null>(null);
|
||||
|
||||
const existingProfilesMap = $derived(
|
||||
$maintenanceProfiles.reduce((map, profile) => {
|
||||
map.set(profile.id, profile);
|
||||
return map;
|
||||
}, new Map<string, MaintenanceProfile>())
|
||||
);
|
||||
|
||||
const existingGroupsMap = $derived(
|
||||
$tagGroups.reduce((map, group) => {
|
||||
map.set(group.id, group);
|
||||
return map;
|
||||
}, new Map<string, TagGroup>())
|
||||
);
|
||||
|
||||
const hasImportedEntities = $derived(
|
||||
Boolean(importedProfiles.length || importedGroups.length)
|
||||
);
|
||||
|
||||
const transporter = new BulkEntitiesTransporter();
|
||||
|
||||
function tryBulkImport() {
|
||||
importedProfiles = [];
|
||||
importedGroups = [];
|
||||
errorMessage = '';
|
||||
|
||||
importedString = importedString.trim();
|
||||
|
||||
if (!importedString) {
|
||||
errorMessage = 'Nothing to import.';
|
||||
return;
|
||||
}
|
||||
|
||||
let importedEntities: StorageEntity[] = [];
|
||||
|
||||
try {
|
||||
if (importedString.startsWith('{')) {
|
||||
importedEntities = transporter.parseAndImportFromJSON(importedString);
|
||||
} else {
|
||||
importedEntities = transporter.parseAndImportFromCompressedJSON(importedString);
|
||||
}
|
||||
} catch (importError) {
|
||||
errorMessage = importError instanceof Error ? importError.message : 'Unknown error!';
|
||||
return;
|
||||
}
|
||||
|
||||
if (importedEntities.length) {
|
||||
for (const targetImportedEntity of importedEntities) {
|
||||
switch (targetImportedEntity.type) {
|
||||
case "profiles":
|
||||
importedProfiles.push(targetImportedEntity as MaintenanceProfile);
|
||||
break;
|
||||
case "groups":
|
||||
importedGroups.push(targetImportedEntity as TagGroup);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unprocessed entity type detected: ${targetImportedEntity.type}`, targetImportedEntity);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errorMessage = "Import string contains nothing!";
|
||||
}
|
||||
}
|
||||
|
||||
function cancelImport() {
|
||||
importedProfiles = [];
|
||||
importedGroups = [];
|
||||
}
|
||||
|
||||
function refreshAreAllEntitiesChecked() {
|
||||
requestAnimationFrame(() => {
|
||||
saveAllProfiles = importedProfiles.every(profile => selectedEntities.profiles[profile.id]);
|
||||
saveAllGroups = importedGroups.every(group => selectedEntities.groups[group.id]);
|
||||
});
|
||||
}
|
||||
|
||||
function createToggleAllOnUserInput(entityType: keyof App.EntityNamesMap) {
|
||||
return () => {
|
||||
requestAnimationFrame(() => {
|
||||
switch (entityType) {
|
||||
case "profiles":
|
||||
importedProfiles.forEach(profile => selectedEntities.profiles[profile.id] = saveAllProfiles);
|
||||
break;
|
||||
case "groups":
|
||||
importedGroups.forEach(group => selectedEntities.groups[group.id] = saveAllGroups);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Trying to toggle unsupported entity type: ${entityType}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createShowPreviewForEntity(entity: StorageEntity) {
|
||||
return (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
previewedEntity = entity;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSelectedEntities() {
|
||||
Promise.allSettled([
|
||||
Promise.allSettled(
|
||||
importedProfiles
|
||||
.filter(profile => selectedEntities.profiles[profile.id])
|
||||
.map(profile => profile.save())
|
||||
),
|
||||
Promise.allSettled(
|
||||
importedGroups
|
||||
.filter(group => selectedEntities.groups[group.id])
|
||||
.map(group => group.save())
|
||||
),
|
||||
]).then(() => {
|
||||
goto("/transporting");
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !hasImportedEntities}
|
||||
<Menu>
|
||||
<MenuItem href="/transporting" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if errorMessage}
|
||||
<p class="error">{errorMessage}</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
</Menu>
|
||||
{/if}
|
||||
<FormContainer>
|
||||
<FormControl label="Import string">
|
||||
<textarea bind:value={importedString} rows="6"></textarea>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem onclick={tryBulkImport}>Import & Preview</MenuItem>
|
||||
</Menu>
|
||||
{:else if previewedEntity}
|
||||
<Menu>
|
||||
<MenuItem onclick={() => previewedEntity = null} icon="arrow-left">Back to Selection</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if previewedEntity instanceof MaintenanceProfile}
|
||||
<ProfileView profile={previewedEntity}></ProfileView>
|
||||
{:else if previewedEntity instanceof TagGroup}
|
||||
<GroupView group={previewedEntity}></GroupView>
|
||||
{/if}
|
||||
{:else}
|
||||
<Menu>
|
||||
<MenuItem onclick={cancelImport} icon="arrow-left">Cancel Import</MenuItem>
|
||||
<hr>
|
||||
{#if importedProfiles.length}
|
||||
<hr>
|
||||
<MenuCheckboxItem bind:checked={saveAllProfiles} oninput={createToggleAllOnUserInput('profiles')}>
|
||||
Import All Profiles
|
||||
</MenuCheckboxItem>
|
||||
{#each importedProfiles as candidateProfile}
|
||||
<MenuCheckboxItem
|
||||
bind:checked={selectedEntities.profiles[candidateProfile.id]}
|
||||
oninput={refreshAreAllEntitiesChecked}
|
||||
onitemclick={createShowPreviewForEntity(candidateProfile)}
|
||||
>
|
||||
{#if existingProfilesMap.has(candidateProfile.id)}
|
||||
Update:
|
||||
{:else}
|
||||
New:
|
||||
{/if}
|
||||
{candidateProfile.settings.name || 'Unnamed Profile'}
|
||||
</MenuCheckboxItem>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if importedGroups.length}
|
||||
<hr>
|
||||
<MenuCheckboxItem
|
||||
bind:checked={saveAllGroups}
|
||||
oninput={createToggleAllOnUserInput('groups')}
|
||||
>
|
||||
Import All Groups
|
||||
</MenuCheckboxItem>
|
||||
{#each importedGroups as candidateGroup}
|
||||
<MenuCheckboxItem
|
||||
bind:checked={selectedEntities.groups[candidateGroup.id]}
|
||||
oninput={refreshAreAllEntitiesChecked}
|
||||
onitemclick={createShowPreviewForEntity(candidateGroup)}
|
||||
>
|
||||
{#if existingGroupsMap.has(candidateGroup.id)}
|
||||
Update:
|
||||
{:else}
|
||||
New:
|
||||
{/if}
|
||||
{candidateGroup.settings.name || 'Unnamed Group'}
|
||||
</MenuCheckboxItem>
|
||||
{/each}
|
||||
{/if}
|
||||
<hr>
|
||||
<MenuItem onclick={saveSelectedEntities}>
|
||||
Imported Selected
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
|
||||
.error {
|
||||
padding: 5px 24px;
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
background: colors.$error-background;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user