mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2025-12-25 07:22: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:
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user