feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+171 -5
View File
@@ -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" ||