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:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { dateToSerial, timeSerial } from '../../engine';
|
||||
import { formatValue } from '../format';
|
||||
|
||||
describe('formatValue: date/time', () => {
|
||||
const day = dateToSerial(2026, 9, 2); // a Wednesday
|
||||
const noon = timeSerial(12, 0, 0);
|
||||
const dayAtNoon = day + noon;
|
||||
|
||||
it('renders a date per locale', () => {
|
||||
expect(formatValue(day, 'de-DE', 'date')).toBe('02.09.2026');
|
||||
expect(formatValue(day, 'en-US', 'date')).toBe('09/02/2026');
|
||||
expect(formatValue(day, 'plain', 'date')).toBe('2026-09-02');
|
||||
});
|
||||
|
||||
it('renders a time per locale', () => {
|
||||
expect(formatValue(noon, 'de-DE', 'time')).toBe('12:00');
|
||||
expect(formatValue(noon, 'plain', 'time')).toBe('12:00');
|
||||
});
|
||||
|
||||
it('renders date + time together', () => {
|
||||
expect(formatValue(dayAtNoon, 'de-DE', 'datetime')).toBe('02.09.2026 12:00');
|
||||
expect(formatValue(dayAtNoon, 'plain', 'datetime')).toBe('2026-09-02 12:00');
|
||||
});
|
||||
|
||||
it('date + time shows only the time for a bare time of day (no 1899 date)', () => {
|
||||
expect(formatValue(timeSerial(12, 23, 0), 'de-DE', 'datetime')).toBe('12:23');
|
||||
});
|
||||
|
||||
it('"duration" keeps counting hours past 24', () => {
|
||||
expect(formatValue(37.5 / 24, 'de-DE', 'duration')).toBe('37:30');
|
||||
expect(formatValue(timeSerial(8, 5, 0), 'de-DE', 'duration')).toBe('08:05');
|
||||
expect(formatValue((100 * 3600 + 30) / 86400, 'de-DE', 'duration')).toBe('100:00:30');
|
||||
expect(formatValue(-1.5 / 24, 'de-DE', 'duration')).toBe('-01:30');
|
||||
expect(formatValue(0, 'de-DE', 'duration')).toBe('00:00');
|
||||
});
|
||||
|
||||
it('empty cell / error still short-circuit before any date formatting', () => {
|
||||
expect(formatValue(null, 'de-DE', 'date')).toBe('');
|
||||
});
|
||||
|
||||
it('"dateiso" renders YYYY-MM-DD regardless of the active locale', () => {
|
||||
expect(formatValue(day, 'de-DE', 'dateiso')).toBe('2026-09-02');
|
||||
expect(formatValue(day, 'en-US', 'dateiso')).toBe('2026-09-02');
|
||||
expect(formatValue(day, 'fr-FR', 'dateiso')).toBe('2026-09-02');
|
||||
expect(formatValue(day, 'plain', 'dateiso')).toBe('2026-09-02');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
import { isError, serialToUtcDate, type CellValue } from '../engine';
|
||||
import type { CellNumFmt } from './types';
|
||||
|
||||
/**
|
||||
* Locale used to render numeric values. `'plain'` is not a real locale -- it
|
||||
* renders the bare JS number (`.` decimal, no grouping).
|
||||
*
|
||||
* A *display* setting only. `rawString()` (CSV / XLSX export) stays
|
||||
* locale-neutral so round-trips are lossless.
|
||||
*/
|
||||
export type NumberLocale =
|
||||
| 'plain'
|
||||
| 'de-DE'
|
||||
| 'de-CH'
|
||||
| 'en-US'
|
||||
| 'en-GB'
|
||||
| 'fr-FR'
|
||||
| 'it-IT';
|
||||
|
||||
export const NUMBER_LOCALES: ReadonlyArray<{ id: NumberLocale; label: string }> = [
|
||||
{ id: 'plain', label: 'Ohne Trennzeichen · 1234.5' },
|
||||
{ id: 'de-DE', label: 'Deutsch (DE) · 1.234,5' },
|
||||
{ id: 'de-CH', label: 'Schweiz (CH) · 1’234.5' },
|
||||
{ id: 'en-US', label: 'English (US) · 1,234.5' },
|
||||
{ id: 'en-GB', label: 'English (UK) · 1,234.5' },
|
||||
{ id: 'fr-FR', label: 'Français (FR) · 1 234,5' },
|
||||
{ id: 'it-IT', label: 'Italiano (IT) · 1.234,5' },
|
||||
];
|
||||
|
||||
const LOCALE_IDS = new Set(NUMBER_LOCALES.map((l) => l.id));
|
||||
export function isNumberLocale(x: unknown): x is NumberLocale {
|
||||
return typeof x === 'string' && LOCALE_IDS.has(x as NumberLocale);
|
||||
}
|
||||
export const DEFAULT_LOCALE: NumberLocale = 'de-DE';
|
||||
|
||||
/** Cell number-format options offered in the UI. */
|
||||
export const NUM_FORMATS: ReadonlyArray<{ id: CellNumFmt; label: string }> = [
|
||||
{ id: 'auto', label: 'Automatisch' },
|
||||
{ id: 'plain', label: 'Ohne Formatierung' },
|
||||
{ id: 'int', label: 'Ganzzahl · 1.235' },
|
||||
{ id: 'dec1', label: '1 Nachkommastelle · 1.234,6' },
|
||||
{ id: 'dec2', label: '2 Nachkommastellen · 1.234,56' },
|
||||
{ id: 'pct0', label: 'Prozent · 12 %' },
|
||||
{ id: 'pct2', label: 'Prozent · 12,34 %' },
|
||||
{ id: 'date', label: 'Datum · 02.09.2026' },
|
||||
{ id: 'dateiso', label: 'Datum (ISO) · 2026-09-02' },
|
||||
{ id: 'time', label: 'Zeit · 14:30' },
|
||||
{ id: 'datetime', label: 'Datum + Zeit · 02.09.2026 14:30' },
|
||||
{ id: 'duration', label: 'Dauer · 37:30' },
|
||||
];
|
||||
const NUM_FMT_IDS = new Set(NUM_FORMATS.map((f) => f.id));
|
||||
export function isCellNumFmt(x: unknown): x is CellNumFmt {
|
||||
return typeof x === 'string' && NUM_FMT_IDS.has(x as CellNumFmt);
|
||||
}
|
||||
|
||||
const round10 = (n: number) => Math.round(n * 1e10) / 1e10;
|
||||
|
||||
function nf(locale: NumberLocale, opts: Intl.NumberFormatOptions): Intl.NumberFormat {
|
||||
// for the pseudo-locale 'plain' use en-US digits but turn grouping off
|
||||
return new Intl.NumberFormat(locale === 'plain' ? 'en-US' : locale, {
|
||||
useGrouping: locale !== 'plain',
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** `YYYY-MM-DD`, hand-written rather than via `Intl` -- there's no locale
|
||||
* that reliably reproduces ISO 8601, and this is also what the `'plain'`
|
||||
* pseudo-locale uses for a date (an `Intl` call with an `undefined` locale
|
||||
* would silently fall back to the *browser's* locale instead of staying
|
||||
* neutral). Shared by the locale-independent `'dateiso'` cell format and
|
||||
* by `formatDatePart`'s `'plain'` branch below. */
|
||||
function isoDateString(d: Date): string {
|
||||
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
function formatDatePart(d: Date, locale: NumberLocale): string {
|
||||
if (locale === 'plain') return isoDateString(d);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
}).format(d);
|
||||
}
|
||||
function formatTimePart(d: Date, locale: NumberLocale): string {
|
||||
if (locale === 'plain') return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/** Elapsed time as `[h]:mm` -- hours keep counting past 24 (`37:30`), the
|
||||
* way a sum of working times should read; seconds only when there are any.
|
||||
* Locale-independent: `:` is the time separator in every supported locale. */
|
||||
function formatDuration(n: number): string {
|
||||
let sec = Math.round(Math.abs(n) * 86400);
|
||||
const h = Math.floor(sec / 3600);
|
||||
sec -= h * 3600;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec - m * 60;
|
||||
return `${n < 0 && (h || m || s) ? '-' : ''}${pad2(h)}:${pad2(m)}${s ? `:${pad2(s)}` : ''}`;
|
||||
}
|
||||
|
||||
/** Render a computed value for display (grid cell / status bar / chart). */
|
||||
export function formatValue(
|
||||
v: CellValue,
|
||||
locale: NumberLocale = DEFAULT_LOCALE,
|
||||
numFmt: CellNumFmt = 'auto',
|
||||
): string {
|
||||
if (v === null) return '';
|
||||
if (isError(v)) return v.code;
|
||||
if (typeof v !== 'number') return v;
|
||||
if (!Number.isFinite(v)) return '#NUM!';
|
||||
|
||||
const n = round10(v);
|
||||
switch (numFmt) {
|
||||
case 'plain':
|
||||
return String(n);
|
||||
case 'int':
|
||||
return nf(locale, { maximumFractionDigits: 0 }).format(n);
|
||||
case 'dec1':
|
||||
return nf(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(n);
|
||||
case 'dec2':
|
||||
return nf(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
|
||||
case 'pct0':
|
||||
return nf(locale, { style: 'percent', maximumFractionDigits: 0 }).format(n);
|
||||
case 'pct2':
|
||||
return nf(locale, {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n);
|
||||
case 'date':
|
||||
return formatDatePart(serialToUtcDate(n), locale);
|
||||
case 'dateiso':
|
||||
return isoDateString(serialToUtcDate(n)); // same regardless of locale
|
||||
case 'time':
|
||||
return formatTimePart(serialToUtcDate(n), locale);
|
||||
case 'datetime':
|
||||
// a bare time of day (< 1 day) has no calendar date -- showing the
|
||||
// epoch "30.12.1899" in front of it would be noise
|
||||
if (n >= 0 && n < 1) return formatTimePart(serialToUtcDate(n), locale);
|
||||
return `${formatDatePart(serialToUtcDate(n), locale)} ${formatTimePart(serialToUtcDate(n), locale)}`;
|
||||
case 'duration':
|
||||
return formatDuration(n);
|
||||
default: // 'auto'
|
||||
return locale === 'plain'
|
||||
? String(n)
|
||||
: nf(locale, { maximumFractionDigits: 10 }).format(n);
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain (ungrouped, `.`-decimal) string -- machine-readable, for CSV / XLSX. */
|
||||
export function rawString(v: CellValue): string {
|
||||
if (v === null) return '';
|
||||
if (isError(v)) return v.code;
|
||||
if (typeof v === 'number') return String(round10(v));
|
||||
return v;
|
||||
}
|
||||
|
||||
export function isNumeric(v: CellValue): boolean {
|
||||
return typeof v === 'number' && Number.isFinite(v);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Who is signed in. An account is optional: without one, documents stay in
|
||||
* this browser (IndexedDB); with one, they live on the server and can be
|
||||
* shared. Sign-in is by magic link or the 6-digit code from the same mail.
|
||||
*/
|
||||
import { computed, reactive } from 'vue';
|
||||
import { api, bootData, type User } from '../io/api';
|
||||
|
||||
const state = reactive({ user: bootData().user as User | null });
|
||||
|
||||
export function useSession() {
|
||||
const signedIn = computed(() => !!state.user);
|
||||
|
||||
/** Mail a sign-in link + code. `returnTo` = where the link leads back to. */
|
||||
async function requestLink(email: string, returnTo = location.pathname + location.search) {
|
||||
await api('POST', '/auth/magic_link', { body: { email, return_to: returnTo } });
|
||||
}
|
||||
|
||||
/** Sign in with the code. The page reloads, so everything starts signed in. */
|
||||
async function verifyCode(email: string, code: string) {
|
||||
await api<{ redirect: string }>('POST', '/auth/code', { body: { email, code } });
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api('DELETE', '/auth/session');
|
||||
location.assign('/');
|
||||
}
|
||||
|
||||
async function setDisplayName(display_name: string) {
|
||||
const r = await api<{ user: User }>('PATCH', '/me', { body: { display_name } });
|
||||
state.user = r.user;
|
||||
}
|
||||
|
||||
return { state, signedIn, requestLink, verifyCode, logout, setDisplayName };
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Persisted / JSON-exported document shape. **Version 2** — Numbers-style
|
||||
* canvas model: a sheet is a free surface holding table, chart and text *objects*.
|
||||
*
|
||||
* (Version 1 was one grid per sheet; `io/json.ts` migrates v1 -> v2 on import.)
|
||||
*/
|
||||
|
||||
import type { NumberLocale } from './format';
|
||||
import type { CellValue } from '../engine';
|
||||
|
||||
export interface CellCoord {
|
||||
col: number;
|
||||
row: number;
|
||||
}
|
||||
|
||||
export type HAlign = 'left' | 'center' | 'right';
|
||||
export type VAlign = 'top' | 'middle' | 'bottom';
|
||||
|
||||
/** Per-cell number-display rule, combined with the effective locale. */
|
||||
export type CellNumFmt =
|
||||
| 'auto'
|
||||
| 'plain'
|
||||
| 'int' // 0 decimals, grouped
|
||||
| 'dec1' // 1 decimal
|
||||
| 'dec2' // 2 decimals
|
||||
| 'pct0' // percent, 0 decimals (value 0.5 -> "50 %")
|
||||
| 'pct2' // percent, 2 decimals
|
||||
| 'date' // e.g. 02.09.2026 -- value is a date serial, see engine/dates.ts
|
||||
| 'dateiso' // e.g. 2026-09-02 -- ISO 8601, same regardless of locale
|
||||
| 'time' // e.g. 14:30 -- time of day, wraps at 24 h
|
||||
| 'datetime' // date + time
|
||||
| 'duration'; // e.g. 37:30 -- elapsed hours, never wraps at 24 h
|
||||
|
||||
/** Explicit formatting for a single cell (all fields optional = inherit). */
|
||||
export interface CellFormat {
|
||||
align?: HAlign;
|
||||
valign?: VAlign;
|
||||
numFmt?: CellNumFmt;
|
||||
/** text colour (#rgb/#rrggbb/#rrggbbaa) */
|
||||
color?: string;
|
||||
/** fill colour */
|
||||
bg?: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
}
|
||||
|
||||
/** How a table object is sized on the canvas. */
|
||||
export interface TableView {
|
||||
mode: 'auto' | 'fixed';
|
||||
width: number;
|
||||
height: number;
|
||||
frozenRows: number;
|
||||
frozenCols: number;
|
||||
}
|
||||
|
||||
export function defaultTableView(): TableView {
|
||||
return { mode: 'auto', width: 640, height: 320, frozenRows: 0, frozenCols: 0 };
|
||||
}
|
||||
|
||||
export const DEFAULT_ROW_H = 26;
|
||||
|
||||
/** A table object placed on a sheet canvas. */
|
||||
export interface TableObject {
|
||||
kind: 'table';
|
||||
id: string;
|
||||
/** document-unique name; the qualifier used in formulas (`Umsatz!B2`) */
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
rows: number;
|
||||
cols: number;
|
||||
/** column index -> pixel width (only non-default widths are stored) */
|
||||
colWidths: Record<number, number>;
|
||||
/** default row height (px); unset = DEFAULT_ROW_H */
|
||||
rowHeight?: number;
|
||||
/** row index -> pixel height override */
|
||||
rowHeights?: Record<number, number>;
|
||||
/** "col,row" -> raw user input (formula string or literal). Empty cells absent. */
|
||||
cells: Record<string, string>;
|
||||
/** "col,row" -> explicit cell formatting */
|
||||
formats?: Record<string, CellFormat>;
|
||||
/** sizing + frozen rows/cols */
|
||||
view: TableView;
|
||||
/** number-format locale override; falls back to the sheet, then the document */
|
||||
locale?: NumberLocale;
|
||||
/** object background colour override (grid surface) */
|
||||
bg?: string;
|
||||
/** object default text colour override */
|
||||
fg?: string;
|
||||
/** when true, this table's title bar and row/column headers are only
|
||||
* shown while it's the selected canvas object -- otherwise (including
|
||||
* right after the document loads, before anything is selected) they're
|
||||
* hidden entirely, not just visually: the table's footprint shrinks to
|
||||
* just its cell grid. A presentation/"clean look" option; unset/false =
|
||||
* always shown, today's behaviour. */
|
||||
hideChromeUnfocused?: boolean;
|
||||
}
|
||||
|
||||
/** A chart object placed on a sheet canvas. Freely positioned and resized. */
|
||||
export interface ChartObject {
|
||||
kind: 'chart';
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
view: 'bar' | 'line';
|
||||
sourceTableId: string | null;
|
||||
locale?: NumberLocale;
|
||||
bg?: string;
|
||||
fg?: string;
|
||||
}
|
||||
|
||||
/** A free-floating formatted text box, a companion to tables and charts. */
|
||||
export interface TextObject {
|
||||
kind: 'text';
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
text: string;
|
||||
fontSize: number;
|
||||
align: HAlign;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
color?: string;
|
||||
bg?: string;
|
||||
}
|
||||
|
||||
/** A clickable canvas object that runs an assigned macro (see `Macro`
|
||||
* below) when clicked -- the second of the two trigger UIs the macro
|
||||
* feature offers, alongside the "▶ Ausführen" button in the Makros panel.
|
||||
* Freely positioned/resized like a chart or text box, no intrinsic size. */
|
||||
export interface ButtonObject {
|
||||
kind: 'button';
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** button caption, independent of `name` (the canvas-object label shown
|
||||
* in its chrome/rename-on-dblclick) */
|
||||
label: string;
|
||||
/** which macro a click runs; `null` = unassigned (shows a hint instead
|
||||
* of running anything). A dangling id (its macro was deleted, or an
|
||||
* imported document's macro wasn't carried along) is treated the same
|
||||
* as `null` -- the same lenient convention as a chart's dangling
|
||||
* `sourceTableId`. */
|
||||
macroId: string | null;
|
||||
bg?: string;
|
||||
fg?: string;
|
||||
}
|
||||
|
||||
export type SheetObject = TableObject | ChartObject | TextObject | ButtonObject;
|
||||
|
||||
export interface Sheet {
|
||||
id: string;
|
||||
name: string;
|
||||
/** array order == z-order; the last element renders in front */
|
||||
objects: SheetObject[];
|
||||
locale?: NumberLocale;
|
||||
/** canvas backdrop colour; unset = themed default */
|
||||
background?: string;
|
||||
/** default text colour for this sheet's grids/labels; unset = themed default */
|
||||
foreground?: string;
|
||||
/** show the canvas dot-grid; unset / true = shown */
|
||||
showGrid?: boolean;
|
||||
/** grid spacing in px -- also the snap increment for moving/resizing
|
||||
* canvas objects on this sheet; unset = DEFAULT_GRID_SIZE */
|
||||
gridSize?: number;
|
||||
/** per-sheet canvas zoom (0.4 – 2); unset = 1 */
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_GRID_SIZE = 24;
|
||||
export const MIN_GRID_SIZE = 4;
|
||||
export const MAX_GRID_SIZE = 200;
|
||||
|
||||
export interface SelectionRange {
|
||||
anchor: CellCoord;
|
||||
focus: CellCoord;
|
||||
}
|
||||
|
||||
export interface SelectionState {
|
||||
tableId: string;
|
||||
/** primary rectangle; `focus` is the active cell (formula bar / keyboard) */
|
||||
anchor: CellCoord;
|
||||
focus: CellCoord;
|
||||
/** additional rectangles for a discontiguous multi-selection */
|
||||
ranges?: SelectionRange[];
|
||||
}
|
||||
|
||||
/** A document-wide, user-defined formula function, callable from any cell
|
||||
* like a built-in (e.g. `=MARGE(Umsatz!B2; Umsatz!C2)`). Created, edited and
|
||||
* removed exclusively through the "𝑓 Funktionen" UI -- see
|
||||
* src/engine/userFunctions.ts for how a call is expanded into a cell's AST. */
|
||||
export interface CustomFunction {
|
||||
id: string;
|
||||
/** document-unique, compared case-insensitively; never a built-in name. */
|
||||
name: string;
|
||||
/** parameter names, in call order; may be empty (a plain named formula). */
|
||||
params: string[];
|
||||
/** raw formula text, no leading "=" -- a single expression for
|
||||
* `kind: 'expression'`, a FOR/DO/RETURN statement sequence for
|
||||
* `kind: 'block'`. */
|
||||
body: string;
|
||||
/** 'expression' (default, single-formula body) or 'block' (FOR-loop
|
||||
* capable, "Mit Schleife" in the UI). Missing on any function saved
|
||||
* before this existed -- normalised to 'expression' on load. */
|
||||
kind: 'expression' | 'block';
|
||||
}
|
||||
|
||||
/** A document-wide name that stands for a fixed cell/range on a specific
|
||||
* table (`Umsatz_Q1` instead of `Umsatz!B2:B13`), usable unqualified in any
|
||||
* ordinary top-level formula (not inside a custom function's own body --
|
||||
* see engine/parser.ts's `namedRefFallback`). Created, edited and removed
|
||||
* exclusively through the "🏷 Namen" UI. Unlike a `CustomFunction`, there is
|
||||
* no expression to evaluate, just fixed coordinates -- so no cycle
|
||||
* detection is needed. `tableId` (not the table's *name*) so a named range
|
||||
* survives its target table being renamed -- see
|
||||
* engine/workbook.ts's `resolveNamedRange`. */
|
||||
export interface NamedRange {
|
||||
id: string;
|
||||
/** document-unique, compared case-insensitively; never a built-in/custom
|
||||
* function name, never shaped like a cell reference (A1, $B2, ...). */
|
||||
name: string;
|
||||
tableId: string;
|
||||
start: CellCoord;
|
||||
end: CellCoord;
|
||||
}
|
||||
|
||||
/** A document-wide, explicitly-triggered script -- NOT callable from any
|
||||
* cell formula (deliberately a separate type from `CustomFunction`, not a
|
||||
* third `kind`, so that's impossible by construction, not just
|
||||
* convention). Can freely read AND WRITE cells, rows/columns and tables/
|
||||
* sheets across the whole document (`engine/types.ts`'s `MacroWriteHost`
|
||||
* vocabulary: `SETVALUE`/`INSERTROW`/`ADDTABLE`/`ADDSHEET`/...), unlike an
|
||||
* ordinary formula -- safe only because it runs OUTSIDE the passive
|
||||
* recalculation graph, triggered by a click (the "▶ Ausführen" button in
|
||||
* the Makros panel, or a canvas `ButtonObject`), never automatically. See
|
||||
* `document.ts`'s `runMacro`/`validateMacro` and README's "Makros"
|
||||
* section. Takes no parameters (a click carries no arguments) and can
|
||||
* never call another macro (no such action exists) -- both deliberate,
|
||||
* documented v1 scope cuts.
|
||||
*
|
||||
* Also able to reach the network (`GET`/`POST`, `engine/types.ts`'s
|
||||
* `MacroNetworkHost`) -- a second trust dimension beyond read/write scope,
|
||||
* gated separately via `approvedHosts` below. */
|
||||
export interface Macro {
|
||||
id: string;
|
||||
/** document-unique, compared case-insensitively; never a built-in/custom
|
||||
* function/action name. */
|
||||
name: string;
|
||||
/** raw statement-sequence text (assignments, FOR/IF/RETURN, and
|
||||
* write-action calls), no leading "=" -- parsed via
|
||||
* `engine/parser.ts`'s `parseMacroBlock`. Every cell/range reference
|
||||
* inside must be qualified with a table name (`Umsatz::A1`, never bare
|
||||
* `A1`) -- a macro has no "home table", see `validateMacro`. */
|
||||
body: string;
|
||||
/** Hostnames (`host` from `new URL(...)`, i.e. including a non-default
|
||||
* port) this macro has already been confirmed to contact via `GET`/
|
||||
* `POST`, persisted so a human only approves each macro×host pair once,
|
||||
* ever -- not once per session, not once per document (a different
|
||||
* macro contacting an already-approved host still prompts separately;
|
||||
* see `document.ts#runMacro`'s doc comment for the reasoning). Absent/
|
||||
* empty for a macro that has never made a network call. */
|
||||
approvedHosts?: string[];
|
||||
}
|
||||
|
||||
/** One entry in the formula-autocomplete dropdown (see document.ts's
|
||||
* `functionSuggestions()`). */
|
||||
export interface FnSuggestion {
|
||||
name: string;
|
||||
/** one-line signature hint, e.g. "SUM(Zahl1; Zahl2; …)" */
|
||||
hint: string;
|
||||
kind: 'builtin' | 'custom';
|
||||
}
|
||||
|
||||
/** One entry in the Name Box's autocomplete dropdown (see document.ts's
|
||||
* `nameBoxSuggestions()`) -- a named range, table, or sheet the typed
|
||||
* prefix matches. */
|
||||
export interface NameSuggestion {
|
||||
name: string;
|
||||
/** one-line description, e.g. the range a named range points at, or
|
||||
* which sheet a table lives on. */
|
||||
hint: string;
|
||||
kind: 'namedrange' | 'table' | 'sheet';
|
||||
}
|
||||
|
||||
/** One node of the interactive dependency-graph view (document.ts's
|
||||
* `cellGraphNeighbors()`/`cellGraphSelf()`) -- a precedent/dependent cell
|
||||
* (or the centered cell itself), enriched with what the UI needs to
|
||||
* render and label it without a second lookup. */
|
||||
export interface CellGraphNode {
|
||||
tableId: string;
|
||||
tableName: string;
|
||||
/** the sheet the table lives on -- shown only when navigating makes the
|
||||
* centered cell's own sheet ambiguous otherwise. */
|
||||
sheetName: string;
|
||||
col: number;
|
||||
row: number;
|
||||
/** A1 label within its table, e.g. "B5" (never table/sheet-qualified --
|
||||
* the UI adds that itself from `tableName`/`sheetName` as needed). */
|
||||
label: string;
|
||||
/** raw input (formula text or literal), for a tooltip/detail view. */
|
||||
raw: string;
|
||||
value: CellValue;
|
||||
/** this cell's *own* value is `#CIRC` -- it sits on a circular reference. */
|
||||
isCircular: boolean;
|
||||
}
|
||||
|
||||
export interface DocumentState {
|
||||
version: 2;
|
||||
sheets: Sheet[];
|
||||
activeSheetId: string;
|
||||
activeObjectId: string | null;
|
||||
selection: SelectionState | null;
|
||||
locale: NumberLocale;
|
||||
csvDelimiter: ',' | ';';
|
||||
customFunctions: CustomFunction[];
|
||||
namedRanges: NamedRange[];
|
||||
macros: Macro[];
|
||||
}
|
||||
Reference in New Issue
Block a user