feat: publish av-tools 0.1.0
This commit is contained in:
228
src/presets/user-presets.ts
Normal file
228
src/presets/user-presets.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
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'));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user