Files
numolus/app/frontend/store/library.ts
T
wkhandClaude Opus 5.5 7fce972fdc Live collaboration with Yjs over ActionCable
All editors now work on a server document at the same time instead of
taking turns behind an edit lock.

- The document's content is a sequence of Yjs updates (document_updates);
  DocumentChannel stores and relays them immediately, checks access on
  subscribe and again whenever links or memberships change.
- app/frontend/collab/: the store stays position-based; a binding maps it
  to a Yjs form with stable row/column ids, so a row inserted by one
  person doesn't misplace what another is typing. Remote changes are
  applied only after pending local ones went into Yjs.
- Per-person undo (Y.UndoManager), presence via awareness: the others'
  selections as coloured boxes with names, avatars in the toolbar.
- Existing JSON documents convert on first open, deterministically, so
  two people opening one at once end up with the same Yjs state.
- Editors compact the stored updates after 200 and keep a JSON snapshot
  current for export and duplicating. Edit lock and polling are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:41:02 +02:00

708 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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=<id>`. Without an account, that's all there is.
* - **server** documents: on the numolus server (src/io/api.ts), URL
* `/d/<id>`. With an account, new documents are created there and local
* ones can be uploaded; they can be shared. A share link
* (`/d/<id>#k=<token>`) 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'];
const ANON_NAMES = ['Fuchs', 'Dachs', 'Luchs', 'Otter', 'Eule', 'Biber', 'Reh', 'Igel'];
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 = <T>(a: T[]) => a[Math.floor(Math.random() * a.length)];
return { name: `Anonym (${pick(ANON_NAMES)})`, 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<CollabSession | null>(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<Role>(() =>
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<void> = Promise.resolve();
const warnedShared = new Set<string>();
const post = (m: Msg) => channel?.postMessage(m);
const enqueue = (job: () => Promise<void>) => {
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<boolean> {
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<boolean> {
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<DocRef | null> {
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<Map<string, string>> {
const done = new Map<string, string>();
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=<token>` 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/<id>[#k=<token>]` or `/?doc=<id>`. */
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<Msg>) => 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<typeof createLibrary> | null = null;
export function useLibrary() {
if (!instance) instance = createLibrary();
return instance;
}