481 lines
15 KiB
TypeScript
481 lines
15 KiB
TypeScript
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<T>(request: IDBRequest<T>): Promise<T> {
|
|
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<void> {
|
|
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<IDBDatabase> {
|
|
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<string, SudokuProjectRecord>();
|
|
readonly #memoryAutosaves = new Map<string, SudokuProjectRecord>();
|
|
#database: Promise<IDBDatabase> | 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<ProjectLibraryMode> {
|
|
await this.#db();
|
|
return this.#mode;
|
|
}
|
|
|
|
async #db(): Promise<IDBDatabase | undefined> {
|
|
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<readonly SudokuProjectSummary[]> {
|
|
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<SudokuProjectRecord | undefined> {
|
|
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<SudokuProjectRecord> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<SudokuProjectRecord> {
|
|
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<SudokuProjectRecord | undefined> {
|
|
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<void> {
|
|
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<ProjectLibraryExport> {
|
|
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<ProjectLibraryExport> {
|
|
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<number> {
|
|
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<number> {
|
|
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);
|
|
}
|