/** * 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(r: IDBRequest): Promise { return new Promise((resolve, reject) => { r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); }); } function done(tx: IDBTransaction): Promise { 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 { 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 { const tx = this.db.transaction('meta', 'readonly'); const all = await req(tx.objectStore('meta').getAll() as IDBRequest); return all.sort((a, b) => b.updatedAt - a.updatedAt); } async getMeta(id: string): Promise { const tx = this.db.transaction('meta', 'readonly'); return req(tx.objectStore('meta').get(id) as IDBRequest); } async getContent(id: string): Promise { const tx = this.db.transaction('content', 'readonly'); const rec = await req(tx.objectStore('content').get(id) as IDBRequest); return rec?.json; } /** Create or overwrite a document -- meta and content in one transaction. */ async put(meta: DocMeta, json: string): Promise { 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>): Promise { const tx = this.db.transaction('meta', 'readwrite'); const store = tx.objectStore('meta'); const cur = await req(store.get(id) as IDBRequest); if (cur) store.put({ ...cur, ...patch, id }); await done(tx); } async remove(id: string): Promise { 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)}`; }