import { SudokuFormatError } from "../formats"; import { cloneProjectRecord, createProjectRecord, MAX_PROJECT_BYTES, normalizeProjectRecord, } from "./record"; import type { ProjectLibraryExport, ProjectLibraryQuery, SudokuProjectRecord, SudokuProjectSummary, } from "./types"; export const MAX_LIBRARY_PROJECTS = 256; export const MAX_MEMORY_LIBRARY_BYTES = 32 * 1_048_576; const DATABASE_VERSION = 2; const STORE_NAME = "projects"; const AUTOSAVE_STORE_NAME = "autosaves"; const DEFAULT_AUTOSAVE_SLOT = "current"; export type ProjectLibraryMode = "indexeddb" | "memory"; export interface ProjectLibraryOptions { readonly indexedDB?: IDBFactory | null; readonly databaseName?: string; } function summary(record: SudokuProjectRecord): SudokuProjectSummary { return { id: record.id, title: record.title, createdAt: record.createdAt, updatedAt: record.updatedAt, size: record.puzzle.size, completed: record.progress?.completed ?? false, tags: [...(record.tags ?? [])], thumbnail: record.thumbnail ?? record.puzzle.givens .map((value) => (value === 0 ? "." : String(value))) .join(""), }; } function matchesQuery( item: SudokuProjectSummary, query: ProjectLibraryQuery, ): boolean { const search = query.search?.trim().toLocaleLowerCase(); if ( search && !item.title.toLocaleLowerCase().includes(search) && !item.tags.some((tag) => tag.toLocaleLowerCase().includes(search)) ) { return false; } const tags = query.tags?.filter(Boolean) ?? []; if (tags.length > 0 && !tags.every((tag) => item.tags.includes(tag))) { return false; } return query.completed === undefined || item.completed === query.completed; } function bytes(record: SudokuProjectRecord): number { return new TextEncoder().encode(JSON.stringify(record)).byteLength; } function requestResult(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed.")); }); } function transactionDone(transaction: IDBTransaction): Promise { return new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed.")); transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted.")); }); } async function openDatabase( factory: IDBFactory, name: string, ): Promise { return await new Promise((resolve, reject) => { const request = factory.open(name, DATABASE_VERSION); request.onupgradeneeded = () => { const database = request.result; const store = database.objectStoreNames.contains(STORE_NAME) ? request.transaction!.objectStore(STORE_NAME) : database.createObjectStore(STORE_NAME, { keyPath: "id" }); if (!store.indexNames.contains("updatedAt")) { store.createIndex("updatedAt", "updatedAt"); } if (!store.indexNames.contains("title")) { store.createIndex("title", "title"); } if (!store.indexNames.contains("tags")) { store.createIndex("tags", "tags", { multiEntry: true }); } if (!database.objectStoreNames.contains(AUTOSAVE_STORE_NAME)) { database.createObjectStore(AUTOSAVE_STORE_NAME, { keyPath: "slot" }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error("Could not open IndexedDB.")); request.onblocked = () => reject(new Error("The project library database upgrade is blocked.")); }); } export class ProjectLibrary { readonly #factory: IDBFactory | null; readonly #databaseName: string; readonly #memory = new Map(); readonly #memoryAutosaves = new Map(); #database: Promise | undefined; #mode: ProjectLibraryMode; constructor(options: ProjectLibraryOptions = {}) { this.#factory = options.indexedDB === undefined ? typeof indexedDB === "undefined" ? null : indexedDB : options.indexedDB; this.#databaseName = options.databaseName ?? "sudoku-tools"; this.#mode = this.#factory === null ? "memory" : "indexeddb"; } get mode(): ProjectLibraryMode { return this.#mode; } async ready(): Promise { await this.#db(); return this.#mode; } async #db(): Promise { if (this.#mode === "memory" || this.#factory === null) return undefined; this.#database ??= openDatabase(this.#factory, this.#databaseName); try { return await this.#database; } catch { this.#mode = "memory"; this.#database = undefined; return undefined; } } #memoryTotal(replacement?: SudokuProjectRecord): number { let total = 0; for (const record of this.#memory.values()) { if (replacement !== undefined && record.id === replacement.id) continue; total += bytes(record); } return total + (replacement === undefined ? 0 : bytes(replacement)); } #memoryPut(record: SudokuProjectRecord): void { if ( !this.#memory.has(record.id) && this.#memory.size >= MAX_LIBRARY_PROJECTS ) { throw new SudokuFormatError( "STORAGE_LIMIT", `The fallback library is limited to ${MAX_LIBRARY_PROJECTS} projects.`, ); } if (this.#memoryTotal(record) > MAX_MEMORY_LIBRARY_BYTES) { throw new SudokuFormatError( "STORAGE_LIMIT", "The in-memory project library is full.", ); } this.#memory.set(record.id, cloneProjectRecord(record)); } async list( query: ProjectLibraryQuery = {}, ): Promise { const database = await this.#db(); if (database === undefined) { return [...this.#memory.values()] .map(summary) .filter((item) => matchesQuery(item, query)) .sort( (a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title), ); } try { const transaction = database.transaction(STORE_NAME, "readonly"); const records = await requestResult( transaction.objectStore(STORE_NAME).getAll(), ); await transactionDone(transaction); if (records.length > MAX_LIBRARY_PROJECTS) { throw new SudokuFormatError( "STORAGE_LIMIT", "The project library contains too many records.", ); } return records .map((record) => summary(normalizeProjectRecord(record))) .filter((item) => matchesQuery(item, query)) .sort( (a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title), ); } catch (error) { if (error instanceof SudokuFormatError) throw error; this.#mode = "memory"; return this.list(query); } } async get(id: string): Promise { const database = await this.#db(); if (database === undefined) { const record = this.#memory.get(id); return record === undefined ? undefined : cloneProjectRecord(record); } try { const transaction = database.transaction(STORE_NAME, "readonly"); const value = await requestResult( transaction.objectStore(STORE_NAME).get(id), ); await transactionDone(transaction); return value === undefined ? undefined : normalizeProjectRecord(value); } catch (error) { if (error instanceof SudokuFormatError) throw error; this.#mode = "memory"; return this.get(id); } } async put(value: SudokuProjectRecord): Promise { const record = normalizeProjectRecord(value); if (bytes(record) > MAX_PROJECT_BYTES) { throw new SudokuFormatError( "STORAGE_LIMIT", "The project is too large to save.", ); } const database = await this.#db(); if (database === undefined) { this.#memoryPut(record); return cloneProjectRecord(record); } try { const transaction = database.transaction(STORE_NAME, "readwrite"); const store = transaction.objectStore(STORE_NAME); const existing = await requestResult(store.getKey(record.id)); const count = await requestResult(store.count()); if (existing === undefined && count >= MAX_LIBRARY_PROJECTS) { transaction.abort(); throw new SudokuFormatError( "STORAGE_LIMIT", `The project library is limited to ${MAX_LIBRARY_PROJECTS} projects.`, ); } store.put(record); await transactionDone(transaction); return cloneProjectRecord(record); } catch (error) { if (error instanceof SudokuFormatError) throw error; this.#mode = "memory"; this.#memoryPut(record); return cloneProjectRecord(record); } } async delete(id: string): Promise { const database = await this.#db(); if (database === undefined) return this.#memory.delete(id); try { const transaction = database.transaction(STORE_NAME, "readwrite"); const store = transaction.objectStore(STORE_NAME); const exists = (await requestResult(store.getKey(id))) !== undefined; if (exists) store.delete(id); await transactionDone(transaction); return exists; } catch { this.#mode = "memory"; return this.#memory.delete(id); } } async clear(): Promise { const database = await this.#db(); if (database === undefined) { this.#memory.clear(); return; } try { const transaction = database.transaction(STORE_NAME, "readwrite"); transaction.objectStore(STORE_NAME).clear(); await transactionDone(transaction); } catch { this.#mode = "memory"; this.#memory.clear(); } } /** Persist the latest working state separately from explicit Library saves. */ async putAutosave( value: SudokuProjectRecord, slot = DEFAULT_AUTOSAVE_SLOT, ): Promise { if (!slot || slot.length > 128) { throw new SudokuFormatError( "INVALID_PROJECT", "Autosave slot is invalid.", ); } const record = normalizeProjectRecord(value); const database = await this.#db(); if (database === undefined) { this.#memoryAutosaves.set(slot, cloneProjectRecord(record)); return cloneProjectRecord(record); } try { const transaction = database.transaction( AUTOSAVE_STORE_NAME, "readwrite", ); transaction.objectStore(AUTOSAVE_STORE_NAME).put({ slot, record }); await transactionDone(transaction); return cloneProjectRecord(record); } catch { this.#mode = "memory"; this.#memoryAutosaves.set(slot, cloneProjectRecord(record)); return cloneProjectRecord(record); } } async getAutosave( slot = DEFAULT_AUTOSAVE_SLOT, ): Promise { const database = await this.#db(); if (database === undefined) { const record = this.#memoryAutosaves.get(slot); return record === undefined ? undefined : cloneProjectRecord(record); } try { const transaction = database.transaction(AUTOSAVE_STORE_NAME, "readonly"); const value = await requestResult( transaction.objectStore(AUTOSAVE_STORE_NAME).get(slot), ); await transactionDone(transaction); if (typeof value !== "object" || value === null || !("record" in value)) { return undefined; } return normalizeProjectRecord((value as { record: unknown }).record); } catch (error) { if (error instanceof SudokuFormatError) throw error; this.#mode = "memory"; return this.getAutosave(slot); } } async clearAutosave(slot = DEFAULT_AUTOSAVE_SLOT): Promise { const database = await this.#db(); if (database === undefined) { this.#memoryAutosaves.delete(slot); return; } try { const transaction = database.transaction( AUTOSAVE_STORE_NAME, "readwrite", ); transaction.objectStore(AUTOSAVE_STORE_NAME).delete(slot); await transactionDone(transaction); } catch { this.#mode = "memory"; this.#memoryAutosaves.delete(slot); } } async exportAll(): Promise { const summaries = await this.list(); const projects: SudokuProjectRecord[] = []; for (const item of summaries) { const record = await this.get(item.id); if (record !== undefined) projects.push(record); } return { schema: "de.add-ideas.sudoku-tools.library", version: 1, exportedAt: Date.now(), projects, }; } async exportSelected(ids: readonly string[]): Promise { const unique = [...new Set(ids)].slice(0, MAX_LIBRARY_PROJECTS); const projects: SudokuProjectRecord[] = []; for (const id of unique) { const record = await this.get(id); if (record !== undefined) projects.push(record); } return { schema: "de.add-ideas.sudoku-tools.library", version: 1, exportedAt: Date.now(), projects, }; } /** Create independent local copies without reusing source record IDs. */ async duplicateSelected(ids: readonly string[]): Promise { const unique = [...new Set(ids)].slice(0, MAX_LIBRARY_PROJECTS); const sources: SudokuProjectRecord[] = []; for (const id of unique) { const source = await this.get(id); if (source !== undefined) sources.push(source); } if ((await this.list()).length + sources.length > MAX_LIBRARY_PROJECTS) { throw new SudokuFormatError( "STORAGE_LIMIT", `Copying this selection would exceed the ${String(MAX_LIBRARY_PROJECTS)}-project limit.`, ); } let copied = 0; for (const source of sources) { const now = Date.now() + copied; await this.put( createProjectRecord(source.puzzle, { title: `${source.title || "Untitled puzzle"} copy`, progress: source.progress, tags: source.tags, now, }), ); copied += 1; } return copied; } async importAll(value: unknown, replace = false): Promise { if ( typeof value !== "object" || value === null || (value as { schema?: unknown }).schema !== "de.add-ideas.sudoku-tools.library" || (value as { version?: unknown }).version !== 1 || !Array.isArray((value as { projects?: unknown }).projects) ) { throw new SudokuFormatError( "INVALID_LIBRARY", "Unsupported project library export.", ); } const raw = (value as { projects: unknown[] }).projects; if (raw.length > MAX_LIBRARY_PROJECTS) { throw new SudokuFormatError( "STORAGE_LIMIT", "The import contains too many projects.", ); } const records = raw.map(normalizeProjectRecord); if (replace) await this.clear(); for (const record of records) await this.put(record); return records.length; } } export function createProjectLibrary( options?: ProjectLibraryOptions, ): ProjectLibrary { return new ProjectLibrary(options); }