Store multiple documents in IndexedDB with a document list

Documents now live in an IndexedDB library instead of a single
localStorage slot: each has its own ?doc=<id> URL, a title (editable in
the toolbar, used for export file names) and appears in Datei ›
Dokumente (open, rename, duplicate, delete). New/sample/import create
new documents instead of replacing the open one. Tabs stay in step via
BroadcastChannel. The old localStorage document is migrated once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-28 19:48:49 +02:00
co-authored by Claude Opus 5.5
parent 8e974cc870
commit 873cd50eac
10 changed files with 867 additions and 56 deletions
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { IDBFactory } from 'fake-indexeddb';
import { DocumentLibrary, newDocId, type DocMeta } from '../library';
const meta = (id: string, title: string, updatedAt: number): DocMeta => ({
id,
title,
createdAt: 1,
updatedAt,
});
/** a fresh, empty in-memory IndexedDB per test */
const openLib = () => DocumentLibrary.open(new IDBFactory());
describe('DocumentLibrary', () => {
it('stores meta and content separately and reads them back', async () => {
const lib = await openLib();
await lib.put(meta('a', 'Budget', 10), '{"x":1}');
expect(await lib.getMeta('a')).toEqual(meta('a', 'Budget', 10));
expect(await lib.getContent('a')).toBe('{"x":1}');
expect(await lib.getContent('missing')).toBeUndefined();
});
it('lists documents most recently changed first', async () => {
const lib = await openLib();
await lib.put(meta('old', 'Alt', 10), '{}');
await lib.put(meta('new', 'Neu', 30), '{}');
await lib.put(meta('mid', 'Mitte', 20), '{}');
expect((await lib.list()).map((d) => d.id)).toEqual(['new', 'mid', 'old']);
});
it('patchMeta renames without touching content; unknown ids are ignored', async () => {
const lib = await openLib();
await lib.put(meta('a', 'Alt', 10), '{"keep":true}');
await lib.patchMeta('a', { title: 'Neu' });
await lib.patchMeta('nope', { title: 'X' });
expect((await lib.getMeta('a'))?.title).toBe('Neu');
expect(await lib.getContent('a')).toBe('{"keep":true}');
expect(await lib.list()).toHaveLength(1);
});
it('remove deletes meta and content', async () => {
const lib = await openLib();
await lib.put(meta('a', 'A', 10), '{}');
await lib.remove('a');
expect(await lib.getMeta('a')).toBeUndefined();
expect(await lib.getContent('a')).toBeUndefined();
expect(await lib.list()).toEqual([]);
});
it('keeps data across re-opening the same database', async () => {
const factory = new IDBFactory();
await (await DocumentLibrary.open(factory)).put(meta('a', 'A', 10), '{"v":2}');
expect(await (await DocumentLibrary.open(factory)).getContent('a')).toBe('{"v":2}');
});
});
describe('newDocId', () => {
it('produces distinct v4 UUIDs', () => {
const a = newDocId();
expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(newDocId()).not.toBe(a);
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* The browser's document library: every numolus document the user has in
* this browser, stored in IndexedDB. Two object stores, so listing documents
* never has to read their (possibly large) contents:
*
* meta { id, title, createdAt, updatedAt } -- the document list
* content { id, json } -- the document itself
*
* `json` is the serialised DocumentState (the same JSON a .num file holds).
* Plain promise wrapper over the IndexedDB API, no library; `factory` is
* injectable so tests can run against an in-memory implementation.
*/
export interface DocMeta {
id: string;
title: string;
/** epoch ms */
createdAt: number;
/** epoch ms of the last *content* change (not cursor moves) */
updatedAt: number;
}
interface ContentRecord {
id: string;
json: string;
}
const DB_NAME = 'numolus';
const DB_VERSION = 1;
function req<T>(r: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
});
}
function done(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error ?? new Error('Transaktion abgebrochen.'));
});
}
export class DocumentLibrary {
private constructor(private db: IDBDatabase) {}
static async open(factory: IDBFactory = indexedDB): Promise<DocumentLibrary> {
const r = factory.open(DB_NAME, DB_VERSION);
r.onupgradeneeded = () => {
const db = r.result;
if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', { keyPath: 'id' });
if (!db.objectStoreNames.contains('content')) db.createObjectStore('content', { keyPath: 'id' });
};
const db = await req(r);
// another tab upgrading the schema later must not be blocked by us
db.onversionchange = () => db.close();
return new DocumentLibrary(db);
}
/** All documents, most recently changed first. */
async list(): Promise<DocMeta[]> {
const tx = this.db.transaction('meta', 'readonly');
const all = await req(tx.objectStore('meta').getAll() as IDBRequest<DocMeta[]>);
return all.sort((a, b) => b.updatedAt - a.updatedAt);
}
async getMeta(id: string): Promise<DocMeta | undefined> {
const tx = this.db.transaction('meta', 'readonly');
return req(tx.objectStore('meta').get(id) as IDBRequest<DocMeta | undefined>);
}
async getContent(id: string): Promise<string | undefined> {
const tx = this.db.transaction('content', 'readonly');
const rec = await req(tx.objectStore('content').get(id) as IDBRequest<ContentRecord | undefined>);
return rec?.json;
}
/** Create or overwrite a document -- meta and content in one transaction. */
async put(meta: DocMeta, json: string): Promise<void> {
const tx = this.db.transaction(['meta', 'content'], 'readwrite');
tx.objectStore('meta').put(meta);
tx.objectStore('content').put({ id: meta.id, json } satisfies ContentRecord);
await done(tx);
}
/** Update only the listing data (rename). No-op for an unknown id. */
async patchMeta(id: string, patch: Partial<Omit<DocMeta, 'id'>>): Promise<void> {
const tx = this.db.transaction('meta', 'readwrite');
const store = tx.objectStore('meta');
const cur = await req(store.get(id) as IDBRequest<DocMeta | undefined>);
if (cur) store.put({ ...cur, ...patch, id });
await done(tx);
}
async remove(id: string): Promise<void> {
const tx = this.db.transaction(['meta', 'content'], 'readwrite');
tx.objectStore('meta').delete(id);
tx.objectStore('content').delete(id);
await done(tx);
}
}
export function newDocId(): string {
// randomUUID exists only in secure contexts (https, localhost)
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
+11 -6
View File
@@ -44,16 +44,21 @@ const breathe = () => new Promise((r) => setTimeout(r, 0));
// ---- export ---------------------------------------------------------
export function exportJson(store: DocumentStore): void {
downloadText('dokument.json', serializeDocument(store.snapshot()), 'application/json');
/** A document title as a file name (without extension). */
export function fileBaseName(title: string): string {
return title.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '-').trim() || 'dokument';
}
export function exportJson(store: DocumentStore, name = 'dokument'): void {
downloadText(`${fileBaseName(name)}.json`, serializeDocument(store.snapshot()), 'application/json');
}
/** Save as the native `.num` file (the JSON document, zipped -- see num.ts).
* The zip code loads on first use, like the xlsx support. */
export async function exportNum(store: DocumentStore): Promise<void> {
export async function exportNum(store: DocumentStore, name = 'dokument'): Promise<void> {
const { packNum, NUM_MIMETYPE } = await import('./num');
const bytes = packNum(serializeDocument(store.snapshot()));
downloadBlob('dokument.num', new Blob([bytes], { type: NUM_MIMETYPE }));
downloadBlob(`${fileBaseName(name)}.num`, new Blob([bytes], { type: NUM_MIMETYPE }));
}
/** The document JSON inside a `.num` file -- then imported like a `.json`. */
@@ -68,8 +73,8 @@ export function exportActiveTableCsv(store: DocumentStore, opts: CsvExportOption
downloadText(`${table.name}.csv`, tableToCsv(store, table.id, opts), 'text/csv;charset=utf-8');
}
export async function exportXlsx(store: DocumentStore): Promise<void> {
downloadBlob('arbeitsmappe.xlsx', await documentToXlsx(store));
export async function exportXlsx(store: DocumentStore, name = 'arbeitsmappe'): Promise<void> {
downloadBlob(`${fileBaseName(name)}.xlsx`, await documentToXlsx(store));
}
// ---- import: JSON ------------------------------------------------