feat: complete advanced Sudoku workbench
This commit is contained in:
+171
-5
@@ -1,19 +1,23 @@
|
||||
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 = 1;
|
||||
const DATABASE_VERSION = 2;
|
||||
const STORE_NAME = "projects";
|
||||
const AUTOSAVE_STORE_NAME = "autosaves";
|
||||
const DEFAULT_AUTOSAVE_SLOT = "current";
|
||||
|
||||
export type ProjectLibraryMode = "indexeddb" | "memory";
|
||||
|
||||
@@ -30,9 +34,34 @@ function summary(record: SudokuProjectRecord): SudokuProjectSummary {
|
||||
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;
|
||||
}
|
||||
@@ -63,10 +92,21 @@ async function openDatabase(
|
||||
const request = factory.open(name, DATABASE_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = database.createObjectStore(STORE_NAME, { keyPath: "id" });
|
||||
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 = () =>
|
||||
@@ -80,6 +120,7 @@ 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;
|
||||
|
||||
@@ -143,11 +184,14 @@ export class ProjectLibrary {
|
||||
this.#memory.set(record.id, cloneProjectRecord(record));
|
||||
}
|
||||
|
||||
async list(): Promise<readonly SudokuProjectSummary[]> {
|
||||
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),
|
||||
);
|
||||
@@ -166,13 +210,14 @@ export class ProjectLibrary {
|
||||
}
|
||||
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();
|
||||
return this.list(query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +309,82 @@ export class ProjectLibrary {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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[] = [];
|
||||
@@ -279,6 +400,51 @@ export class ProjectLibrary {
|
||||
};
|
||||
}
|
||||
|
||||
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" ||
|
||||
|
||||
Reference in New Issue
Block a user