/** * Glue between the document store (one open document) and where documents * live: * * - **local** documents: in this browser's IndexedDB (src/io/library.ts), * URL `/?doc=`. Without an account, that's all there is. * - **server** documents: on the numolus server (src/io/api.ts), URL * `/d/`. With an account, new documents are created there and local * ones can be uploaded; they can be shared. A share link * (`/d/#k=`) also opens a server document without an account -- * the token is kept in this browser (localStorage) and sent along. * * Local documents are saved by the store's debounced save (`persist()`). * Server documents are edited live, together with everyone who has them * open: a CollabSession (src/collab/session.ts) keeps the store and the * shared Yjs document in step over ActionCable. * * Other tabs: a BroadcastChannel keeps lists current and lets a local * document open in two tabs follow the other tab's saves. */ import { computed, reactive, shallowRef } from 'vue'; import { useDocument, setPersistHandler, setReadOnlyNotifier, contentFingerprint } from './document'; import type { DocumentState } from './types'; import { DocumentLibrary, newDocId, type DocMeta } from '../io/library'; import { api, ApiError, type Role, type ServerDoc, type ServerDocMeta } from '../io/api'; import { validateDocument } from '../io/json'; import { createBlankDocument, createSeedDocument } from '../data/seed'; import { useToasts } from '../composables/useToasts'; import { useSession } from './session'; import { CollabSession } from '../collab/session'; /** The single-document localStorage keys used before the library existed -- * read once and moved into the library as its first document. */ const LEGACY_KEYS = ['numolus:doc:v2', 'numbers-study:doc:v2']; /** share-link tokens this browser holds without an account */ const LINKS_KEY = 'numolus:links'; const CHANNEL = 'numolus:library'; const DEFAULT_TITLE = 'Unbenannt'; const APP_TITLE = 'numolus'; const ANON_COLORS = ['#e67e22', '#2980b9', '#27ae60', '#8e44ad', '#c0392b', '#16a085']; /** Anonymous people are shown as two random words, e.g. "Kiesel Komet". */ const ANON_WORDS_A = ['Kiesel', 'Wolke', 'Anker', 'Nebel', 'Kompass', 'Laterne', 'Moos', 'Feder', 'Bernstein', 'Brise', 'Quelle', 'Funke']; const ANON_WORDS_B = ['Komet', 'Fuchs', 'Welle', 'Ahorn', 'Leuchtturm', 'Otter', 'Gipfel', 'Mond', 'Dachs', 'Birke', 'Falke', 'Stern']; export type DocSource = 'local' | 'server'; export interface DocRef { source: DocSource; id: string; } export interface LinkDoc { id: string; token: string; title: string; role: Role; } type Msg = | { type: 'list' } | { type: 'saved'; id: string } | { type: 'deleted'; id: string } | { type: 'hello'; id: string } | { type: 'here'; id: string }; function metaOf(d: ServerDoc): ServerDocMeta { const { id, title, role, owner_name, updated_at, updated_by_name } = d; return { id, title, role, owner_name, updated_at, updated_by_name }; } function readLinks(): LinkDoc[] { try { return JSON.parse(localStorage.getItem(LINKS_KEY) || '[]') as LinkDoc[]; } catch { return []; } } function writeLinks(links: LinkDoc[]) { try { localStorage.setItem(LINKS_KEY, JSON.stringify(links)); } catch { /* private mode -- the link itself still works */ } } /** Name + colour shown to the others: the account, or a random animal. */ function presenceIdentity(user: { name: string; color: string } | null) { if (user) return { name: user.name, color: user.color }; const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]; return { name: `${pick(ANON_WORDS_A)} ${pick(ANON_WORDS_B)}`, color: pick(ANON_COLORS) }; } function createLibrary() { const store = useDocument(); const toasts = useToasts(); const session = useSession(); const state = reactive({ ready: false, /** false = IndexedDB unavailable (private mode in some browsers, ...) */ available: true, /** local documents (IndexedDB) */ docs: [] as DocMeta[], /** server documents of the signed-in user: own + shared with them */ serverDocs: [] as ServerDocMeta[], /** documents opened via share link without an account */ linkDocs: readLinks(), current: null as DocRef | null, /** the open server document's metadata (title, role, ...) */ server: null as ServerDocMeta | null, }); /** the live session of the open server document */ const collab = shallowRef(null); const identity = presenceIdentity(session.state.user); const currentTitle = computed(() => { const c = state.current; if (!c) return DEFAULT_TITLE; if (c.source === 'server') return state.server?.title ?? DEFAULT_TITLE; return state.docs.find((d) => d.id === c.id)?.title ?? DEFAULT_TITLE; }); /** role in the open document; local documents are always the user's own */ const currentRole = computed(() => state.current?.source === 'server' ? (state.server?.role ?? 'viewer') : 'owner', ); const canEdit = computed(() => currentRole.value !== 'viewer'); /** others on the open document right now */ const peers = computed(() => collab.value?.peers ?? []); const online = computed(() => collab.value?.online.value ?? true); let lib: DocumentLibrary | null = null; let channel: BroadcastChannel | null = null; /** content fingerprint of what's saved for the open local document */ let savedContent = ''; /** `updatedAt` of the open local document as this tab last wrote or read it */ let knownUpdatedAt = 0; /** local writes run one after another, in order */ let writeQueue: Promise = Promise.resolve(); const warnedShared = new Set(); const post = (m: Msg) => channel?.postMessage(m); const enqueue = (job: () => Promise) => { writeQueue = writeQueue.then(job).catch((err) => { console.error(err); toasts.error(`Speichern fehlgeschlagen: ${(err as Error).message}`); }); return writeQueue; }; const shareToken = (id: string) => state.linkDocs.find((l) => l.id === id)?.token ?? null; const isCurrent = (ref: DocRef) => state.current?.source === ref.source && state.current.id === ref.id; async function refreshList() { if (lib) state.docs = await lib.list(); } async function refreshServerList() { if (!session.signedIn.value) return; try { state.serverDocs = (await api<{ documents: ServerDocMeta[] }>('GET', '/documents')).documents; } catch (err) { console.error(err); } } function uniqueTitle(base: string, taken: string[]): string { const set = new Set(taken.map((t) => t.toLowerCase())); if (!set.has(base.toLowerCase())) return base; for (let n = 2; ; n++) { const t = `${base} ${n}`; if (!set.has(t.toLowerCase())) return t; } } function setUrl(ref: DocRef, push: boolean) { const url = new URL(location.href); url.hash = ''; if (ref.source === 'server') { url.pathname = `/d/${ref.id}`; url.searchParams.delete('doc'); } else { url.pathname = '/'; url.searchParams.set('doc', ref.id); } if (url.href === location.href) return; if (push) history.pushState(null, '', url); else history.replaceState(null, '', url); } function updateWindowTitle() { document.title = `${currentTitle.value} – ${APP_TITLE}`; } function applyReadOnly() { store.ui.readOnly = state.current?.source === 'server' && currentRole.value === 'viewer' ? 'Nur Lesen – du kannst dieses Dokument ansehen, aber nicht bearbeiten.' : null; } /** Put `next` into the store as the open document. */ function show(ref: DocRef, next: DocumentState, keepView = false) { if (keepView && next.sheets.some((s) => s.id === store.doc.activeSheetId)) { // another tab's save: take the content, keep this tab's cursor/sheet next.activeSheetId = store.doc.activeSheetId; next.activeObjectId = store.doc.activeObjectId; next.selection = store.doc.selection; } state.current = ref; store.replaceDocument(next); // it came from where it's saved -- writing it straight back is pointless store.cancelSave(); savedContent = contentFingerprint(store.doc); updateWindowTitle(); } // ---- persistence (local documents) --------------------------------------- function persist(doc: DocumentState) { const c = state.current; if (!c || c.source !== 'local' || !lib) return; // server documents: CollabSession const id = c.id; const json = JSON.stringify(doc); const fp = contentFingerprint(doc); const changed = fp !== savedContent; savedContent = fp; void enqueue(async () => { const meta = (await lib!.getMeta(id)) ?? { id, title: currentTitle.value, createdAt: Date.now(), updatedAt: Date.now(), }; if (!changed) { // only the cursor/active sheet moved: saved so reopening restores // it, but never over newer content another tab has written meanwhile if (meta.updatedAt !== knownUpdatedAt) return; await lib!.put(meta, json); return; } const updatedAt = Math.max(Date.now(), meta.updatedAt + 1); await lib!.put({ ...meta, updatedAt }, json); if (isCurrent({ source: 'local', id })) knownUpdatedAt = updatedAt; await refreshList(); post({ type: 'saved', id }); }); } // ---- leaving / opening ----------------------------------------------- async function leaveCurrent() { const c = state.current; if (!c) return; if (c.source === 'server') { collab.value?.destroy(); collab.value = null; store.setUndoProvider(null); state.server = null; } else { store.flushSave(); await writeQueue; } } async function openLocal(id: string, opts: { push?: boolean } = {}): Promise { if (!lib) return false; if (isCurrent({ source: 'local', id })) return true; const [meta, json] = await Promise.all([lib.getMeta(id), lib.getContent(id)]); if (!meta || json === undefined) return false; let next: DocumentState; try { next = validateDocument(JSON.parse(json)); } catch (err) { toasts.error(`Dokument konnte nicht geladen werden: ${(err as Error).message}`); return false; } await leaveCurrent(); show({ source: 'local', id }, next); knownUpdatedAt = meta.updatedAt; applyReadOnly(); setUrl({ source: 'local', id }, opts.push ?? true); post({ type: 'hello', id }); return true; } async function openServer(id: string, opts: { push?: boolean } = {}): Promise { if (isCurrent({ source: 'server', id })) return true; let meta: ServerDocMeta; try { const r = await api<{ document: ServerDoc }>('GET', `/documents/${id}?content=0`, { shareToken: shareToken(id) }); meta = metaOf(r.document); } catch (err) { toasts.error( err instanceof ApiError && err.status === 404 ? 'Dieses Dokument gibt es nicht, oder du hast keinen Zugriff darauf.' : `Dokument konnte nicht geladen werden: ${(err as Error).message}`, ); return false; } const ref: DocRef = { source: 'server', id }; const s = new CollabSession(id, shareToken(id), identity, { onTitle: (title) => { if (!isCurrent(ref) || !state.server) return; state.server = { ...state.server, title }; updateWindowTitle(); void refreshServerList(); }, onRole: (role) => { if (!isCurrent(ref) || !state.server || state.server.role === role) return; state.server = { ...state.server, role }; applyReadOnly(); toasts.info(role === 'viewer' ? 'Du kannst dieses Dokument jetzt nur noch ansehen.' : 'Du kannst dieses Dokument jetzt bearbeiten.'); }, onRevoked: () => { if (!isCurrent(ref)) return; toasts.error('Du hast keinen Zugriff mehr auf dieses Dokument.'); state.linkDocs = state.linkDocs.filter((l) => l.id !== id); writeLinks(state.linkDocs); void refreshServerList().then(() => { state.current = null; collab.value?.destroy(); collab.value = null; store.setUndoProvider(null); return openFallback(); }); }, }); let next: DocumentState; try { next = await s.start(); } catch (err) { s.destroy(); toasts.error(`Dokument konnte nicht geöffnet werden: ${(err as Error).message}`); return false; } await leaveCurrent(); state.server = { ...meta, role: s.role }; collab.value = s; show(ref, next); store.setUndoProvider(s.attach()); // remember the title for link-only documents const link = state.linkDocs.find((l) => l.id === id); if (link && (link.title !== meta.title || link.role !== meta.role)) { Object.assign(link, { title: meta.title, role: meta.role }); writeLinks(state.linkDocs); } applyReadOnly(); setUrl(ref, opts.push ?? true); return true; } function open(ref: DocRef, opts: { push?: boolean } = {}) { return ref.source === 'server' ? openServer(ref.id, opts) : openLocal(ref.id, opts); } // ---- creating -------------------------------------------------------- /** Add a document and open it: on the server when signed in, else locally. */ async function create(doc: DocumentState, title: string): Promise { const t = title.trim() || DEFAULT_TITLE; if (session.signedIn.value) { try { const r = await api<{ document: ServerDocMeta }>('POST', '/documents', { body: { title: uniqueTitle(t, state.serverDocs.map((d) => d.title)), content: JSON.stringify(doc) }, }); await refreshServerList(); post({ type: 'list' }); await openServer(r.document.id); return { source: 'server', id: r.document.id }; } catch (err) { toasts.error(`Anlegen auf dem Server fehlgeschlagen: ${(err as Error).message}`); return null; } } if (!lib) { store.replaceDocument(doc); // no storage at all: at least show it return null; } const id = newDocId(); const now = Date.now(); await enqueue(() => lib!.put({ id, title: uniqueTitle(t, state.docs.map((d) => d.title)), createdAt: now, updatedAt: now }, JSON.stringify(doc)), ); await refreshList(); post({ type: 'list' }); await openLocal(id); return { source: 'local', id }; } const createBlank = () => create(createBlankDocument(), DEFAULT_TITLE); const createSample = () => create(createSeedDocument(), 'Muster · Umsatzanalyse'); // ---- managing ---------------------------------------------------------- async function rename(ref: DocRef, title: string) { const t = title.trim(); if (!t) return; if (ref.source === 'server') { try { const r = await api<{ document: ServerDocMeta }>('PATCH', `/documents/${ref.id}`, { body: { title: t }, shareToken: shareToken(ref.id), }); if (isCurrent(ref) && state.server) state.server = { ...state.server, title: r.document.title }; await refreshServerList(); } catch (err) { toasts.error(`Umbenennen fehlgeschlagen: ${(err as Error).message}`); } } else { if (!lib) return; await enqueue(() => lib!.patchMeta(ref.id, { title: t })); await refreshList(); } if (isCurrent(ref)) updateWindowTitle(); post({ type: 'list' }); } async function contentOf(ref: DocRef): Promise<{ title: string; json: string } | null> { if (isCurrent(ref)) { // the open document: straight from the store, always current store.flushSave(); await writeQueue; return { title: currentTitle.value, json: JSON.stringify(store.doc) }; } if (ref.source === 'server') { const r = await api<{ document: ServerDoc }>('GET', `/documents/${ref.id}`, { shareToken: shareToken(ref.id) }); return r.document.content === undefined ? null : { title: r.document.title, json: r.document.content }; } if (!lib) return null; const [meta, json] = await Promise.all([lib.getMeta(ref.id), lib.getContent(ref.id)]); return meta && json !== undefined ? { title: meta.title, json } : null; } /** A copy next to the original (server copies go to the own account). */ async function duplicate(ref: DocRef) { try { const src = await contentOf(ref); if (!src) return; const title = `${src.title} (Kopie)`; if (ref.source === 'server') { await api('POST', '/documents', { body: { title: uniqueTitle(title, state.serverDocs.map((d) => d.title)), content: src.json }, }); await refreshServerList(); } else if (lib) { const now = Date.now(); await enqueue(() => lib!.put( { id: newDocId(), title: uniqueTitle(title, state.docs.map((d) => d.title)), createdAt: now, updatedAt: now }, src.json, ), ); await refreshList(); } post({ type: 'list' }); } catch (err) { toasts.error(`Duplizieren fehlgeschlagen: ${(err as Error).message}`); } } /** Delete (own / local) or leave (shared with me / link). */ async function remove(ref: DocRef) { if (ref.source === 'server') { const wasCurrent = isCurrent(ref); if (wasCurrent) await leaveCurrent(); if (session.signedIn.value) { try { await api('DELETE', `/documents/${ref.id}`); } catch (err) { if (!(err instanceof ApiError && err.status === 404)) { toasts.error(`Löschen fehlgeschlagen: ${(err as Error).message}`); return; } } } state.linkDocs = state.linkDocs.filter((l) => l.id !== ref.id); writeLinks(state.linkDocs); await refreshServerList(); post({ type: 'list' }); if (wasCurrent) { state.current = null; await openFallback(); } return; } if (!lib) return; await enqueue(() => lib!.remove(ref.id)); await refreshList(); post({ type: 'deleted', id: ref.id }); if (isCurrent(ref)) { state.current = null; await openFallback(); } } /** * Move local documents into the account (server). Each one is removed from * this browser once it's safely on the server; an open one is reopened * from there. Returns the server id of each uploaded document. */ async function upload(localIds: string[]): Promise> { const done = new Map(); if (!lib || !session.signedIn.value) return done; for (const id of localIds) { try { const src = await contentOf({ source: 'local', id }); if (!src) continue; const r = await api<{ document: ServerDocMeta }>('POST', '/documents', { body: { title: src.title, content: src.json }, }); done.set(id, r.document.id); const wasCurrent = isCurrent({ source: 'local', id }); if (wasCurrent) await openServer(r.document.id, { push: false }); await enqueue(() => lib!.remove(id)); post({ type: 'deleted', id }); } catch (err) { toasts.error(`Hochladen fehlgeschlagen: ${(err as Error).message}`); } } await Promise.all([refreshList(), refreshServerList()]); post({ type: 'list' }); return done; } /** Most recently changed document, or a new blank one. */ async function openFallback() { state.current = null; const server = state.serverDocs[0]; const local = state.docs[0]; const serverNewer = server && (!local || Date.parse(server.updated_at) > local.updatedAt); if (serverNewer && (await openServer(server.id, { push: false }))) return; if (local && (await openLocal(local.id, { push: false }))) return; await createBlank(); } // ---- share links ------------------------------------------------------- /** Redeem `#k=` for document `id` (from the URL). */ async function joinByLink(id: string, token: string) { try { const r = await api<{ document: ServerDocMeta }>('POST', `/documents/${id}/join`, { body: { token } }); if (session.signedIn.value) { await refreshServerList(); // it's in the account now } else { state.linkDocs = [ { id, token, title: r.document.title, role: r.document.role }, ...state.linkDocs.filter((l) => l.id !== id), ]; writeLinks(state.linkDocs); } } catch { toasts.error('Dieser Link ist nicht (mehr) gültig.'); } } /** After signing in: documents opened via link become part of the account. */ async function adoptLinkDocs() { if (!session.signedIn.value || !state.linkDocs.length) return; for (const l of state.linkDocs) { await api('POST', `/documents/${l.id}/join`, { body: { token: l.token } }).catch(() => {}); } state.linkDocs = []; writeLinks([]); } // ---- other tabs -------------------------------------------------------- async function onMessage(m: Msg) { if (m.type === 'list') { await Promise.all([refreshList(), refreshServerList()]); state.linkDocs = readLinks(); updateWindowTitle(); return; } const cur = state.current?.source === 'local' ? state.current.id : null; if (m.type === 'saved') { await refreshList(); // our own unsaved edit wins (it's written right after); otherwise // follow -- a pending save that's only a cursor move doesn't count const localEdit = store.hasPendingSave() && contentFingerprint(store.doc) !== savedContent; if (m.id === cur && !localEdit && lib) { const [meta, json] = await Promise.all([lib.getMeta(m.id), lib.getContent(m.id)]); if (meta && json !== undefined && isCurrent({ source: 'local', id: m.id })) { show({ source: 'local', id: m.id }, validateDocument(JSON.parse(json)), true); knownUpdatedAt = meta.updatedAt; } } } else if (m.type === 'deleted') { await refreshList(); if (m.id === cur) { toasts.info('Dieses Dokument ist nicht mehr in diesem Browser (gelöscht oder hochgeladen).'); state.current = null; await refreshServerList(); await openFallback(); } } else if (m.id === cur) { // hello / here: the same local document is open in another tab if (m.type === 'hello') post({ type: 'here', id: m.id }); if (!warnedShared.has(m.id)) { warnedShared.add(m.id); toasts.info( 'Dieses Dokument ist auch in einem anderen Tab geöffnet. Gleichzeitige Änderungen können sich überschreiben.', ); } } } // ---- start ------------------------------------------------------------- async function migrateLegacy() { for (const key of LEGACY_KEYS) { let raw: string | null = null; try { raw = localStorage.getItem(key); } catch { return; } if (!raw) continue; try { const doc = validateDocument(JSON.parse(raw)); const now = Date.now(); await lib!.put({ id: newDocId(), title: 'Mein Dokument', createdAt: now, updatedAt: now }, JSON.stringify(doc)); } catch { continue; // corrupt -- leave it where it is } for (const k of LEGACY_KEYS) localStorage.removeItem(k); return; } } /** What the URL asks for: `/d/[#k=]` or `/?doc=`. */ function refFromUrl(): { ref: DocRef | null; token: string | null } { const m = location.pathname.match(/^\/d\/([0-9a-f-]{36})\/?$/i); if (m) { const token = new URLSearchParams(location.hash.slice(1)).get('k'); return { ref: { source: 'server', id: m[1] }, token }; } const id = new URL(location.href).searchParams.get('doc'); return { ref: id ? { source: 'local', id } : null, token: null }; } async function init() { setReadOnlyNotifier((why) => toasts.info(why)); try { lib = await DocumentLibrary.open(); await migrateLegacy(); await refreshList(); } catch (err) { console.error(err); lib = null; state.available = false; if (!session.signedIn.value) { toasts.error('Dokumente können in diesem Browser nicht gespeichert werden (IndexedDB nicht verfügbar).'); } } await adoptLinkDocs(); await refreshServerList(); if (typeof BroadcastChannel !== 'undefined') { channel = new BroadcastChannel(CHANNEL); channel.onmessage = (e: MessageEvent) => void onMessage(e.data); } setPersistHandler(persist); const { ref, token } = refFromUrl(); if (ref?.source === 'server' && token) await joinByLink(ref.id, token); if (ref && !(await open(ref, { push: false })) && ref.source === 'local') { toasts.error('Das angeforderte Dokument gibt es in diesem Browser nicht.'); } if (!state.current) await openFallback(); window.addEventListener('popstate', () => { const { ref: r } = refFromUrl(); if (r && !isCurrent(r)) void open(r, { push: false }); }); // don't lose the last moments of typing when the tab goes away window.addEventListener('pagehide', () => { store.flushSave(); collab.value?.destroy(); }); // back from the browser's page cache: the live session is gone window.addEventListener('pageshow', (e) => { if (e.persisted && state.current?.source === 'server') location.reload(); }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { store.flushSave(); collab.value?.flushLocal(); } }); state.ready = true; } return { state, currentTitle, currentRole, canEdit, peers, online, init, open, create, createBlank, createSample, rename, duplicate, remove, upload, refreshServerList, }; } let instance: ReturnType | null = null; export function useLibrary() { if (!instance) instance = createLibrary(); return instance; }