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>
This commit is contained in:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
+852
View File
@@ -0,0 +1,852 @@
/**
* 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;
}