229 lines
6.8 KiB
TypeScript
229 lines
6.8 KiB
TypeScript
import type { UserExportPreset } from './preset.types';
|
|
import {
|
|
parseUserPresetJson,
|
|
serializeUserPreset,
|
|
validateUserPreset,
|
|
} from './preset-validation';
|
|
|
|
export interface UserPresetPersistence {
|
|
load(): Promise<readonly UserExportPreset[]>;
|
|
save(presets: readonly UserExportPreset[]): Promise<void>;
|
|
}
|
|
|
|
export class UserPresetRegistry {
|
|
readonly #persistence: UserPresetPersistence;
|
|
#presets = new Map<string, UserExportPreset>();
|
|
|
|
constructor(persistence: UserPresetPersistence) {
|
|
this.#persistence = persistence;
|
|
}
|
|
|
|
async initialize(): Promise<void> {
|
|
const loaded = await this.#persistence.load();
|
|
const validated = loaded.map((preset) => {
|
|
const result = validateUserPreset(preset);
|
|
if (!result.valid || !result.preset) {
|
|
throw new TypeError(
|
|
`Stored preset is invalid: ${result.errors.join('; ')}`
|
|
);
|
|
}
|
|
return result.preset;
|
|
});
|
|
this.#presets = new Map(validated.map((preset) => [preset.id, preset]));
|
|
}
|
|
|
|
list(): readonly UserExportPreset[] {
|
|
return Object.freeze(
|
|
[...this.#presets.values()].sort((left, right) =>
|
|
left.name.localeCompare(right.name)
|
|
)
|
|
);
|
|
}
|
|
|
|
get(id: string): UserExportPreset | undefined {
|
|
return this.#presets.get(id);
|
|
}
|
|
|
|
async save(preset: UserExportPreset): Promise<void> {
|
|
const result = validateUserPreset(preset);
|
|
if (!result.valid || !result.preset) {
|
|
throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`);
|
|
}
|
|
this.#presets.set(result.preset.id, result.preset);
|
|
await this.#persist();
|
|
}
|
|
|
|
async clone(
|
|
id: string,
|
|
newId: string,
|
|
newName: string
|
|
): Promise<UserExportPreset> {
|
|
const original = this.#presets.get(id);
|
|
if (!original) {
|
|
throw new RangeError(`Unknown preset: ${id}`);
|
|
}
|
|
const cloned = { ...original, id: newId, name: newName };
|
|
await this.save(cloned);
|
|
return this.#presets.get(newId) as UserExportPreset;
|
|
}
|
|
|
|
async rename(id: string, name: string): Promise<void> {
|
|
const preset = this.#presets.get(id);
|
|
if (!preset) {
|
|
throw new RangeError(`Unknown preset: ${id}`);
|
|
}
|
|
await this.save({ ...preset, name });
|
|
}
|
|
|
|
async delete(id: string): Promise<boolean> {
|
|
const deleted = this.#presets.delete(id);
|
|
if (deleted) {
|
|
await this.#persist();
|
|
}
|
|
return deleted;
|
|
}
|
|
|
|
async reset(): Promise<void> {
|
|
this.#presets.clear();
|
|
await this.#persist();
|
|
}
|
|
|
|
export(id: string): string {
|
|
const preset = this.#presets.get(id);
|
|
if (!preset) {
|
|
throw new RangeError(`Unknown preset: ${id}`);
|
|
}
|
|
return serializeUserPreset(preset);
|
|
}
|
|
|
|
async import(json: string): Promise<UserExportPreset> {
|
|
const preset = parseUserPresetJson(json);
|
|
await this.save(preset);
|
|
return preset;
|
|
}
|
|
|
|
async #persist(): Promise<void> {
|
|
await this.#persistence.save(this.list());
|
|
}
|
|
}
|
|
|
|
export class MemoryUserPresetPersistence implements UserPresetPersistence {
|
|
#presets: readonly UserExportPreset[] = [];
|
|
|
|
async load(): Promise<readonly UserExportPreset[]> {
|
|
return this.#presets;
|
|
}
|
|
|
|
async save(presets: readonly UserExportPreset[]): Promise<void> {
|
|
this.#presets = structuredClone(presets);
|
|
}
|
|
}
|
|
|
|
export interface IndexedDbUserPresetPersistenceOptions {
|
|
readonly databaseName?: string;
|
|
readonly storeName?: string;
|
|
readonly indexedDB?: IDBFactory;
|
|
}
|
|
|
|
/**
|
|
* Stores only validated typed preset documents. A dedicated database avoids
|
|
* version races with durable job metadata stores that may be opened first.
|
|
*/
|
|
export class IndexedDbUserPresetPersistence implements UserPresetPersistence {
|
|
readonly #databaseName: string;
|
|
readonly #storeName: string;
|
|
readonly #factory: IDBFactory;
|
|
#database?: Promise<IDBDatabase>;
|
|
|
|
constructor(options: IndexedDbUserPresetPersistenceOptions = {}) {
|
|
const factory = options.indexedDB ?? globalThis.indexedDB;
|
|
if (!factory) {
|
|
throw new Error('IndexedDB is unavailable');
|
|
}
|
|
this.#factory = factory;
|
|
this.#databaseName = options.databaseName ?? 'av-tools-user-presets-v1';
|
|
this.#storeName = options.storeName ?? 'presets';
|
|
}
|
|
|
|
async load(): Promise<readonly UserExportPreset[]> {
|
|
const request = (await this.#store('readonly')).get('all');
|
|
const value = await requestResult<unknown>(request);
|
|
if (value === undefined) {
|
|
return Object.freeze([]);
|
|
}
|
|
if (!Array.isArray(value)) {
|
|
throw new TypeError('Stored user presets are invalid');
|
|
}
|
|
return Object.freeze(
|
|
value.map((preset) => {
|
|
const result = validateUserPreset(preset);
|
|
if (!result.valid || !result.preset) {
|
|
throw new TypeError(
|
|
`Stored preset is invalid: ${result.errors.join('; ')}`
|
|
);
|
|
}
|
|
return result.preset;
|
|
})
|
|
);
|
|
}
|
|
|
|
async save(presets: readonly UserExportPreset[]): Promise<void> {
|
|
const validated = presets.map((preset) => {
|
|
const result = validateUserPreset(preset);
|
|
if (!result.valid || !result.preset) {
|
|
throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`);
|
|
}
|
|
return result.preset;
|
|
});
|
|
const store = await this.#store('readwrite');
|
|
const completion = transactionComplete(store.transaction);
|
|
await requestResult<IDBValidKey>(
|
|
store.put(structuredClone(validated), 'all')
|
|
);
|
|
await completion;
|
|
}
|
|
|
|
async #store(mode: IDBTransactionMode): Promise<IDBObjectStore> {
|
|
const database = await (this.#database ??= this.#open());
|
|
return database
|
|
.transaction(this.#storeName, mode)
|
|
.objectStore(this.#storeName);
|
|
}
|
|
|
|
#open(): Promise<IDBDatabase> {
|
|
return new Promise((resolve, reject) => {
|
|
const request = this.#factory.open(this.#databaseName, 1);
|
|
request.onupgradeneeded = () => {
|
|
if (!request.result.objectStoreNames.contains(this.#storeName)) {
|
|
request.result.createObjectStore(this.#storeName);
|
|
}
|
|
};
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () =>
|
|
reject(request.error ?? new Error('Could not open preset storage'));
|
|
request.onblocked = () =>
|
|
reject(new Error('Preset storage upgrade is blocked'));
|
|
});
|
|
}
|
|
}
|
|
|
|
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
|
|
return new Promise((resolve, reject) => {
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () =>
|
|
reject(request.error ?? new Error('Preset storage request failed'));
|
|
});
|
|
}
|
|
|
|
function transactionComplete(transaction: IDBTransaction): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
transaction.oncomplete = () => resolve();
|
|
transaction.onerror = () =>
|
|
reject(
|
|
transaction.error ?? new Error('Preset storage transaction failed')
|
|
);
|
|
transaction.onabort = () =>
|
|
reject(transaction.error ?? new Error('Preset storage was aborted'));
|
|
});
|
|
}
|