Files
numolus/app/frontend/store/library.ts
T
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

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

853 lines
30 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.
*
* Persistence: the store's debounced save lands in `persist()` and goes to
* IndexedDB or to the server. Server documents have a single-editor lock
* (until live collaboration exists): whoever holds it edits, everyone else
* sees the document read-only and follows its changes by polling.
*
* 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 } 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';
/** 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 UPLOAD_DELAY = 800; // ms after the last change
const HEARTBEAT = 30_000; // refresh the edit lock (server TTL is 90 s)
const POLL = 10_000; // read-only: look for someone else's changes
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, version, role, owner_name, updated_at, updated_by_name } = d;
return { id, title, version, 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 */
}
}
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, version, ...) */
server: null as ServerDocMeta | null,
/** someone else holds the edit lock on the open server document */
lockedBy: null as string | null,
/** server saves waiting or failing */
syncState: 'idle' as 'idle' | 'saving' | 'offline',
});
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' && !state.lockedBy);
let lib: DocumentLibrary | null = null;
let channel: BroadcastChannel | null = null;
/** content fingerprint of what's saved for the open 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>();
// server document state
let uploadTimer: ReturnType<typeof setTimeout> | undefined;
let uploading: Promise<void> | null = null;
let pendingJson: string | null = null;
let pendingFp = '';
let holdsLock = false;
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
let pollTimer: ReturnType<typeof setInterval> | undefined;
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'
? null
: currentRole.value === 'viewer'
? 'Nur Lesen – du kannst dieses Dokument ansehen, aber nicht bearbeiten.'
: state.lockedBy
? `${state.lockedBy} bearbeitet gerade – nur Lesen, bis die Bearbeitung beendet ist.`
: 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)) {
// someone else'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 -----------------------------------------------------
function persist(doc: DocumentState) {
const c = state.current;
if (!c) return;
if (c.source === 'server') return persistServer(doc);
if (!lib) return;
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 });
});
}
function persistServer(doc: DocumentState) {
if (!canEdit.value) return;
const fp = contentFingerprint(doc);
if (fp === savedContent && pendingJson === null) return; // cursor move only
pendingJson = JSON.stringify(doc);
pendingFp = fp;
clearTimeout(uploadTimer);
uploadTimer = setTimeout(() => void upload(), UPLOAD_DELAY);
}
/** Send the pending change of the open server document (one at a time). */
async function upload(): Promise<void> {
clearTimeout(uploadTimer);
uploadTimer = undefined;
if (uploading) {
await uploading;
if (pendingJson !== null) return upload();
return;
}
const c = state.current;
const meta = state.server;
if (!c || c.source !== 'server' || !meta || pendingJson === null) return;
const json = pendingJson;
const fp = pendingFp;
pendingJson = null;
state.syncState = 'saving';
uploading = (async () => {
try {
const r = await api<{ document: ServerDocMeta }>('PATCH', `/documents/${c.id}`, {
body: { content: json, base_version: meta.version },
shareToken: shareToken(c.id),
});
if (isCurrent(c)) {
state.server = { ...meta, ...r.document, role: meta.role };
savedContent = fp;
}
state.syncState = 'idle';
} catch (err) {
await uploadFailed(c, err, json, fp);
} finally {
uploading = null;
}
})();
await uploading;
if (pendingJson !== null && isCurrent(c)) return upload();
}
async function uploadFailed(c: DocRef, err: unknown, json: string, fp: string) {
if (!(err instanceof ApiError) || err.status === 0 || err.status >= 500) {
// offline / server trouble: keep it and try again
state.syncState = 'offline';
if (pendingJson === null) {
pendingJson = json;
pendingFp = fp;
}
clearTimeout(uploadTimer);
uploadTimer = setTimeout(() => void upload(), 5000);
return;
}
state.syncState = 'idle';
// the server's version wins; edits made meanwhile would be based on the
// rejected state, so they go too
pendingJson = null;
clearTimeout(uploadTimer);
if (err.status === 423) {
holdsLock = false;
state.lockedBy = (err.body.holder_name as string) || 'Jemand anderes';
toasts.error(`${state.lockedBy} bearbeitet dieses Dokument inzwischen – deine letzte Änderung wurde nicht gespeichert.`);
} else if (err.status === 409) {
toasts.error('Das Dokument wurde inzwischen anderswo geändert – deine letzte Änderung wurde nicht gespeichert.');
} else {
toasts.error(`Speichern fehlgeschlagen: ${err.message}`);
}
if (isCurrent(c)) await reloadServer(c.id, true);
startServerTimers();
}
/** Before leaving the open server document: send what's pending. */
async function flushServer() {
store.flushSave();
if (pendingJson !== null || uploading) await upload();
}
// ---- edit lock + polling ---------------------------------------------
function stopServerTimers() {
clearInterval(heartbeatTimer);
clearInterval(pollTimer);
heartbeatTimer = pollTimer = undefined;
}
function startServerTimers() {
stopServerTimers();
if (state.current?.source !== 'server') return;
if (holdsLock) heartbeatTimer = setInterval(() => void heartbeat(), HEARTBEAT);
else pollTimer = setInterval(() => void poll(), POLL);
}
/** Try to take the edit lock of the open server document. */
async function takeLock(id: string): Promise<boolean> {
if (currentRole.value === 'viewer') return false;
try {
const r = await api<{ lock: { mine: boolean; holder_name: string | null } }>('POST', `/documents/${id}/lock`, {
shareToken: shareToken(id),
});
holdsLock = r.lock.mine;
state.lockedBy = r.lock.mine ? null : r.lock.holder_name || 'Jemand anderes';
} catch {
holdsLock = false;
}
applyReadOnly();
return holdsLock;
}
async function heartbeat() {
const c = state.current;
if (c?.source !== 'server') return;
const had = holdsLock;
await takeLock(c.id);
if (had && !holdsLock) {
toasts.error(`${state.lockedBy ?? 'Jemand anderes'} bearbeitet dieses Dokument jetzt.`);
startServerTimers();
}
}
async function poll() {
const c = state.current;
if (c?.source !== 'server' || document.visibilityState === 'hidden') return;
if (currentRole.value !== 'viewer' && (await takeLock(c.id))) {
await reloadServer(c.id, true);
toasts.info('Du kannst das Dokument jetzt bearbeiten.');
startServerTimers();
return;
}
await reloadServer(c.id, true);
}
function releaseLock(id: string) {
if (!holdsLock) return;
holdsLock = false;
void api('DELETE', `/documents/${id}/lock`, { shareToken: shareToken(id), keepalive: true }).catch(() => {});
}
/** Fetch the server document again; with `keepView` only when it changed. */
async function reloadServer(id: string, keepView: boolean) {
const known = keepView ? state.server?.version : undefined;
try {
const q = known ? `?known_version=${known}` : '';
const r = await api<{ document: ServerDoc; unchanged?: boolean }>('GET', `/documents/${id}${q}`, {
shareToken: shareToken(id),
});
if (!isCurrent({ source: 'server', id })) return;
const { content } = r.document;
state.server = metaOf(r.document);
if (!r.unchanged && content !== undefined) {
show({ source: 'server', id }, validateDocument(JSON.parse(content)), keepView);
}
updateWindowTitle();
applyReadOnly();
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
toasts.error('Kein Zugriff mehr auf dieses Dokument.');
await openFallback();
}
}
}
// ---- leaving / opening -----------------------------------------------
async function leaveCurrent() {
const c = state.current;
if (!c) return;
if (c.source === 'server') {
await flushServer();
stopServerTimers();
releaseLock(c.id);
state.lockedBy = 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 r: { document: ServerDoc };
try {
r = await api<{ document: ServerDoc }>('GET', `/documents/${id}`, { shareToken: shareToken(id) });
} 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 { content } = r.document;
const meta = metaOf(r.document);
let next: DocumentState;
try {
next = validateDocument(JSON.parse(content ?? ''));
} catch (err) {
toasts.error(`Dokument konnte nicht geladen werden: ${(err as Error).message}`);
return false;
}
await leaveCurrent();
state.server = meta;
state.lockedBy = null;
holdsLock = false;
show({ source: 'server', id }, next);
// 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();
if (meta.role !== 'viewer') await takeLock(id);
startServerTimers();
setUrl({ source: 'server', id }, 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)) {
if (ref.source === 'server') await flushServer();
else {
store.flushSave();
await writeQueue;
}
}
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();
if (state.current?.source === 'server') {
if (pendingJson !== null) void upload();
releaseLock(state.current.id);
}
});
window.addEventListener('beforeunload', (e) => {
if (pendingJson !== null || uploading) e.preventDefault(); // unsaved server changes
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') store.flushSave();
});
state.ready = true;
}
return {
state,
currentTitle,
currentRole,
canEdit,
init,
open,
create,
createBlank,
createSample,
rename,
duplicate,
remove,
upload: upload_,
refreshServerList,
};
}
let instance: ReturnType<typeof createLibrary> | null = null;
export function useLibrary() {
if (!instance) instance = createLibrary();
return instance;
}