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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 ------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user