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>
114 lines
4.1 KiB
TypeScript
114 lines
4.1 KiB
TypeScript
/**
|
|
* 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)}`;
|
|
}
|