feat(notes): browser sync engine with offline queue; editor saves through the operation log
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m26s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m26s
Phase 2 of the collaboration plan: - frontend/src/lib/docSync: TypeScript port of the block tree, operations, effects and order keys (kept identical to Go via shared test vectors), diff from the editor's flat list to operations, IndexedDB persistence, HTTP transport and DocSyncEngine (one request in flight, idempotent resends, backoff, 3 s/30 s polling, keepalive flush and leave on exit, block leases for focus, 20 s grace and new blocks). - Text changes carry the version the user saw when typing (first change wins when coalescing; a stale focused field keeps its old base), so a concurrent edit becomes a conflict copy instead of a silent overwrite. - DocumentEditor uses the engine instead of the 800 ms whole-document PUT; editors (not only owners) can edit, remote changes appear live. - Server: task IDs of linked blocks are delivered again, linked tasks follow ticked/deleted blocks after sync, note tags are derived, title changes notify other users; block IDs already used in another note are renamed (starter content with fixed IDs was empty for every further user). - Fix: unlinking tasks from blocks wrote to non-existent columns and never worked (error was swallowed). - Tests: Go/TS order key vectors, diff round trips, engine races (lost responses, offline + reload, edits arriving between typing and sending, stale focused field, duplicate first block), plus a Playwright test with two browsers against the real image (e2e/run.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ea0c78b41
commit
51f90eb7e4
31 files changed
+2468
-207
No files matched your search
@@ -7,3 +7,4 @@ frontend/dist
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
.env
|
.env
|
||||||
|
e2e
|
||||||
@@ -17,3 +17,4 @@ dev-data/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
e2e/node_modules/
|
||||||
@@ -39,6 +39,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| `internal/config/` | Umgebungsvariablen |
|
| `internal/config/` | Umgebungsvariablen |
|
||||||
| `frontend/src/features/` | `triage`, `workspace`, `lists`, `teams`, `settings`, `capture`, `auth`, `help` |
|
| `frontend/src/features/` | `triage`, `workspace`, `lists`, `teams`, `settings`, `capture`, `auth`, `help` |
|
||||||
| `frontend/src/lib/` | `api.ts` (REST-Client, Transport-Umschaltung WS/SSE), `types.ts`, `markdown.tsx`, `theme.ts` |
|
| `frontend/src/lib/` | `api.ts` (REST-Client, Transport-Umschaltung WS/SSE), `types.ts`, `markdown.tsx`, `theme.ts` |
|
||||||
|
| `frontend/src/lib/docSync/` | Notiz-Synchronisation im Browser: Portierung von `internal/collab` (`model.ts`, `orderKey.ts`), Vergleich Editor-Liste → Operationen (`diff.ts`), `engine.ts`, IndexedDB (`persist.ts`), HTTP (`transport.ts`) |
|
||||||
|
| `e2e/` | Browser-Tests mit Playwright gegen einen echten Server (`e2e/run.sh`) |
|
||||||
| `frontend/src/themes/` | CSS pro Theme |
|
| `frontend/src/themes/` | CSS pro Theme |
|
||||||
|
|
||||||
## Start und Laufzeit
|
## Start und Laufzeit
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
|
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
|
||||||
| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig |
|
| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig |
|
||||||
| FLAW-24 | Gruppen | Organisations-Endpunkte ohne Berechtigungsprüfung | Mittel |
|
| FLAW-24 | Gruppen | Organisations-Endpunkte ohne Berechtigungsprüfung | Mittel |
|
||||||
|
| FLAW-25 | Workspace | Zusammenführen/Einrücken an einem fremd gesperrten Bullet wird nur teilweise ausgeführt | Mittel |
|
||||||
|
| FLAW-26 | Workspace | Nach Enter landet sehr schnell Getipptes noch im vorherigen Bullet | Niedrig |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -121,6 +123,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
### FLAW-14: Verschluckte Fehler
|
### FLAW-14: Verschluckte Fehler
|
||||||
- **Fundstellen:** u. a. `PatchDocumentBlocks` (Upserts/Deletes), `AddTaskListShare`, `RemoveTaskListShare`, viele `_, _ = s.db.Exec`.
|
- **Fundstellen:** u. a. `PatchDocumentBlocks` (Upserts/Deletes), `AddTaskListShare`, `RemoveTaskListShare`, viele `_, _ = s.db.Exec`.
|
||||||
- **Problem:** Scheitert ein Schreibzugriff (z. B. `SQLITE_BUSY`), antwortet die API trotzdem mit Erfolg und broadcastet den Stand, der nie gespeichert wurde. Fehler lassen sich nur schwer nachvollziehen.
|
- **Problem:** Scheitert ein Schreibzugriff (z. B. `SQLITE_BUSY`), antwortet die API trotzdem mit Erfolg und broadcastet den Stand, der nie gespeichert wurde. Fehler lassen sich nur schwer nachvollziehen.
|
||||||
|
- **Beispiel (behoben):** Das Lösen der Verknüpfung zwischen Notiz-Block und Aufgabe („Verknüpfung aufheben“, Löschen eines verknüpften Blocks) schrieb in nicht existierende Spalten `thoughts.doc_id`/`block_id`. Der Fehler wurde verschluckt, die Funktion war nie wirksam. Korrigiert; im neuen Sync-Weg gleicht `ReconcileDocTaskLinks` Häkchen und gelöschte Blöcke ab.
|
||||||
- **Maßnahme:** Mehrstufige Änderungen in Transaktionen bündeln und Fehler zurückgeben.
|
- **Maßnahme:** Mehrstufige Änderungen in Transaktionen bündeln und Fehler zurückgeben.
|
||||||
|
|
||||||
### FLAW-15: WriteTimeout und SSE
|
### FLAW-15: WriteTimeout und SSE
|
||||||
@@ -172,6 +175,16 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
- **Problem:** Diese Handler prüfen weder Eigentümer noch Mitgliedschaft. Jeder angemeldete Nutzer kann mit bekannter ID eine fremde Organisation löschen, deren Gruppen auflisten oder dort Gruppen anlegen. Gruppenbezogene Endpunkte (`/teams/{id}…`) prüfen dagegen korrekt auf Gruppen-Admin. Das Datenmodell kennt Organisationen nur noch rudimentär; im UI werden Gruppen direkt über `POST /teams` angelegt (`orgId` Standard `default`).
|
- **Problem:** Diese Handler prüfen weder Eigentümer noch Mitgliedschaft. Jeder angemeldete Nutzer kann mit bekannter ID eine fremde Organisation löschen, deren Gruppen auflisten oder dort Gruppen anlegen. Gruppenbezogene Endpunkte (`/teams/{id}…`) prüfen dagegen korrekt auf Gruppen-Admin. Das Datenmodell kennt Organisationen nur noch rudimentär; im UI werden Gruppen direkt über `POST /teams` angelegt (`orgId` Standard `default`).
|
||||||
- **Maßnahme:** Organisationen entweder konsequent absichern (Eigentümer/Server-Admin) oder entfernen.
|
- **Maßnahme:** Organisationen entweder konsequent absichern (Eigentümer/Server-Admin) oder entfernen.
|
||||||
|
|
||||||
|
### FLAW-25: Teilweise ausgeführte Struktur-Aktionen an gesperrten Bullets
|
||||||
|
- **Fundstellen:** `handleMergeWithPrevious` und `handleIndentBlock` in [`OutlinerTree.tsx`](../frontend/src/features/workspace/OutlinerTree.tsx); Sperrregeln in `internal/collab` (Verschieben/Löschen gesperrter Blöcke wird abgelehnt).
|
||||||
|
- **Problem:** Eine Editor-Aktion besteht oft aus mehreren Operationen. Ist einer der beteiligten Bullets von jemand anderem gesperrt, lehnt der Server nur diese Operation ab. Beispiel aus dem Browser-Test: Backspace am Zeilenanfang hängt den Text oben an (angewendet), das Löschen des unteren Bullets wird abgelehnt – der Text steht dann doppelt da. Einrücken eines fremd gesperrten Bullets wird ohne Hinweis zurückgenommen. Es geht kein Text verloren.
|
||||||
|
- **Maßnahme (Phase 3):** Gesperrte Bullets im Editor anzeigen und Struktur-Aktionen, die sie berühren, gar nicht erst ausführen.
|
||||||
|
|
||||||
|
### FLAW-26: Fokus-Verzögerung nach Enter
|
||||||
|
- **Fundstelle:** `focusBlockInput` in `OutlinerTree.tsx` setzt den Fokus erst im nächsten Animation-Frame.
|
||||||
|
- **Problem:** Zeichen, die innerhalb dieses Frames getippt werden (automatisiert oder sehr schnelles Tippen), landen noch im vorherigen Bullet. Bei fremd gesperrtem Bullet entsteht daraus eine Konfliktkopie.
|
||||||
|
- **Maßnahme:** Fokus nach dem Anlegen synchron setzen (`useLayoutEffect`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Aus der früheren Fassung entfernt
|
## Aus der früheren Fassung entfernt
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
|
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
|
||||||
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
||||||
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
|
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
|
||||||
- **Speichern:** Der Editor speichert 800 ms nach der letzten Eingabe das gesamte Dokument (`PUT /documents/{id}`). Der schlanke Block-Endpunkt `PATCH /documents/{id}/blocks` wird derzeit vom To-Do-Rollup beim Abhaken genutzt.
|
- **Speichern:** Jede Notiz hat im Browser eine `DocSyncEngine` ([lib/docSync/](../../frontend/src/lib/docSync/)). Der Editor meldet jede Änderung der Block-Liste; die Engine übersetzt sie in Operationen, speichert sie zuerst in IndexedDB und sendet sie dann gesammelt (300 ms) an `POST /docs/{id}/sync`. Beim Öffnen zeigt sie sofort den gespeicherten Stand, auch offline.
|
||||||
|
- **Abgleich:** Bei geteilten oder mehrfach geöffneten Notizen fragt die Engine alle 3 s nach Änderungen, sonst alle 30 s, sowie sofort bei Rückkehr ins Netz oder in den Tab. Beim Verlassen sendet sie Ausstehendes mit `keepalive` und gibt ihre Sperren frei.
|
||||||
|
- **Textkonflikte:** Jede Textänderung trägt die Version, die der Nutzer beim Tippen vor sich hatte. Hat jemand anderes den Bullet inzwischen geändert oder gesperrt, legt der Server eine Konfliktkopie an; es wird nie still überschrieben.
|
||||||
|
- **Sperren:** Der Bullet mit dem Cursor, verlassene Bullets für 20 s und neu angelegte Bullets werden gehalten. Die Anzeige gesperrter Bullets folgt in Phase 3 des [Plans](../plans/kollaboration-notizen.md).
|
||||||
|
- Den bisherigen Speicherweg (`PUT /documents/{id}`) nutzt der Editor nur noch für Symbol und Anheften; Titel und Inhalt laufen über die Engine. `PATCH /documents/{id}/blocks` nutzt das To-Do-Rollup; beide Wege landen im selben Änderungs-Log.
|
||||||
|
|
||||||
## Explorer, Tags, To-Dos
|
## Explorer, Tags, To-Dos
|
||||||
|
|
||||||
|
|||||||
@@ -279,8 +279,8 @@ Damit private Notizen keinen unnötigen Aufwand erzeugen, liefert der Server im
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 0 | Sicherheitsfixes vorziehen: Raumberechtigung (FLAW-01/02), Umsortieren (FLAW-03), Stream-Timeout (FLAW-15). **Erledigt.** Die zentrale `docRole` wandert in Phase 1, weil sie auf dem neuen Freigabemodell (`workspace_access`) aufbaut. | Grundlage dicht |
|
| 0 | Sicherheitsfixes vorziehen: Raumberechtigung (FLAW-01/02), Umsortieren (FLAW-03), Stream-Timeout (FLAW-15). **Erledigt.** Die zentrale `docRole` wandert in Phase 1, weil sie auf dem neuen Freigabemodell (`workspace_access`) aufbaut. | Grundlage dicht |
|
||||||
| 1 | Operations-Log, `/snapshot`, `/sync`, `/changes`, `/leave`, Konfliktregeln, Leases, zentrale `DocRole`, Simulationstests. **Erledigt.** Abweichungen: Die bestehenden Tabellen `blocks`/`documents` wurden erweitert statt ersetzt (Baum per `parent_id`/`order_key`, alte Daten werden umgewandelt); der bisherige Speicherweg (`PUT`/`PATCH`) läuft als Adapter durch dieselbe Engine. `DocRole` arbeitet auf den vorhandenen Freigabetabellen; deren Zusammenlegung zu `workspace_access` erfolgt mit dem Freigabe-Dialog in Phase 5. `/changes` liefert nur `{headSeq, changed}`, die Daten kommen immer über `/sync`. | Server fertig und abgesichert |
|
| 1 | Operations-Log, `/snapshot`, `/sync`, `/changes`, `/leave`, Konfliktregeln, Leases, zentrale `DocRole`, Simulationstests. **Erledigt.** Abweichungen: Die bestehenden Tabellen `blocks`/`documents` wurden erweitert statt ersetzt (Baum per `parent_id`/`order_key`, alte Daten werden umgewandelt); der bisherige Speicherweg (`PUT`/`PATCH`) läuft als Adapter durch dieselbe Engine. `DocRole` arbeitet auf den vorhandenen Freigabetabellen; deren Zusammenlegung zu `workspace_access` erfolgt mit dem Freigabe-Dialog in Phase 5. `/changes` liefert nur `{headSeq, changed}`, die Daten kommen immer über `/sync`. | Server fertig und abgesichert |
|
||||||
| 2 | `SyncEngine` mit IndexedDB-Warteschlange, nur Intervall-Abfrage als Transport, Editor umgestellt (Baum statt `level`) | Bearbeiten funktioniert überall, auch offline |
|
| 2 | `SyncEngine` mit IndexedDB-Warteschlange, nur Intervall-Abfrage als Transport, Editor umgestellt. **Erledigt.** Abweichung: Die Editor-Komponenten arbeiten weiter mit der flachen Liste (Ebene + Reihenfolge); die Engine übersetzt jede Änderung per Vergleich in Operationen (`lib/docSync/diff.ts`). Sperren werden bereits gehalten (Fokus, 20 s Nachlauf, neue Bullets), aber noch nicht angezeigt. Ende-zu-Ende-Test mit zwei Browsern: `e2e/`. | Bearbeiten funktioniert überall, auch offline |
|
||||||
| 3 | Leases und Präsenz in UI, Konfliktblöcke mit Übernehmen/Verwerfen, Rückgängig/Wiederholen | Gemeinsames Bearbeiten ohne Kollisionen, Strg+Z |
|
| 3 | Leases und Präsenz in UI, Konfliktblöcke mit Übernehmen/Verwerfen, Rückgängig/Wiederholen. Dazu aus Phase 2: gesperrte Bullets schreibgeschützt anzeigen und Struktur-Aktionen auf ihnen gar nicht erst starten (sonst kann Zusammenführen halb ausgeführt werden, siehe FLAW-25); Fokuswechsel nach Enter ohne Frame-Verzögerung (FLAW-26). | Gemeinsames Bearbeiten ohne Kollisionen, Strg+Z |
|
||||||
| 4 | Push-Stufen WebSocket → SSE → Long-Polling mit automatischer Erkennung | Live-Gefühl, wo das Netz es zulässt |
|
| 4 | Push-Stufen WebSocket → SSE → Long-Polling mit automatischer Erkennung | Live-Gefühl, wo das Netz es zulässt |
|
||||||
| 5 | Freigabe-Dialog mit Bearbeiter-Rolle, Ordnerfreigaben, Altlasten entfernen (`PUT`-Blockersatz, `PATCH /blocks`, `doc_shares`, `doc_team_shares`, Zwangsmigration auf „read“) | Feature komplett |
|
| 5 | Freigabe-Dialog mit Bearbeiter-Rolle, Ordnerfreigaben, Altlasten entfernen (`PUT`-Blockersatz, `PATCH /blocks`, `doc_shares`, `doc_team_shares`, Zwangsmigration auf „read“) | Feature komplett |
|
||||||
| 6 | Härtung: Playwright-Tests, Log-Kompaktierung (Snapshot statt Log für Clients mit sehr altem `lastSeq`), Aufräumen der Grabsteine | Langzeitbetrieb |
|
| 6 | Härtung: Playwright-Tests, Log-Kompaktierung (Snapshot statt Log für Clients mit sehr altem `lastSeq`), Aufräumen der Grabsteine | Langzeitbetrieb |
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ npm run dev # Vite-Entwicklungsserver
|
|||||||
npx tsc --noEmit # Typprüfung
|
npx tsc --noEmit # Typprüfung
|
||||||
npm run test:run # Komponententests (Vitest, jsdom)
|
npm run test:run # Komponententests (Vitest, jsdom)
|
||||||
npm run build # Produktions-Build
|
npm run build # Produktions-Build
|
||||||
|
cd ..
|
||||||
|
e2e/run.sh # Browser-Tests (Playwright, zwei Benutzer) gegen ein frisch gebautes Image
|
||||||
```
|
```
|
||||||
|
|
||||||
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus.
|
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Ende-zu-Ende-Tests
|
||||||
|
|
||||||
|
Browser-Tests mit Playwright gegen einen echten MindSift-Server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
e2e/run.sh # baut das Image, startet es im Demo-Modus und führt die Tests aus
|
||||||
|
ENGINE=docker e2e/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Gegen einen bereits laufenden Server (Demo-Modus mit `admin/admin` und `user/user` nötig):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd e2e && npm install && MINDSIFT_URL=http://localhost:8080 node collab.e2e.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## collab.e2e.mjs
|
||||||
|
|
||||||
|
Zwei Browser (Eigentümer und Bearbeiter) arbeiten in derselben Notiz. Geprüft wird:
|
||||||
|
|
||||||
|
- Tippen wird gespeichert und erscheint beim anderen ohne Neuladen
|
||||||
|
- neue Bullets per Enter, in beide Richtungen
|
||||||
|
- Offline-Phase: Text bleibt lokal und wird nach Rückkehr übertragen
|
||||||
|
- beide Browser und der Server zeigen denselben Stand, auch nach Neuladen
|
||||||
|
- Sperren eines geschlossenen Browsers laufen nach spätestens 20 s ab
|
||||||
|
- Einrücken (Tab) und Zusammenführen (Backspace) bleiben nach Neuladen erhalten
|
||||||
|
|
||||||
|
Der Test dauert wegen des Sperrablaufs etwa eine Minute. Er läuft nicht in der CI, weil er Browser-Images braucht.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// End-to-end test of collaborative note editing with two real browsers (owner and editor).
|
||||||
|
// Needs a server in demo mode (accounts admin/admin and user/user); see e2e/README.md.
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080';
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
let failures = 0;
|
||||||
|
const check = (cond, msg) => {
|
||||||
|
console.log(`${cond ? 'OK ' : 'FAIL'} ${msg}`);
|
||||||
|
if (!cond) failures++;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function api(token, method, path, body) {
|
||||||
|
const res = await fetch(BASE + path, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${text}`);
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(fn, ms, label) {
|
||||||
|
const end = Date.now() + ms;
|
||||||
|
let last;
|
||||||
|
while (Date.now() < end) {
|
||||||
|
try {
|
||||||
|
last = await fn();
|
||||||
|
if (last) return last;
|
||||||
|
} catch {}
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverBlocks = async (token, docId) => {
|
||||||
|
const snap = await api(token, 'GET', `/api/v1/docs/${docId}/snapshot`);
|
||||||
|
return snap.blocks.filter((b) => !b.deleted).map((b) => b.content);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Outliner text fields of a page (without the title field)
|
||||||
|
const editorValues = (page) =>
|
||||||
|
page.$$eval('textarea', (els) => els.filter((e) => !e.placeholder?.startsWith('Titel')).map((e) => e.value));
|
||||||
|
|
||||||
|
async function openDoc(browser, token, title) {
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||||
|
await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token);
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
page.on('pageerror', (e) => console.log('PAGEERROR', e.message));
|
||||||
|
await page.goto(BASE);
|
||||||
|
await page.getByRole('button', { name: /Workspace/ }).first().click();
|
||||||
|
await page.getByText(title, { exact: true }).first().click();
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('textarea').length > 1, null, { timeout: 10000 });
|
||||||
|
return { ctx, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TITLE = `E2E Notiz ${Date.now()}`;
|
||||||
|
const main = async () => {
|
||||||
|
const admin = (await api(null, 'POST', '/api/v1/auth/login', { login: 'admin', password: 'admin' })).token;
|
||||||
|
const user = (await api(null, 'POST', '/api/v1/auth/login', { login: 'user', password: 'user' })).token;
|
||||||
|
const dir = await api(admin, 'GET', '/api/v1/users/directory');
|
||||||
|
const userId = dir.find((u) => u.username === 'user' || u.email?.startsWith('user')).id;
|
||||||
|
const doc = await api(admin, 'POST', '/api/v1/documents', {
|
||||||
|
title: TITLE,
|
||||||
|
blocks: [{ id: 'e2e_1', type: 'bullet', content: 'Erster Punkt', level: 0 }],
|
||||||
|
});
|
||||||
|
await api(admin, 'POST', `/api/v1/documents/${doc.id}/shares`, { userId, permission: 'write' });
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const A = await openDoc(browser, admin, TITLE);
|
||||||
|
const B = await openDoc(browser, user, TITLE);
|
||||||
|
check((await editorValues(A.page)).includes('Erster Punkt'), 'Admin sieht die Notiz');
|
||||||
|
check((await editorValues(B.page)).includes('Erster Punkt'), 'Bearbeiter sieht die Notiz');
|
||||||
|
|
||||||
|
// 1. Admin types at the end of the first bullet
|
||||||
|
await A.page.getByText('Erster Punkt', { exact: true }).first().click();
|
||||||
|
await A.page.keyboard.press('End');
|
||||||
|
await A.page.keyboard.type(' + A');
|
||||||
|
const saved = await waitFor(async () => (await serverBlocks(admin, doc.id)).includes('Erster Punkt + A'), 5000);
|
||||||
|
check(!!saved, 'Getippter Text liegt auf dem Server');
|
||||||
|
const seenByB = await waitFor(async () => (await editorValues(B.page)).includes('Erster Punkt + A'), 8000);
|
||||||
|
check(!!seenByB, 'Bearbeiter sieht die Änderung ohne Neuladen');
|
||||||
|
|
||||||
|
// 2. Admin creates a new bullet with Enter
|
||||||
|
await A.page.keyboard.press('Enter');
|
||||||
|
await sleep(150); // a human does not type within the same frame as Enter
|
||||||
|
await A.page.keyboard.type('Zweiter von A');
|
||||||
|
const second = await waitFor(async () => (await editorValues(B.page)).includes('Zweiter von A'), 8000);
|
||||||
|
check(!!second, 'Neuer Bullet erscheint beim Bearbeiter');
|
||||||
|
|
||||||
|
// 3. Bearbeiter adds his own bullet at the end
|
||||||
|
await B.page.getByText('Zweiter von A', { exact: true }).first().click();
|
||||||
|
await B.page.keyboard.press('End');
|
||||||
|
await B.page.keyboard.press('Enter');
|
||||||
|
await sleep(150);
|
||||||
|
await B.page.keyboard.type('Von B');
|
||||||
|
const fromB = await waitFor(async () => (await editorValues(A.page)).includes('Von B'), 8000);
|
||||||
|
check(!!fromB, 'Bullet des Bearbeiters erscheint beim Admin');
|
||||||
|
|
||||||
|
// 4. Offline phase on the admin side
|
||||||
|
await A.ctx.setOffline(true);
|
||||||
|
await A.page.getByText('Erster Punkt + A', { exact: true }).first().click();
|
||||||
|
await A.page.keyboard.press('End');
|
||||||
|
await A.page.keyboard.type(' offline');
|
||||||
|
await sleep(1500);
|
||||||
|
check(!(await serverBlocks(admin, doc.id)).includes('Erster Punkt + A offline'), 'Offline getippt: noch nicht auf dem Server');
|
||||||
|
await A.ctx.setOffline(false);
|
||||||
|
await A.page.evaluate(() => window.dispatchEvent(new Event('online')));
|
||||||
|
const afterOnline = await waitFor(async () => (await serverBlocks(admin, doc.id)).includes('Erster Punkt + A offline'), 8000);
|
||||||
|
check(!!afterOnline, 'Nach Rückkehr online wird der Offline-Text übertragen');
|
||||||
|
|
||||||
|
// 5. Both converge and a reload shows the same state
|
||||||
|
await sleep(4000);
|
||||||
|
const final = await serverBlocks(admin, doc.id);
|
||||||
|
const a = await editorValues(A.page);
|
||||||
|
const b = await editorValues(B.page);
|
||||||
|
check(JSON.stringify(a) === JSON.stringify(b), `Beide Browser zeigen dasselbe: ${JSON.stringify(a)}`);
|
||||||
|
check(final.every((c) => a.includes(c)), `Server-Stand ist sichtbar: ${JSON.stringify(final)}`);
|
||||||
|
await A.page.reload();
|
||||||
|
await A.page.getByRole('button', { name: /Workspace/ }).first().click();
|
||||||
|
await A.page.getByText(TITLE, { exact: true }).first().click();
|
||||||
|
const reloaded = await waitFor(async () => {
|
||||||
|
const v = await editorValues(A.page);
|
||||||
|
return JSON.stringify(v) === JSON.stringify(a) ? v : null;
|
||||||
|
}, 8000);
|
||||||
|
check(!!reloaded, 'Nach Neuladen ist alles noch da');
|
||||||
|
|
||||||
|
// 6. Structure: indent with Tab, merge with Backspace, both survive a reload. B leaves first, so none of
|
||||||
|
// its block leases are active (with B's cursor in a block the server rightly rejects moving it).
|
||||||
|
await B.ctx.close();
|
||||||
|
// Closing a context abruptly may drop B's "leave" request; its leases then expire after 20 s
|
||||||
|
const freed = await waitFor(async () => {
|
||||||
|
const probe = await api(admin, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'probe', lastSeq: 0 });
|
||||||
|
return probe.leases.others.length === 0;
|
||||||
|
}, 25000);
|
||||||
|
check(!!freed, 'Sperren eines geschlossenen Browsers laufen nach spätestens 20 s ab');
|
||||||
|
await A.page.getByText('Von B', { exact: true }).first().click();
|
||||||
|
await A.page.keyboard.press('Home');
|
||||||
|
await A.page.keyboard.press('Tab');
|
||||||
|
await sleep(1500);
|
||||||
|
const legacy = await api(admin, 'GET', `/api/v1/documents/${doc.id}`);
|
||||||
|
const vonB = legacy.blocks.find((b) => b.content === 'Von B');
|
||||||
|
check(vonB && vonB.level === 1, `Tab rückt ein (Ebene ${vonB?.level})`);
|
||||||
|
await A.page.getByText('Zweiter von A', { exact: true }).first().click();
|
||||||
|
await A.page.keyboard.press('Home');
|
||||||
|
await A.page.keyboard.press('Backspace');
|
||||||
|
const merged = await waitFor(async () => (await serverBlocks(admin, doc.id)).includes('Erster Punkt + A offlineZweiter von A'), 5000);
|
||||||
|
check(!!merged, 'Backspace am Zeilenanfang führt mit dem Bullet darüber zusammen');
|
||||||
|
await A.page.reload();
|
||||||
|
await A.page.getByRole('button', { name: /Workspace/ }).first().click();
|
||||||
|
await A.page.getByText(TITLE, { exact: true }).first().click();
|
||||||
|
const afterStructure = await waitFor(async () => {
|
||||||
|
const v = await editorValues(A.page);
|
||||||
|
return v.includes('Erster Punkt + A offlineZweiter von A') && v.includes('Von B') && !v.includes('Zweiter von A') ? v : null;
|
||||||
|
}, 8000);
|
||||||
|
check(!!afterStructure, `Struktur nach Neuladen erhalten: ${JSON.stringify(afterStructure)}`);
|
||||||
|
const B2 = await openDoc(browser, user, TITLE);
|
||||||
|
const bSees = await waitFor(async () => {
|
||||||
|
const v = await editorValues(B2.page);
|
||||||
|
return v.includes('Erster Punkt + A offlineZweiter von A') && !v.includes('Zweiter von A') ? v : null;
|
||||||
|
}, 8000);
|
||||||
|
check(!!bSees, `Bearbeiter sieht nach erneutem Öffnen denselben Stand: ${JSON.stringify(bSees)}`);
|
||||||
|
|
||||||
|
const log = await api(admin, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'e2e-check', lastSeq: 0 });
|
||||||
|
const conflicts = log.ops.filter((e) => e.effect.kind === 'insert' && e.effect.props?.conflictOf).length;
|
||||||
|
console.log(`Log: ${log.headSeq} Einträge, ${conflicts} Konfliktkopien`);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log(failures ? `${failures} FEHLGESCHLAGEN` : 'ALLE PRÜFUNGEN BESTANDEN');
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(2);
|
||||||
|
});
|
||||||
Generated
+57
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "mindsift-e2e",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "mindsift-e2e",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.48.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.48.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.2.tgz",
|
||||||
|
"integrity": "sha512-NjYvYgp4BPmiwfe31j4gHLa3J7bD2WiBz8Lk2RoSsmX38SVIARZ18VYjxLjAcDsAhA+F4iSEXTSGgjua0rrlgQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.48.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.48.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz",
|
||||||
|
"integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "mindsift-e2e",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.48.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds MindSift, starts it in demo mode on port 18080 with a fresh database and runs the browser tests.
|
||||||
|
# Requires podman (or docker via ENGINE=docker). Usage: e2e/run.sh
|
||||||
|
set -euo pipefail
|
||||||
|
ENGINE="${ENGINE:-podman}"
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
trap '"$ENGINE" rm -f mindsift-e2e >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
|
echo "Baue Image …"
|
||||||
|
"$ENGINE" build -q -t mindsift-e2e-image "$ROOT" >/dev/null
|
||||||
|
"$ENGINE" run -d --name mindsift-e2e --network host -e PORT=18080 -e MINDA_DEMO=true \
|
||||||
|
-e JWT_SECRET=e2e-only-secret -v "$WORK":/data:Z mindsift-e2e-image >/dev/null
|
||||||
|
for _ in $(seq 1 60); do curl -s -m 1 http://localhost:18080/api/v1/health >/dev/null && break; sleep 0.5; done
|
||||||
|
|
||||||
|
echo "Starte Browser-Tests …"
|
||||||
|
"$ENGINE" run --rm --network host -v "$ROOT/e2e":/e2e:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
|
||||||
|
sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node collab.e2e.mjs'
|
||||||
Generated
+11
@@ -22,6 +22,7 @@
|
|||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"fake-indexeddb": "^6.2.6",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
@@ -2238,6 +2239,16 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fake-indexeddb": {
|
||||||
|
"version": "6.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.6.tgz",
|
||||||
|
"integrity": "sha512-ihhmr0KoET51jT8R4YhbUYuLLvr1qqO6INISygtN16i8loR8kAxtAUr4+EKHTSMjL+lggaAn4OC7DRqzrNgnuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"fake-indexeddb": "^6.2.6",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||||
import { api, getToken } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
|
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
||||||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||||
|
|
||||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||||
@@ -119,6 +120,29 @@ function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): Document
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True if both lists show the same blocks; avoids re-rendering when the sync engine echoes a local change.
|
||||||
|
function sameBlocks(a: DocumentBlock[], b: DocumentBlock[]): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
const x = a[i];
|
||||||
|
const y = b[i];
|
||||||
|
if (
|
||||||
|
x.id !== y.id ||
|
||||||
|
x.content !== y.content ||
|
||||||
|
(x.level || 0) !== (y.level || 0) ||
|
||||||
|
x.type !== y.type ||
|
||||||
|
!!x.checked !== !!y.checked ||
|
||||||
|
!!x.collapsed !== !!y.collapsed ||
|
||||||
|
(x.lang || '') !== (y.lang || '') ||
|
||||||
|
(x.taskId || '') !== (y.taskId || '') ||
|
||||||
|
(x.conflictOf || '') !== (y.conflictOf || '')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BreadcrumbNode {
|
export interface BreadcrumbNode {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -224,6 +248,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const [emojiSearch, setEmojiSearch] = useState<string>('');
|
const [emojiSearch, setEmojiSearch] = useState<string>('');
|
||||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Sync engine (operation log): stores every change locally and syncs it with the server
|
||||||
|
const engineRef = useRef<DocSyncEngine | null>(null);
|
||||||
|
const [engineState, setEngineState] = useState<EngineState | null>(null);
|
||||||
|
|
||||||
const filteredEmojis = useMemo(() => {
|
const filteredEmojis = useMemo(() => {
|
||||||
const q = emojiSearch.trim().toLowerCase();
|
const q = emojiSearch.trim().toLowerCase();
|
||||||
if (!q) return EMOJI_CATALOG;
|
if (!q) return EMOJI_CATALOG;
|
||||||
@@ -232,13 +260,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
);
|
);
|
||||||
}, [emojiSearch]);
|
}, [emojiSearch]);
|
||||||
|
|
||||||
// AutoSave state (REST persistence)
|
|
||||||
const [, setSaveStatus] = useState<DocSaveStatus>('saved');
|
|
||||||
const [, setSaveErrorMessage] = useState<string | null>(null);
|
|
||||||
const saveTimeoutRef = useRef<number | null>(null);
|
|
||||||
const isSavingRef = useRef<boolean>(false);
|
|
||||||
const lastSavedSnapshotRef = useRef<string>('');
|
|
||||||
const isInitialLoadRef = useRef<boolean>(true);
|
|
||||||
|
|
||||||
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
||||||
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
||||||
@@ -249,7 +270,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
currentTitleRef.current = title;
|
currentTitleRef.current = title;
|
||||||
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
|
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
|
||||||
currentBlocksRef.current = blocks;
|
currentBlocksRef.current = blocks;
|
||||||
const lastKnownUpdatedAtRef = useRef<string | null>(initialDoc?.updatedAt || null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
||||||
@@ -283,11 +303,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId);
|
return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId);
|
||||||
}, [title, doc?.title, blocks, zoomedBlockId]);
|
}, [title, doc?.title, blocks, zoomedBlockId]);
|
||||||
|
|
||||||
// Strictly Read-Only for shared documents: only the document owner can edit!
|
// Owner and editors may edit; the role comes from the server via the sync engine
|
||||||
const canEdit = useMemo(() => {
|
const canEdit = useMemo(() => {
|
||||||
if (!doc) return true;
|
if (engineState?.role) return engineState.role === 'owner' || engineState.role === 'editor';
|
||||||
return Boolean(doc.isOwner || doc.permission === 'owner');
|
if (!doc) return false;
|
||||||
}, [doc]);
|
return Boolean(doc.isOwner || doc.permission === 'owner' || doc.permission === 'write');
|
||||||
|
}, [doc, engineState?.role]);
|
||||||
|
|
||||||
// Extract all #tags present across the current blocks
|
// Extract all #tags present across the current blocks
|
||||||
const extractedTags = useMemo(() => {
|
const extractedTags = useMemo(() => {
|
||||||
@@ -303,124 +324,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
return Array.from(tagSet);
|
return Array.from(tagSet);
|
||||||
}, [blocks]);
|
}, [blocks]);
|
||||||
|
|
||||||
const currentTagsRef = useRef<string[]>(extractedTags);
|
|
||||||
currentTagsRef.current = extractedTags;
|
|
||||||
|
|
||||||
// AutoSave snapshot
|
|
||||||
const currentSnapshot = useMemo(() => {
|
|
||||||
return JSON.stringify({
|
|
||||||
title,
|
|
||||||
blocks,
|
|
||||||
tags: extractedTags,
|
|
||||||
});
|
|
||||||
}, [title, blocks, extractedTags]);
|
|
||||||
|
|
||||||
const currentSnapshotRef = useRef<string>(currentSnapshot);
|
|
||||||
currentSnapshotRef.current = currentSnapshot;
|
|
||||||
|
|
||||||
const executeAutoSave = useCallback(
|
|
||||||
async (immediate: boolean = false) => {
|
|
||||||
if (!docId || !canEdit) return;
|
|
||||||
if (isSavingRef.current && !immediate) return;
|
|
||||||
|
|
||||||
const snapshot = currentSnapshotRef.current;
|
|
||||||
if (snapshot === lastSavedSnapshotRef.current) {
|
|
||||||
setSaveStatus('saved');
|
|
||||||
onSaveStatusChange?.('saved');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSavingRef.current = true;
|
|
||||||
setSaveStatus('saving');
|
|
||||||
onSaveStatusChange?.('saving');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await api.updateDocument(docId, {
|
|
||||||
title: currentTitleRef.current,
|
|
||||||
blocks: currentBlocksRef.current,
|
|
||||||
tags: currentTagsRef.current,
|
|
||||||
});
|
|
||||||
isSavingRef.current = false;
|
|
||||||
lastSavedSnapshotRef.current = snapshot;
|
|
||||||
lastKnownUpdatedAtRef.current = updated.updatedAt || null;
|
|
||||||
setSaveStatus('saved');
|
|
||||||
setSaveErrorMessage(null);
|
|
||||||
onSaveStatusChange?.('saved');
|
|
||||||
if (docRef.current) {
|
|
||||||
docRef.current = updated;
|
|
||||||
onDocMetadataChange(updated);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
isSavingRef.current = false;
|
|
||||||
const msg = err?.message || 'Fehler beim Speichern';
|
|
||||||
setSaveStatus('error');
|
|
||||||
setSaveErrorMessage(msg);
|
|
||||||
onSaveStatusChange?.('error', msg);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[docId, canEdit, onSaveStatusChange, onDocMetadataChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isInitialLoadRef.current) return;
|
|
||||||
if (!canEdit) return;
|
|
||||||
if (currentSnapshot === lastSavedSnapshotRef.current) return;
|
|
||||||
|
|
||||||
setSaveStatus('unsaved');
|
|
||||||
onSaveStatusChange?.('unsaved');
|
|
||||||
|
|
||||||
if (saveTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(saveTimeoutRef.current);
|
|
||||||
}
|
|
||||||
saveTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
saveTimeoutRef.current = null;
|
|
||||||
executeAutoSave();
|
|
||||||
}, 800);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (saveTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(saveTimeoutRef.current);
|
|
||||||
saveTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [currentSnapshot, canEdit, executeAutoSave, onSaveStatusChange]);
|
|
||||||
|
|
||||||
// Flush on page unload / pagehide via keepalive fetch
|
|
||||||
useEffect(() => {
|
|
||||||
const handleUnload = () => {
|
|
||||||
if (saveTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(saveTimeoutRef.current);
|
|
||||||
saveTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
const snapshot = currentSnapshotRef.current;
|
|
||||||
if (snapshot !== lastSavedSnapshotRef.current && canEdit) {
|
|
||||||
const token = getToken();
|
|
||||||
if (token) {
|
|
||||||
fetch(`/api/v1/documents/${docId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: currentTitleRef.current,
|
|
||||||
blocks: currentBlocksRef.current,
|
|
||||||
tags: currentTagsRef.current,
|
|
||||||
}),
|
|
||||||
keepalive: true,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', handleUnload);
|
|
||||||
window.addEventListener('pagehide', handleUnload);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('beforeunload', handleUnload);
|
|
||||||
window.removeEventListener('pagehide', handleUnload);
|
|
||||||
handleUnload();
|
|
||||||
};
|
|
||||||
}, [docId, canEdit]);
|
|
||||||
|
|
||||||
// Fetch document on mount
|
// Fetch document on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -436,29 +339,19 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
const normalized = ensureNonEmptyBlocks(docId, fetched.blocks);
|
const normalized = ensureNonEmptyBlocks(docId, fetched.blocks);
|
||||||
setDoc(fetched);
|
setDoc(fetched);
|
||||||
setTitle(fetched.title || 'Unbenannte Notiz');
|
if (!engineRef.current?.getState().ready) {
|
||||||
setBlocks(normalized);
|
setTitle(fetched.title || 'Unbenannte Notiz');
|
||||||
|
currentBlocksRef.current = normalized;
|
||||||
|
setBlocks(normalized);
|
||||||
|
}
|
||||||
|
const links = new Map<string, string>();
|
||||||
|
for (const b of fetched.blocks || []) {
|
||||||
|
if (b.taskId) links.set(b.id, b.taskId);
|
||||||
|
}
|
||||||
|
engineRef.current?.setTaskLinks(links);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
onDocMetadataChange(fetched);
|
onDocMetadataChange(fetched);
|
||||||
|
|
||||||
const initialTags: string[] = [];
|
|
||||||
for (const b of normalized) {
|
|
||||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
|
||||||
if (matches) {
|
|
||||||
for (const m of matches) {
|
|
||||||
const tag = m.slice(1).toLowerCase();
|
|
||||||
if (!initialTags.includes(tag)) initialTags.push(tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastSavedSnapshotRef.current = JSON.stringify({
|
|
||||||
title: fetched.title || 'Unbenannte Notiz',
|
|
||||||
blocks: normalized,
|
|
||||||
tags: initialTags,
|
|
||||||
});
|
|
||||||
isInitialLoadRef.current = false;
|
|
||||||
setSaveStatus('saved');
|
|
||||||
onSaveStatusChange?.('saved');
|
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
.catch((err: any) => {
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
@@ -471,6 +364,60 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
};
|
};
|
||||||
}, [docId]);
|
}, [docId]);
|
||||||
|
|
||||||
|
// Start the sync engine for this note: shows stored state immediately (also offline), then syncs
|
||||||
|
useEffect(() => {
|
||||||
|
const engine = new DocSyncEngine(docId);
|
||||||
|
engineRef.current = engine;
|
||||||
|
const unsubscribe = engine.subscribe((state) => {
|
||||||
|
setEngineState(state);
|
||||||
|
if (!state.ready) return;
|
||||||
|
const next = ensureNonEmptyBlocks(docId, state.blocks);
|
||||||
|
if (!sameBlocks(currentBlocksRef.current, next)) {
|
||||||
|
currentBlocksRef.current = next;
|
||||||
|
setBlocks(next);
|
||||||
|
}
|
||||||
|
if (state.title && state.title !== currentTitleRef.current) {
|
||||||
|
setTitle(state.title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
void engine.start();
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
engine.stop();
|
||||||
|
if (engineRef.current === engine) engineRef.current = null;
|
||||||
|
};
|
||||||
|
}, [docId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!engineState) return;
|
||||||
|
switch (engineState.status) {
|
||||||
|
case 'saving':
|
||||||
|
onSaveStatusChange?.('saving');
|
||||||
|
break;
|
||||||
|
case 'offline':
|
||||||
|
onSaveStatusChange?.('unsaved', 'Offline – Änderungen sind lokal gesichert und werden gesendet, sobald die Verbindung zurück ist');
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
onSaveStatusChange?.('error', engineState.errorMessage);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
onSaveStatusChange?.('saved');
|
||||||
|
}
|
||||||
|
}, [engineState?.status, engineState?.errorMessage]);
|
||||||
|
|
||||||
|
// Every local block change goes to the engine, which turns it into operations
|
||||||
|
const applyLocalBlocks = useCallback(
|
||||||
|
(update: DocumentBlock[] | ((prev: DocumentBlock[]) => DocumentBlock[])) => {
|
||||||
|
const prev = currentBlocksRef.current;
|
||||||
|
const next = typeof update === 'function' ? update(prev) : update;
|
||||||
|
if (next === prev) return;
|
||||||
|
currentBlocksRef.current = next;
|
||||||
|
setBlocks(next);
|
||||||
|
engineRef.current?.applyFlat(next);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// Keep parent sidebar metadata in sync when blocks/tags change
|
// Keep parent sidebar metadata in sync when blocks/tags change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!docRef.current) return;
|
if (!docRef.current) return;
|
||||||
@@ -488,27 +435,19 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
setTitle(e.target.value);
|
setTitle(e.target.value);
|
||||||
|
engineRef.current?.setTitle(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTitleBlur = () => {
|
const handleTitleBlur = () => {
|
||||||
executeAutoSave();
|
void engineRef.current?.syncNow();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateBlock = useCallback(
|
const handleUpdateBlock = useCallback(
|
||||||
(updatedBlock: DocumentBlock, immediate: boolean = false) => {
|
(updatedBlock: DocumentBlock) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
setBlocks((prev) => {
|
applyLocalBlocks((prev) => prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b)));
|
||||||
const next = prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b));
|
|
||||||
currentBlocksRef.current = next;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
if (immediate) {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
executeAutoSave(true);
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[canEdit, executeAutoSave]
|
[canEdit, applyLocalBlocks]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdateBlocks = useCallback(
|
const handleUpdateBlocks = useCallback(
|
||||||
@@ -518,17 +457,15 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
for (const b of updatedBlocks) {
|
for (const b of updatedBlocks) {
|
||||||
map.set(b.id, b);
|
map.set(b.id, b);
|
||||||
}
|
}
|
||||||
setBlocks((prev) =>
|
applyLocalBlocks((prev) => prev.map((b) => map.get(b.id) || b));
|
||||||
prev.map((b) => map.get(b.id) || b)
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[canEdit]
|
[canEdit, applyLocalBlocks]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateBlock = useCallback(
|
const handleCreateBlock = useCallback(
|
||||||
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
setBlocks((prev) => {
|
applyLocalBlocks((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
if (afterBlockId) {
|
if (afterBlockId) {
|
||||||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||||||
@@ -541,13 +478,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
return ensureNonEmptyBlocks(docId, next);
|
return ensureNonEmptyBlocks(docId, next);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[docId, canEdit]
|
[docId, canEdit, applyLocalBlocks]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleInsertBlocks = useCallback(
|
const handleInsertBlocks = useCallback(
|
||||||
(afterBlockId: string, updatedCurrentBlock: DocumentBlock, newBlocks: DocumentBlock[]) => {
|
(afterBlockId: string, updatedCurrentBlock: DocumentBlock, newBlocks: DocumentBlock[]) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
setBlocks((prev) => {
|
applyLocalBlocks((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
@@ -556,32 +493,24 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
next.push(updatedCurrentBlock, ...newBlocks);
|
next.push(updatedCurrentBlock, ...newBlocks);
|
||||||
}
|
}
|
||||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
return ensureNonEmptyBlocks(docId, next);
|
||||||
currentBlocksRef.current = normalized;
|
|
||||||
return normalized;
|
|
||||||
});
|
});
|
||||||
window.setTimeout(() => {
|
|
||||||
executeAutoSave(true);
|
|
||||||
}, 50);
|
|
||||||
},
|
},
|
||||||
[docId, canEdit, executeAutoSave]
|
[docId, canEdit, applyLocalBlocks]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteBlock = useCallback(
|
const handleDeleteBlock = useCallback(
|
||||||
(blockId: string) => {
|
(blockId: string) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
setBlocks((prev) => {
|
applyLocalBlocks((prev) => ensureNonEmptyBlocks(docId, prev.filter((b) => b.id !== blockId)));
|
||||||
const filtered = prev.filter((b) => b.id !== blockId);
|
|
||||||
return ensureNonEmptyBlocks(docId, filtered);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[docId, canEdit]
|
[docId, canEdit, applyLocalBlocks]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMoveBlock = useCallback(
|
const handleMoveBlock = useCallback(
|
||||||
(blockId: string, targetIndex: number, newLevel?: number) => {
|
(blockId: string, targetIndex: number, newLevel?: number) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
setBlocks((prev) => {
|
applyLocalBlocks((prev) => {
|
||||||
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
||||||
if (fromIdx === -1) return prev;
|
if (fromIdx === -1) return prev;
|
||||||
|
|
||||||
@@ -632,7 +561,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
return ensureNonEmptyBlocks(docId, copy);
|
return ensureNonEmptyBlocks(docId, copy);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[docId, canEdit]
|
[docId, canEdit, applyLocalBlocks]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTogglePin = async () => {
|
const handleTogglePin = async () => {
|
||||||
@@ -977,6 +906,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onTagClick={onSelectTag}
|
onTagClick={onSelectTag}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
initialFocusedBlockId={initialFocusedBlockId}
|
initialFocusedBlockId={initialFocusedBlockId}
|
||||||
|
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1113,6 +1043,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onTagClick={onSelectTag}
|
onTagClick={onSelectTag}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
initialFocusedBlockId={initialFocusedBlockId}
|
initialFocusedBlockId={initialFocusedBlockId}
|
||||||
|
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface OutlinerTreeProps {
|
|||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
onWikiLinkClick?: (pageTitle: string) => void;
|
onWikiLinkClick?: (pageTitle: string) => void;
|
||||||
initialFocusedBlockId?: string | null;
|
initialFocusedBlockId?: string | null;
|
||||||
|
onFocusedBlockChange?: (blockId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||||
@@ -41,8 +42,15 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onTagClick,
|
onTagClick,
|
||||||
onWikiLinkClick,
|
onWikiLinkClick,
|
||||||
initialFocusedBlockId,
|
initialFocusedBlockId,
|
||||||
|
onFocusedBlockChange,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onFocusedBlockChangeRef = useRef(onFocusedBlockChange);
|
||||||
|
onFocusedBlockChangeRef.current = onFocusedBlockChange;
|
||||||
|
useEffect(() => {
|
||||||
|
onFocusedBlockChangeRef.current?.(focusedBlockId);
|
||||||
|
}, [focusedBlockId]);
|
||||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
||||||
initialFocusedBlockId || null
|
initialFocusedBlockId || null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { DocumentBlock } from '../../types';
|
||||||
|
import { diffFlat, toDocumentBlocks } from '../diff';
|
||||||
|
import { applyEffect, applyOp, cloneDoc, docsEqual, emptyDoc, Doc } from '../model';
|
||||||
|
|
||||||
|
let opSeq = 0;
|
||||||
|
const nextOp = () => `op${++opSeq}`;
|
||||||
|
|
||||||
|
function build(blocks: DocumentBlock[]): Doc {
|
||||||
|
const d = emptyDoc();
|
||||||
|
for (const op of diffFlat(d, blocks, nextOp)) applyOp(d, op);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
const B = (id: string, level: number, content = id, extra: Partial<DocumentBlock> = {}): DocumentBlock => ({
|
||||||
|
id,
|
||||||
|
level,
|
||||||
|
content,
|
||||||
|
type: 'bullet',
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shape = (blocks: DocumentBlock[]) => blocks.map((b) => [b.id, b.level || 0, b.content, !!b.checked, b.type]);
|
||||||
|
|
||||||
|
describe('diffFlat', () => {
|
||||||
|
it('builds the tree from levels and produces no ops for an unchanged list', () => {
|
||||||
|
const list = [B('a', 0), B('b', 1), B('c', 2), B('d', 1), B('e', 0)];
|
||||||
|
const d = build(list);
|
||||||
|
expect(shape(toDocumentBlocks(d, 'doc'))).toEqual(shape(list));
|
||||||
|
expect(diffFlat(d, toDocumentBlocks(d, 'doc'), nextOp)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns typing into a single set_content without moves', () => {
|
||||||
|
const d = build([B('a', 0), B('b', 0), B('c', 0)]);
|
||||||
|
const ops = diffFlat(d, [B('a', 0), B('b', 0, 'b geändert'), B('c', 0)], nextOp);
|
||||||
|
expect(ops.map((o) => o.kind)).toEqual(['set_content']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserting in the middle does not move the neighbours', () => {
|
||||||
|
const d = build([B('a', 0), B('c', 0)]);
|
||||||
|
const ops = diffFlat(d, [B('a', 0), B('b', 0), B('c', 0)], nextOp);
|
||||||
|
expect(ops.map((o) => o.kind)).toEqual(['insert']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaches any target list (random edits)', () => {
|
||||||
|
let seed = 99;
|
||||||
|
const rnd = (n: number) => {
|
||||||
|
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||||
|
return Math.floor((seed / 2147483648) * n);
|
||||||
|
};
|
||||||
|
let current: DocumentBlock[] = [B('x0', 0)];
|
||||||
|
let d = build(current);
|
||||||
|
for (let step = 0; step < 300; step++) {
|
||||||
|
const next = current.map((b) => ({ ...b }));
|
||||||
|
const i = rnd(next.length);
|
||||||
|
switch (rnd(6)) {
|
||||||
|
case 0:
|
||||||
|
next.splice(i + 1, 0, B(`x${step + 1}`, next[i] ? next[i].level || 0 : 0));
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
if (next.length > 1) next.splice(i, 1);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
next[i].content += '!';
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
next[i].level = (next[i].level || 0) + 1;
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
next[i].level = Math.max(0, (next[i].level || 0) - 1);
|
||||||
|
break;
|
||||||
|
case 5: {
|
||||||
|
const [moved] = next.splice(i, 1);
|
||||||
|
next.splice(rnd(next.length + 1), 0, moved);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Normalise like the editor would (levels can only grow by one)
|
||||||
|
let prev = -1;
|
||||||
|
for (const b of next) {
|
||||||
|
b.level = Math.min(b.level || 0, prev + 1);
|
||||||
|
prev = b.level;
|
||||||
|
}
|
||||||
|
const before = cloneDoc(d);
|
||||||
|
const replay = cloneDoc(d);
|
||||||
|
for (const op of diffFlat(d, next, nextOp)) {
|
||||||
|
const { effects } = applyOp(d, op);
|
||||||
|
effects.forEach((e) => applyEffect(replay, e));
|
||||||
|
}
|
||||||
|
expect(shape(toDocumentBlocks(d, 'doc'))).toEqual(shape(next));
|
||||||
|
expect(docsEqual(d, replay)).toBe(true);
|
||||||
|
expect(before).not.toBe(d);
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import 'fake-indexeddb/auto';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { DocumentBlock } from '../../types';
|
||||||
|
import { DocSyncEngine, formBatch } from '../engine';
|
||||||
|
import { applyOp, cloneDoc, Doc, emptyDoc, Op, Result } from '../model';
|
||||||
|
import { IndexedDBStorage, MemoryStorage } from '../persist';
|
||||||
|
import { LogEntry, SnapshotResponse, SyncRequest, SyncResponse, Transport, TransportError } from '../transport';
|
||||||
|
|
||||||
|
/** In-memory stand-in for the Go server: same operation rules, log and idempotency. */
|
||||||
|
class FakeServer {
|
||||||
|
doc: Doc = emptyDoc();
|
||||||
|
log: LogEntry[] = [];
|
||||||
|
results = new Map<string, Result>();
|
||||||
|
roles = new Map<string, string>(); // clientId -> role
|
||||||
|
applyCount = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(initial: { id: string; content: string }[] = [{ id: 'b1', content: 'Start' }]) {
|
||||||
|
let key = 'a0';
|
||||||
|
for (const b of initial) {
|
||||||
|
this.apply({ opId: `seed-${b.id}`, kind: 'insert', blockId: b.id, parentId: '', orderKey: key, content: b.content }, 'seed');
|
||||||
|
key = String.fromCharCode(key.charCodeAt(0)) + String.fromCharCode(key.charCodeAt(1) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(op: Op, clientId: string): Result {
|
||||||
|
const known = this.results.get(op.opId);
|
||||||
|
if (known) return known;
|
||||||
|
this.applyCount.set(op.opId, (this.applyCount.get(op.opId) || 0) + 1);
|
||||||
|
const { effects, result } = applyOp(this.doc, op, clientId);
|
||||||
|
for (const e of effects) this.log.push({ seq: this.log.length + 1, userId: clientId, clientId, opId: op.opId, effect: e });
|
||||||
|
if (effects.length > 0) result.seq = this.log.length;
|
||||||
|
this.results.set(op.opId, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): SnapshotResponse {
|
||||||
|
return {
|
||||||
|
docId: 'doc',
|
||||||
|
title: this.doc.title,
|
||||||
|
titleVersion: this.doc.titleVersion,
|
||||||
|
headSeq: this.log.length,
|
||||||
|
blocks: Array.from(cloneDoc(this.doc).blocks.values()),
|
||||||
|
role: 'owner',
|
||||||
|
shared: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sync(body: SyncRequest): SyncResponse {
|
||||||
|
const role = this.roles.get(body.clientId) || 'owner';
|
||||||
|
if (body.ops.length > 0 && role === 'viewer') throw new TransportError('nur Lesen', 403);
|
||||||
|
const results = body.ops.map((op) => this.apply(op, body.clientId));
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
ops: this.log.filter((e) => e.seq > body.lastSeq),
|
||||||
|
headSeq: this.log.length,
|
||||||
|
hasMore: false,
|
||||||
|
role,
|
||||||
|
shared: true,
|
||||||
|
sessions: 2,
|
||||||
|
presence: [],
|
||||||
|
leases: { granted: [], denied: [], others: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Network between one client and the fake server, with switchable failures. */
|
||||||
|
class FakeNet implements Transport {
|
||||||
|
mode: 'ok' | 'down' | 'lose-response' = 'ok';
|
||||||
|
duringRequest: (() => void) | null = null; // runs while the request is "on the wire"
|
||||||
|
|
||||||
|
constructor(private server: FakeServer, private role = 'owner') {}
|
||||||
|
async snapshot() {
|
||||||
|
if (this.mode === 'down') throw new TransportError('offline', 0);
|
||||||
|
return { ...this.server.snapshot(), role: this.role };
|
||||||
|
}
|
||||||
|
async sync(_docId: string, body: SyncRequest) {
|
||||||
|
if (this.mode === 'down') throw new TransportError('offline', 0);
|
||||||
|
this.server.roles.set(body.clientId, this.role);
|
||||||
|
const hook = this.duringRequest;
|
||||||
|
this.duringRequest = null;
|
||||||
|
hook?.();
|
||||||
|
const res = this.server.sync(body);
|
||||||
|
if (this.mode === 'lose-response') throw new TransportError('timeout', 0);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
async leave() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flatOf(engine: DocSyncEngine): DocumentBlock[] {
|
||||||
|
return engine.getState().blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(engine: DocSyncEngine, fn: (blocks: DocumentBlock[]) => DocumentBlock[]) {
|
||||||
|
engine.applyFlat(fn(flatOf(engine).map((b) => ({ ...b }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = (engine: DocSyncEngine) => flatOf(engine).map((b) => b.content);
|
||||||
|
const serverContents = (server: FakeServer) => docContents(server.doc);
|
||||||
|
function docContents(d: Doc): string[] {
|
||||||
|
return Array.from(d.blocks.values())
|
||||||
|
.filter((b) => !b.deleted)
|
||||||
|
.sort((a, b) => (a.orderKey < b.orderKey ? -1 : a.orderKey > b.orderKey ? 1 : a.id < b.id ? -1 : 1))
|
||||||
|
.map((b) => b.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startEngine(net: Transport, storage = new MemoryStorage()) {
|
||||||
|
const engine = new DocSyncEngine('doc', { transport: net, storage, manual: true });
|
||||||
|
await engine.start();
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DocSyncEngine', () => {
|
||||||
|
it('sends edits and confirms them', async () => {
|
||||||
|
const server = new FakeServer();
|
||||||
|
const engine = await startEngine(new FakeNet(server));
|
||||||
|
edit(engine, (b) => {
|
||||||
|
b[0].content = 'Hallo';
|
||||||
|
return [...b, { id: 'b2', type: 'bullet', content: 'Neu', level: 1 }];
|
||||||
|
});
|
||||||
|
expect(engine.getState().pendingCount).toBeGreaterThan(0);
|
||||||
|
await engine.syncNow();
|
||||||
|
expect(engine.getState().status).toBe('saved');
|
||||||
|
expect(engine.getState().pendingCount).toBe(0);
|
||||||
|
expect(serverContents(server)).toEqual(['Hallo', 'Neu']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces fast typing into one text operation', async () => {
|
||||||
|
const server = new FakeServer();
|
||||||
|
const engine = await startEngine(new FakeNet(server));
|
||||||
|
for (const text of ['H', 'Ha', 'Hal', 'Hall', 'Hallo']) edit(engine, (b) => [{ ...b[0], content: text }]);
|
||||||
|
await engine.syncNow();
|
||||||
|
const textOps = server.log.filter((e) => e.effect.kind === 'set_content');
|
||||||
|
expect(textOps).toHaveLength(1);
|
||||||
|
expect(textOps[0].effect.content).toBe('Hallo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps edits through an offline phase and a reload, and applies them exactly once', async () => {
|
||||||
|
const server = new FakeServer();
|
||||||
|
const storage = new MemoryStorage();
|
||||||
|
const net = new FakeNet(server);
|
||||||
|
const first = await startEngine(net, storage);
|
||||||
|
|
||||||
|
net.mode = 'down';
|
||||||
|
edit(first, (b) => [{ ...b[0], content: 'Offline getippt' }, { id: 'b9', type: 'bullet', content: 'Noch einer', level: 0 }]);
|
||||||
|
await new Promise((r) => setTimeout(r, 0)); // let the queue reach storage
|
||||||
|
await first.syncNow();
|
||||||
|
expect(first.getState().status).toBe('offline');
|
||||||
|
expect(contents(first)).toEqual(['Offline getippt', 'Noch einer']);
|
||||||
|
|
||||||
|
// Tab is closed while offline; a new tab opens the note from storage, still offline
|
||||||
|
const second = await startEngine(net, storage);
|
||||||
|
expect(contents(second)).toEqual(['Offline getippt', 'Noch einer']);
|
||||||
|
|
||||||
|
net.mode = 'ok';
|
||||||
|
await second.syncNow();
|
||||||
|
await second.syncNow();
|
||||||
|
expect(serverContents(server)).toEqual(['Offline getippt', 'Noch einer']);
|
||||||
|
for (const count of server.applyCount.values()) expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries after a lost response without applying twice', async () => {
|
||||||
|
const server = new FakeServer();
|
||||||
|
const net = new FakeNet(server);
|
||||||
|
const engine = await startEngine(net);
|
||||||
|
edit(engine, (b) => [...b, { id: 'b2', type: 'bullet', content: 'Einmal', level: 0 }]);
|
||||||
|
net.mode = 'lose-response';
|
||||||
|
await engine.syncNow();
|
||||||
|
expect(engine.getState().status).toBe('offline');
|
||||||
|
net.mode = 'ok';
|
||||||
|
await engine.syncNow();
|
||||||
|
expect(serverContents(server)).toEqual(['Start', 'Einmal']);
|
||||||
|
expect(server.log.filter((e) => e.effect.kind === 'insert' && e.effect.blockId === 'b2')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two clients converge, and a conflict keeps both texts', async () => {
|
||||||
|
const server = new FakeServer();
|
||||||
|
const a = await startEngine(new FakeNet(server));
|
||||||
|
const b = await startEngine(new FakeNet(server));
|
||||||
|
|
||||||
|
edit(a, (blocks) => [{ ...blocks[0], content: 'Von A' }]);
|
||||||
|
edit(b, (blocks) => [{ ...blocks[0], content: 'Von B' }]);
|
||||||
|
await a.syncNow();
|
||||||
|
await b.syncNow(); // B's base version is stale -> conflict copy
|
||||||
|
await a.syncNow();
|
||||||
|
|
||||||
|
expect(contents(a)).toEqual(contents(b));
|
||||||
|
expect(contents(a)).toEqual(['Von A', 'Von B']);
|
||||||
|
expect(flatOf(a)[1].conflictOf).toBe('b1');
|
||||||
|
expect(b.getState().conflicts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps text when two clients create the same first block of an empty note', async () => {
|
||||||
|
const server = new FakeServer([]);
|
||||||
|
const a = await startEngine(new FakeNet(server));
|
||||||
|
const b = await startEngine(new FakeNet(server));
|
||||||
|
a.applyFlat([{ id: 'doc-b0', type: 'text', content: 'Text von A', level: 0 }]);
|
||||||
|
b.applyFlat([{ id: 'doc-b0', type: 'text', content: 'Text von B', level: 0 }]);
|
||||||
|
await a.syncNow();
|
||||||
|
await b.syncNow(); // insert rejected: block exists
|
||||||
|
await b.syncNow(); // text resent as set_content -> conflict copy
|
||||||
|
await a.syncNow();
|
||||||
|
expect(contents(a)).toEqual(contents(b));
|
||||||
|
expect(contents(a)).toEqual(expect.arrayContaining(['Text von A', 'Text von B']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite a change that arrived while the user kept typing', async () => {
|
||||||
|
const server = new FakeServer([
|
||||||
|
{ id: 'b1', content: 'Start' },
|
||||||
|
{ id: 'b2', content: 'Anderer Punkt' },
|
||||||
|
]);
|
||||||
|
const netA = new FakeNet(server);
|
||||||
|
const a = await startEngine(netA);
|
||||||
|
const b = await startEngine(new FakeNet(server));
|
||||||
|
|
||||||
|
edit(a, (blocks) => [blocks[0], { ...blocks[1], content: 'Anderer Punkt!' }]);
|
||||||
|
netA.duringRequest = () => {
|
||||||
|
// While A's request travels: B's change of b1 reaches the server first, and A starts typing into
|
||||||
|
// b1 (still seeing 'Start')
|
||||||
|
server.apply({ opId: 'b-op', kind: 'set_content', blockId: 'b1', baseVersion: 1, content: 'B war schneller' }, 'cB');
|
||||||
|
edit(a, (blocks) => [{ ...blocks[0], content: 'Start + A' }, blocks[1]]);
|
||||||
|
};
|
||||||
|
await a.syncNow(); // response brings B's change; A's text for b1 is still pending
|
||||||
|
await a.syncNow();
|
||||||
|
await b.syncNow();
|
||||||
|
|
||||||
|
expect(contents(a)).toEqual(contents(b));
|
||||||
|
expect(contents(a)).toEqual(expect.arrayContaining(['B war schneller', 'Start + A', 'Anderer Punkt!']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a stale focused text field cannot overwrite a newer remote version', async () => {
|
||||||
|
const server = new FakeServer();
|
||||||
|
const a = await startEngine(new FakeNet(server));
|
||||||
|
const b = await startEngine(new FakeNet(server));
|
||||||
|
|
||||||
|
a.focusBlock('b1'); // A's cursor stays in the block; its field keeps showing 'Start'
|
||||||
|
edit(b, (blocks) => [{ ...blocks[0], content: 'B hat geändert' }]);
|
||||||
|
await b.syncNow();
|
||||||
|
await a.syncNow(); // A receives B's version, but the focused field still shows the old text
|
||||||
|
|
||||||
|
a.applyFlat([{ id: 'b1', type: 'bullet', content: 'Start + A', level: 0 }]);
|
||||||
|
await a.syncNow();
|
||||||
|
await b.syncNow();
|
||||||
|
|
||||||
|
expect(contents(a)).toEqual(contents(b));
|
||||||
|
expect(contents(a)).toEqual(expect.arrayContaining(['B hat geändert', 'Start + A']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('viewers cannot change anything', async () => {
|
||||||
|
const server = new FakeServer();
|
||||||
|
const viewer = await startEngine(new FakeNet(server, 'viewer'));
|
||||||
|
edit(viewer, (b) => [{ ...b[0], content: 'Leser' }]);
|
||||||
|
await viewer.syncNow();
|
||||||
|
expect(viewer.getState().pendingCount).toBe(0);
|
||||||
|
expect(serverContents(server)).toEqual(['Start']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formBatch keeps the last text per block with the base of the first', () => {
|
||||||
|
const batch = formBatch([
|
||||||
|
{ opId: '1', kind: 'set_content', blockId: 'a', content: 'x', baseVersion: 5 },
|
||||||
|
{ opId: '2', kind: 'set_title', title: 'T1', baseVersion: 3 },
|
||||||
|
{ opId: '3', kind: 'set_content', blockId: 'a', content: 'xy', baseVersion: 6 },
|
||||||
|
{ opId: '4', kind: 'set_title', title: 'T2', baseVersion: 4 },
|
||||||
|
]);
|
||||||
|
expect(batch).toEqual([
|
||||||
|
{ opId: '3', kind: 'set_content', blockId: 'a', content: 'xy', baseVersion: 5 },
|
||||||
|
{ opId: '4', kind: 'set_title', title: 'T2', baseVersion: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores state in IndexedDB', async () => {
|
||||||
|
const storage = new IndexedDBStorage(indexedDB);
|
||||||
|
await storage.save({ docId: 'd', title: 'T', titleVersion: 1, lastSeq: 7, role: 'owner', blocks: [], pending: [], inflight: null, savedAt: 1 });
|
||||||
|
const loaded = await storage.load('d');
|
||||||
|
expect(loaded?.lastSeq).toBe(7);
|
||||||
|
expect(await storage.load('missing')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { keyBetween, keysBetween, validOrderKey } from '../orderKey';
|
||||||
|
|
||||||
|
// Generated with internal/collab (Go); both implementations must agree exactly.
|
||||||
|
const VECTORS: [string, string, string][] = [
|
||||||
|
['', '', 'a0'],
|
||||||
|
['a0', '', 'a1'],
|
||||||
|
['', 'a0', 'Zz'],
|
||||||
|
['a0', 'a1', 'a0V'],
|
||||||
|
['a0', 'a0V', 'a0F'],
|
||||||
|
['a0V', 'a1', 'a0k'],
|
||||||
|
['Zz', 'a0', 'ZzV'],
|
||||||
|
['az', '', 'b00'],
|
||||||
|
['', 'Zz', 'Zy'],
|
||||||
|
['a1', 'a1V', 'a1F'],
|
||||||
|
['b00', 'b01', 'b00V'],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('order keys', () => {
|
||||||
|
it('match the Go implementation', () => {
|
||||||
|
for (const [a, b, want] of VECTORS) expect(keyBetween(a, b)).toBe(want);
|
||||||
|
expect(keysBetween('a0', 'a1', 5)).toEqual(['a07', 'a0F', 'a0V', 'a0c', 'a0k']);
|
||||||
|
let last = '';
|
||||||
|
for (let i = 0; i < 70; i++) last = keyBetween(last, '');
|
||||||
|
expect(last).toBe('b07');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid ranges and keys', () => {
|
||||||
|
expect(() => keyBetween('zz', '')).toThrow();
|
||||||
|
expect(() => keyBetween('a1', 'a0')).toThrow();
|
||||||
|
expect(validOrderKey('a0V0')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays sorted under random inserts', () => {
|
||||||
|
const keys: string[] = [];
|
||||||
|
let seed = 7;
|
||||||
|
const rnd = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||||
|
for (let i = 0; i < 2000; i++) {
|
||||||
|
const pos = Math.floor(rnd() * (keys.length + 1));
|
||||||
|
keys.splice(pos, 0, keyBetween(keys[pos - 1] || '', keys[pos] || ''));
|
||||||
|
}
|
||||||
|
const sorted = [...keys].sort();
|
||||||
|
expect(keys).toEqual(sorted);
|
||||||
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// Turns the flat block list the editor works with (level + order) into operations against the tree.
|
||||||
|
// Same algorithm as legacyOps in internal/storage/docsync.go.
|
||||||
|
import type { DocumentBlock } from '../types';
|
||||||
|
import { keysBetween } from './orderKey';
|
||||||
|
import { Doc, Op, flatten, normProps, propsEqual } from './model';
|
||||||
|
|
||||||
|
/** Parent index (-1 = top level) for every entry of a flat list; impossible levels are clamped. */
|
||||||
|
export function parentsFromLevels(levels: number[]): number[] {
|
||||||
|
const parents: number[] = [];
|
||||||
|
const stack: number[] = [];
|
||||||
|
levels.forEach((raw, i) => {
|
||||||
|
let lvl = Math.max(0, raw || 0);
|
||||||
|
if (lvl > stack.length) lvl = stack.length;
|
||||||
|
stack.length = lvl;
|
||||||
|
parents.push(lvl > 0 ? stack[lvl - 1] : -1);
|
||||||
|
stack.push(i);
|
||||||
|
});
|
||||||
|
return parents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks a longest strictly increasing subsequence of the non-empty keys. */
|
||||||
|
export function longestIncreasing(keys: string[]): boolean[] {
|
||||||
|
const keep = new Array(keys.length).fill(false);
|
||||||
|
const tails: number[] = [];
|
||||||
|
const prev = new Array(keys.length).fill(-1);
|
||||||
|
keys.forEach((k, i) => {
|
||||||
|
if (!k) return;
|
||||||
|
let lo = 0;
|
||||||
|
let hi = tails.length;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (keys[tails[mid]] < k) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
if (lo > 0) prev[i] = tails[lo - 1];
|
||||||
|
if (lo === tails.length) tails.push(i);
|
||||||
|
else tails[lo] = i;
|
||||||
|
});
|
||||||
|
if (tails.length > 0) {
|
||||||
|
for (let i = tails[tails.length - 1]; i >= 0; i = prev[i]) keep[i] = true;
|
||||||
|
}
|
||||||
|
return keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the operations that turn the tree into the given flat list. newOpId creates unique op IDs.
|
||||||
|
* Blocks that keep their place keep their order key, so only real changes become operations.
|
||||||
|
*/
|
||||||
|
export function diffFlat(d: Doc, blocks: DocumentBlock[], newOpId: () => string): Op[] {
|
||||||
|
const list = blocks.map((b) => ({ ...b, type: b.type || 'bullet' }));
|
||||||
|
const parentIdx = parentsFromLevels(list.map((b) => b.level || 0));
|
||||||
|
const parentOf = list.map((_, i) => (parentIdx[i] >= 0 ? list[parentIdx[i]].id : ''));
|
||||||
|
|
||||||
|
const byParent = new Map<string, number[]>();
|
||||||
|
list.forEach((_, i) => {
|
||||||
|
const p = parentOf[i];
|
||||||
|
const arr = byParent.get(p);
|
||||||
|
if (arr) arr.push(i);
|
||||||
|
else byParent.set(p, [i]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const keys: string[] = new Array(list.length);
|
||||||
|
byParent.forEach((idxs, parent) => {
|
||||||
|
const existing = idxs.map((i) => {
|
||||||
|
const b = d.blocks.get(list[i].id);
|
||||||
|
return b && !b.deleted && b.parentId === parent ? b.orderKey : '';
|
||||||
|
});
|
||||||
|
const keep = longestIncreasing(existing);
|
||||||
|
let j = 0;
|
||||||
|
while (j < idxs.length) {
|
||||||
|
if (keep[j]) {
|
||||||
|
keys[idxs[j]] = existing[j];
|
||||||
|
j++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const start = j;
|
||||||
|
while (j < idxs.length && !keep[j]) j++;
|
||||||
|
const left = start > 0 ? keys[idxs[start - 1]] : '';
|
||||||
|
const right = j < idxs.length ? existing[j] : '';
|
||||||
|
let fresh: string[];
|
||||||
|
try {
|
||||||
|
fresh = keysBetween(left, right, j - start);
|
||||||
|
} catch {
|
||||||
|
fresh = keysBetween(left, '', j - start);
|
||||||
|
}
|
||||||
|
for (let k = start; k < j; k++) keys[idxs[k]] = fresh[k - start];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ops: Op[] = [];
|
||||||
|
const push = (op: Omit<Op, 'opId'>) => ops.push({ opId: newOpId(), ...op } as Op);
|
||||||
|
const wanted = new Set<string>();
|
||||||
|
|
||||||
|
list.forEach((b, i) => {
|
||||||
|
wanted.add(b.id);
|
||||||
|
const parent = parentOf[i];
|
||||||
|
const props = normProps({ checked: b.checked, collapsed: b.collapsed, lang: b.lang });
|
||||||
|
const cur = d.blocks.get(b.id);
|
||||||
|
if (!cur) {
|
||||||
|
push({ kind: 'insert', blockId: b.id, parentId: parent, orderKey: keys[i], type: b.type, content: b.content || '', props });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cur.deleted) push({ kind: 'restore', ids: [b.id] });
|
||||||
|
if (cur.deleted || cur.parentId !== parent || cur.orderKey !== keys[i]) {
|
||||||
|
push({ kind: 'move', blockId: b.id, parentId: parent, orderKey: keys[i] });
|
||||||
|
}
|
||||||
|
if (cur.content !== (b.content || '')) {
|
||||||
|
push({ kind: 'set_content', blockId: b.id, baseVersion: cur.version, content: b.content || '' });
|
||||||
|
}
|
||||||
|
const curProps = normProps({ checked: cur.props.checked, collapsed: cur.props.collapsed, lang: cur.props.lang });
|
||||||
|
if (cur.type !== b.type || !propsEqual(curProps, props)) {
|
||||||
|
push({ kind: 'set_props', blockId: b.id, patch: { type: b.type, checked: !!b.checked, collapsed: !!b.collapsed, lang: b.lang || '' } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deletes last, after moves have taken wanted children out of deleted branches
|
||||||
|
for (const fb of flatten(d)) {
|
||||||
|
if (!wanted.has(fb.id)) push({ kind: 'delete', blockId: fb.id });
|
||||||
|
}
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts the tree into the flat list used by the editor components. */
|
||||||
|
export function toDocumentBlocks(d: Doc, docId: string, taskLinks?: Map<string, string>): DocumentBlock[] {
|
||||||
|
return flatten(d).map((b, i) => ({
|
||||||
|
id: b.id,
|
||||||
|
documentId: docId,
|
||||||
|
type: b.type as DocumentBlock['type'],
|
||||||
|
content: b.content,
|
||||||
|
checked: !!b.props.checked,
|
||||||
|
collapsed: !!b.props.collapsed,
|
||||||
|
lang: b.props.lang || undefined,
|
||||||
|
level: b.level,
|
||||||
|
position: (i + 1) * 1000,
|
||||||
|
taskId: taskLinks?.get(b.id),
|
||||||
|
conflictOf: b.props.conflictOf,
|
||||||
|
conflictAuthor: b.props.conflictAuthor,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
// DocSyncEngine keeps one note in sync with the server: confirmed state from the server log, plus the
|
||||||
|
// user's own unconfirmed operations on top. Every change is stored in IndexedDB before it is sent, sent
|
||||||
|
// with a unique op ID (safe to resend), and the server decides about conflicts. See
|
||||||
|
// Doku/plans/kollaboration-notizen.md.
|
||||||
|
import type { DocumentBlock } from '../types';
|
||||||
|
import { diffFlat, toDocumentBlocks } from './diff';
|
||||||
|
import { applyEffect, applyOp, cloneDoc, Doc, docFromSnapshot, emptyDoc, Op, Result } from './model';
|
||||||
|
import { defaultStorage, SyncStorage } from './persist';
|
||||||
|
import { httpTransport, LeaseInfo, Presence, Transport, TransportError } from './transport';
|
||||||
|
|
||||||
|
export type EngineStatus = 'loading' | 'saved' | 'saving' | 'offline' | 'error';
|
||||||
|
|
||||||
|
export interface EngineState {
|
||||||
|
ready: boolean;
|
||||||
|
status: EngineStatus;
|
||||||
|
errorMessage?: string;
|
||||||
|
role: string; // owner | editor | viewer | '' (unknown yet)
|
||||||
|
title: string;
|
||||||
|
blocks: DocumentBlock[];
|
||||||
|
pendingCount: number;
|
||||||
|
shared: boolean;
|
||||||
|
sessions: number;
|
||||||
|
presence: Presence[];
|
||||||
|
lockedByOthers: LeaseInfo[];
|
||||||
|
conflicts: Result[]; // conflicts of own operations since start
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EngineOptions {
|
||||||
|
transport?: Transport;
|
||||||
|
storage?: SyncStorage;
|
||||||
|
now?: () => number;
|
||||||
|
/** Disables timers and window listeners (tests drive the engine with syncNow()). */
|
||||||
|
manual?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEASE_GRACE_MS = 20_000; // keep a left block for this long
|
||||||
|
const IDLE_RELEASE_MS = 60_000; // release even the focused block after this long without input
|
||||||
|
const SYNC_DEBOUNCE_MS = 300;
|
||||||
|
const POLL_COLLAB_MS = 3_000;
|
||||||
|
const POLL_SOLO_MS = 30_000;
|
||||||
|
const BACKOFF_MAX_MS = 30_000;
|
||||||
|
|
||||||
|
function randomHex(bytes: number): string {
|
||||||
|
const arr = new Uint8Array(bytes);
|
||||||
|
crypto.getRandomValues(arr);
|
||||||
|
return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Random block ID for new blocks (unique across clients). */
|
||||||
|
export function newBlockId(): string {
|
||||||
|
return `blk_${randomHex(10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps only the last text change per block (and the last title change). The base version is the one of
|
||||||
|
* the first change: all later keystrokes build on what the user saw when starting to type, so a change
|
||||||
|
* from someone else in between is detected as a conflict instead of being overwritten.
|
||||||
|
*/
|
||||||
|
export function formBatch(pending: Op[]): Op[] {
|
||||||
|
const lastText = new Map<string, number>();
|
||||||
|
const firstTextBase = new Map<string, number | undefined>();
|
||||||
|
let lastTitle = -1;
|
||||||
|
let firstTitleBase: number | undefined;
|
||||||
|
pending.forEach((op, i) => {
|
||||||
|
if (op.kind === 'set_content') {
|
||||||
|
lastText.set(op.blockId!, i);
|
||||||
|
if (!firstTextBase.has(op.blockId!)) firstTextBase.set(op.blockId!, op.baseVersion);
|
||||||
|
}
|
||||||
|
if (op.kind === 'set_title') {
|
||||||
|
if (lastTitle < 0) firstTitleBase = op.baseVersion;
|
||||||
|
lastTitle = i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const batch: Op[] = [];
|
||||||
|
pending.forEach((op, i) => {
|
||||||
|
if (op.kind === 'set_content') {
|
||||||
|
if (lastText.get(op.blockId!) !== i) return;
|
||||||
|
batch.push({ ...op, baseVersion: firstTextBase.get(op.blockId!) ?? 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (op.kind === 'set_title') {
|
||||||
|
if (i !== lastTitle) return;
|
||||||
|
batch.push({ ...op, baseVersion: firstTitleBase ?? 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
batch.push(op);
|
||||||
|
});
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocSyncEngine {
|
||||||
|
readonly clientId = `c_${randomHex(8)}`;
|
||||||
|
private transport: Transport;
|
||||||
|
private storage: SyncStorage;
|
||||||
|
private now: () => number;
|
||||||
|
private manual: boolean;
|
||||||
|
|
||||||
|
private confirmed: Doc = emptyDoc();
|
||||||
|
private lastSeq = 0;
|
||||||
|
private pending: Op[] = [];
|
||||||
|
private inflight: Op[] | null = null;
|
||||||
|
private local: Doc = emptyDoc();
|
||||||
|
private opCounter = 0;
|
||||||
|
|
||||||
|
private ready = false;
|
||||||
|
private status: EngineStatus = 'loading';
|
||||||
|
private errorMessage?: string;
|
||||||
|
private role = '';
|
||||||
|
private shared = false;
|
||||||
|
private sessions = 0;
|
||||||
|
private presence: Presence[] = [];
|
||||||
|
private lockedByOthers: LeaseInfo[] = [];
|
||||||
|
private conflicts: Result[] = [];
|
||||||
|
private taskLinks = new Map<string, string>();
|
||||||
|
|
||||||
|
private focused: string | null = null;
|
||||||
|
private activity = new Map<string, number>(); // blockId -> last edit/focus time
|
||||||
|
// Version of each block the editor last showed in agreement with the engine. The focused text field does
|
||||||
|
// not take over remote changes while typing, so its edits are based on this version, not the newest one.
|
||||||
|
private uiSeen = new Map<string, number>();
|
||||||
|
|
||||||
|
private syncing = false;
|
||||||
|
private syncAgain = false;
|
||||||
|
private stopped = false;
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private backoff = 0;
|
||||||
|
private listeners = new Set<(s: EngineState) => void>();
|
||||||
|
private cleanup: Array<() => void> = [];
|
||||||
|
|
||||||
|
constructor(readonly docId: string, opts: EngineOptions = {}) {
|
||||||
|
this.transport = opts.transport || httpTransport;
|
||||||
|
this.storage = opts.storage || defaultStorage();
|
||||||
|
this.now = opts.now || (() => Date.now());
|
||||||
|
this.manual = !!opts.manual;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public API ---
|
||||||
|
|
||||||
|
/** Loads the stored state (instant, works offline), then syncs with the server. */
|
||||||
|
async start(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stored = await this.storage.load(this.docId);
|
||||||
|
if (stored) {
|
||||||
|
this.confirmed = docFromSnapshot(stored.title, stored.titleVersion, stored.blocks);
|
||||||
|
this.lastSeq = stored.lastSeq;
|
||||||
|
this.role = stored.role || '';
|
||||||
|
this.pending = stored.pending || [];
|
||||||
|
this.inflight = stored.inflight || null;
|
||||||
|
this.ready = true;
|
||||||
|
this.recompute();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable: continue with the server state only
|
||||||
|
}
|
||||||
|
if (!this.manual) this.attachWindowListeners();
|
||||||
|
if (!this.ready) {
|
||||||
|
try {
|
||||||
|
await this.loadSnapshot();
|
||||||
|
} catch (err) {
|
||||||
|
this.handleError(err);
|
||||||
|
this.emit();
|
||||||
|
this.schedule(this.nextBackoff());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emit();
|
||||||
|
await this.syncNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flushes pending changes (keepalive) and leaves the note. */
|
||||||
|
stop(): void {
|
||||||
|
if (this.stopped) return;
|
||||||
|
this.flushOnExit();
|
||||||
|
this.stopped = true;
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.cleanup.forEach((fn) => fn());
|
||||||
|
this.cleanup = [];
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(fn: (s: EngineState) => void): () => void {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
fn(this.getState());
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): EngineState {
|
||||||
|
return {
|
||||||
|
ready: this.ready,
|
||||||
|
status: this.status,
|
||||||
|
errorMessage: this.errorMessage,
|
||||||
|
role: this.role,
|
||||||
|
title: this.local.title,
|
||||||
|
blocks: toDocumentBlocks(this.local, this.docId, this.taskLinks),
|
||||||
|
pendingCount: this.pending.length + (this.inflight?.length || 0),
|
||||||
|
shared: this.shared,
|
||||||
|
sessions: this.sessions,
|
||||||
|
presence: this.presence,
|
||||||
|
lockedByOthers: this.lockedByOthers,
|
||||||
|
conflicts: this.conflicts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get canEdit(): boolean {
|
||||||
|
return this.role === 'owner' || this.role === 'editor';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Task IDs of blocks linked to tasks (kept outside the synced tree). */
|
||||||
|
setTaskLinks(links: Map<string, string>): void {
|
||||||
|
this.taskLinks = new Map(links);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records the editor's complete block list; differences become operations. */
|
||||||
|
applyFlat(blocks: DocumentBlock[]): void {
|
||||||
|
if (!this.ready || !this.canEdit) return;
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b.taskId) this.taskLinks.set(b.id, b.taskId);
|
||||||
|
}
|
||||||
|
for (const b of blocks) {
|
||||||
|
const cur = this.local.blocks.get(b.id);
|
||||||
|
if (cur && !cur.deleted && cur.content === (b.content || '')) this.uiSeen.set(b.id, cur.version);
|
||||||
|
}
|
||||||
|
const ops = diffFlat(this.local, blocks, () => this.newOpId());
|
||||||
|
if (ops.length === 0) return;
|
||||||
|
const t = this.now();
|
||||||
|
for (const op of ops) {
|
||||||
|
if ((op.kind === 'insert' || op.kind === 'set_content') && op.blockId) this.activity.set(op.blockId, t);
|
||||||
|
if (op.kind === 'set_content' && this.uiSeen.has(op.blockId!)) op.baseVersion = this.uiSeen.get(op.blockId!);
|
||||||
|
}
|
||||||
|
this.enqueue(ops);
|
||||||
|
// The editor now shows exactly the local state for these blocks
|
||||||
|
for (const op of ops) {
|
||||||
|
if (op.kind === 'set_content' || op.kind === 'insert') {
|
||||||
|
const cur = this.local.blocks.get(op.blockId!);
|
||||||
|
if (cur) this.uiSeen.set(op.blockId!, cur.version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle(title: string): void {
|
||||||
|
if (!this.ready || !this.canEdit || title === this.local.title) return;
|
||||||
|
// The title field always shows the engine's title, so the current local version is what the user saw
|
||||||
|
this.enqueue([{ opId: this.newOpId(), kind: 'set_title', title, baseVersion: this.local.titleVersion }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tells the engine which block the cursor is in (null = none); used for block leases. */
|
||||||
|
focusBlock(blockId: string | null): void {
|
||||||
|
const t = this.now();
|
||||||
|
if (this.focused) this.activity.set(this.focused, t);
|
||||||
|
this.focused = blockId;
|
||||||
|
if (blockId) this.activity.set(blockId, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs one sync round trip now (or right after the running one). */
|
||||||
|
async syncNow(): Promise<void> {
|
||||||
|
if (this.stopped) return;
|
||||||
|
if (this.syncing) {
|
||||||
|
this.syncAgain = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
this.syncing = true;
|
||||||
|
let delay = this.pollDelay();
|
||||||
|
try {
|
||||||
|
await this.roundTrip();
|
||||||
|
this.backoff = 0;
|
||||||
|
if (this.syncAgain || this.pending.length > 0) delay = 0;
|
||||||
|
} catch (err) {
|
||||||
|
this.handleError(err);
|
||||||
|
delay = this.status === 'error' ? this.pollDelay() : this.nextBackoff();
|
||||||
|
} finally {
|
||||||
|
this.syncing = false;
|
||||||
|
this.syncAgain = false;
|
||||||
|
this.recompute();
|
||||||
|
this.emit();
|
||||||
|
this.schedule(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internals ---
|
||||||
|
|
||||||
|
private newOpId(): string {
|
||||||
|
this.opCounter++;
|
||||||
|
return `${this.clientId}.${this.opCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueue(ops: Op[]): void {
|
||||||
|
this.pending.push(...ops);
|
||||||
|
this.recompute();
|
||||||
|
if (this.status !== 'offline' && this.status !== 'error') this.status = 'saving';
|
||||||
|
this.emit();
|
||||||
|
// Store first, then send: typed text must survive a crash between the two
|
||||||
|
void this.persist().finally(() => this.schedule(SYNC_DEBOUNCE_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
private recompute(): void {
|
||||||
|
const v = cloneDoc(this.confirmed);
|
||||||
|
for (const op of [...(this.inflight || []), ...this.pending]) {
|
||||||
|
const local = { ...op };
|
||||||
|
if (local.kind === 'set_content') local.baseVersion = v.blocks.get(local.blockId!)?.version ?? 0;
|
||||||
|
if (local.kind === 'set_title') local.baseVersion = v.titleVersion;
|
||||||
|
applyOp(v, local);
|
||||||
|
}
|
||||||
|
this.local = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(): void {
|
||||||
|
// Unfocused blocks take over whatever the engine shows; only the focused text field may lag behind
|
||||||
|
this.local.blocks.forEach((b, id) => {
|
||||||
|
if (id !== this.focused) this.uiSeen.set(id, b.version);
|
||||||
|
});
|
||||||
|
if (this.listeners.size === 0) return;
|
||||||
|
const s = this.getState();
|
||||||
|
this.listeners.forEach((fn) => fn(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persist(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.storage.save({
|
||||||
|
docId: this.docId,
|
||||||
|
title: this.confirmed.title,
|
||||||
|
titleVersion: this.confirmed.titleVersion,
|
||||||
|
lastSeq: this.lastSeq,
|
||||||
|
role: this.role,
|
||||||
|
blocks: Array.from(this.confirmed.blocks.values()),
|
||||||
|
pending: this.pending,
|
||||||
|
inflight: this.inflight,
|
||||||
|
savedAt: this.now(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Storage full or unavailable: changes stay in memory and are still sent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadSnapshot(): Promise<void> {
|
||||||
|
const snap = await this.transport.snapshot(this.docId);
|
||||||
|
this.confirmed = docFromSnapshot(snap.title, snap.titleVersion, snap.blocks || []);
|
||||||
|
this.lastSeq = snap.headSeq;
|
||||||
|
this.role = snap.role;
|
||||||
|
this.shared = snap.shared;
|
||||||
|
this.ready = true;
|
||||||
|
this.recompute();
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
private holds(): string[] {
|
||||||
|
const t = this.now();
|
||||||
|
const out: string[] = [];
|
||||||
|
this.activity.forEach((at, id) => {
|
||||||
|
const fresh = t - at < LEASE_GRACE_MS || (id === this.focused && t - at < IDLE_RELEASE_MS);
|
||||||
|
if (fresh && this.local.blocks.has(id)) out.push(id);
|
||||||
|
else if (!fresh) this.activity.delete(id);
|
||||||
|
});
|
||||||
|
return out.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
private collaborative(): boolean {
|
||||||
|
return this.shared || this.sessions > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async roundTrip(): Promise<void> {
|
||||||
|
if (!this.ready) await this.loadSnapshot();
|
||||||
|
if (!this.inflight && this.pending.length > 0 && this.canEdit) {
|
||||||
|
this.inflight = formBatch(this.pending);
|
||||||
|
this.pending = [];
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
|
const ops = this.canEdit ? this.inflight || [] : [];
|
||||||
|
if (ops.length > 0) this.status = 'saving';
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const res = await this.transport.sync(this.docId, {
|
||||||
|
clientId: this.clientId,
|
||||||
|
lastSeq: this.lastSeq,
|
||||||
|
ops: this.inflight ? ops : [],
|
||||||
|
leases: this.canEdit && this.collaborative() ? { hold: this.holds(), release: [] } : undefined,
|
||||||
|
});
|
||||||
|
if (this.inflight) {
|
||||||
|
const sent = new Map(this.inflight.map((op) => [op.opId, op]));
|
||||||
|
for (const r of res.results) {
|
||||||
|
if (r.status === 'conflict') this.conflicts = [...this.conflicts, r];
|
||||||
|
// Someone else created a block with the same ID first (e.g. the first block of an empty note):
|
||||||
|
// keep the typed text by sending it as a regular text change of that block
|
||||||
|
const op = sent.get(r.opId);
|
||||||
|
if (r.status === 'rejected' && r.reason === 'exists' && op?.kind === 'insert' && op.content) {
|
||||||
|
// Base 0: the user never saw the other client's version of this block
|
||||||
|
this.pending.unshift({ opId: this.newOpId(), kind: 'set_content', blockId: op.blockId, content: op.content, baseVersion: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.inflight = null;
|
||||||
|
}
|
||||||
|
this.role = res.role;
|
||||||
|
this.shared = res.shared;
|
||||||
|
this.sessions = res.sessions;
|
||||||
|
this.presence = res.presence || [];
|
||||||
|
this.lockedByOthers = res.leases?.others || [];
|
||||||
|
|
||||||
|
let gap = !!res.resync;
|
||||||
|
for (const entry of res.ops) {
|
||||||
|
if (entry.seq <= this.lastSeq) continue;
|
||||||
|
if (entry.seq !== this.lastSeq + 1) {
|
||||||
|
gap = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
applyEffect(this.confirmed, entry.effect);
|
||||||
|
this.lastSeq = entry.seq;
|
||||||
|
}
|
||||||
|
if (gap) await this.loadSnapshot();
|
||||||
|
await this.persist();
|
||||||
|
if (!res.hasMore || gap) break;
|
||||||
|
}
|
||||||
|
this.errorMessage = undefined;
|
||||||
|
this.status = this.pending.length > 0 ? 'saving' : 'saved';
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(err: unknown): void {
|
||||||
|
if (err instanceof TransportError && (err.status === 403 || err.status === 404)) {
|
||||||
|
// Access lost: keep the queue (nothing is thrown away), but stop sending
|
||||||
|
this.status = 'error';
|
||||||
|
this.errorMessage = err.message;
|
||||||
|
if (err.status === 404) this.role = '';
|
||||||
|
else if (this.role !== 'owner') this.role = 'viewer';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.status = 'offline';
|
||||||
|
this.errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pollDelay(): number {
|
||||||
|
return this.collaborative() ? POLL_COLLAB_MS : POLL_SOLO_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextBackoff(): number {
|
||||||
|
this.backoff = this.backoff ? Math.min(this.backoff * 2, BACKOFF_MAX_MS) : 1000;
|
||||||
|
return this.backoff;
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(delay: number): void {
|
||||||
|
if (this.manual || this.stopped) return;
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = null;
|
||||||
|
void this.syncNow();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends what is pending with keepalive (survives page unload) and releases leases. */
|
||||||
|
private flushOnExit(): void {
|
||||||
|
if (this.canEdit && (this.inflight || this.pending.length > 0)) {
|
||||||
|
const ops = this.inflight || formBatch(this.pending);
|
||||||
|
this.transport
|
||||||
|
.sync(this.docId, { clientId: this.clientId, lastSeq: this.lastSeq, ops }, { keepalive: true })
|
||||||
|
.catch(() => {
|
||||||
|
// Not delivered: the queue is in IndexedDB and is resent on the next visit (same op IDs)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.transport.leave(this.docId, this.clientId).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachWindowListeners(): void {
|
||||||
|
const onOnline = () => void this.syncNow();
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState === 'hidden') this.flushOnExit();
|
||||||
|
else void this.syncNow();
|
||||||
|
};
|
||||||
|
const onPageHide = () => this.flushOnExit();
|
||||||
|
window.addEventListener('online', onOnline);
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
window.addEventListener('pagehide', onPageHide);
|
||||||
|
this.cleanup.push(
|
||||||
|
() => window.removeEventListener('online', onOnline),
|
||||||
|
() => document.removeEventListener('visibilitychange', onVisibility),
|
||||||
|
() => window.removeEventListener('pagehide', onPageHide)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
// Block tree, operations and effects of collaborative note editing. Port of internal/collab (doc.go, ops.go).
|
||||||
|
// applyEffect must behave exactly like the server's Doc.ApplyEffect, because clients replay the server log.
|
||||||
|
import { keyBetween, validOrderKey } from './orderKey';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
checked?: boolean;
|
||||||
|
collapsed?: boolean;
|
||||||
|
lang?: string;
|
||||||
|
conflictOf?: string;
|
||||||
|
conflictAuthor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Block {
|
||||||
|
id: string;
|
||||||
|
parentId: string; // '' = top level
|
||||||
|
orderKey: string;
|
||||||
|
type: string;
|
||||||
|
content: string;
|
||||||
|
props: Props;
|
||||||
|
version: number;
|
||||||
|
deleted?: boolean;
|
||||||
|
updatedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Doc {
|
||||||
|
title: string;
|
||||||
|
titleVersion: number;
|
||||||
|
blocks: Map<string, Block>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PropsPatch {
|
||||||
|
type?: string;
|
||||||
|
checked?: boolean;
|
||||||
|
collapsed?: boolean;
|
||||||
|
lang?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpKind = 'insert' | 'set_content' | 'set_props' | 'move' | 'delete' | 'restore' | 'set_title';
|
||||||
|
|
||||||
|
export interface Op {
|
||||||
|
opId: string;
|
||||||
|
kind: OpKind;
|
||||||
|
blockId?: string;
|
||||||
|
baseVersion?: number;
|
||||||
|
parentId?: string;
|
||||||
|
orderKey?: string;
|
||||||
|
type?: string;
|
||||||
|
content?: string;
|
||||||
|
props?: Props;
|
||||||
|
patch?: PropsPatch;
|
||||||
|
ids?: string[];
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Effect {
|
||||||
|
kind: OpKind;
|
||||||
|
blockId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
orderKey?: string;
|
||||||
|
type?: string;
|
||||||
|
content?: string;
|
||||||
|
props?: Props;
|
||||||
|
version?: number;
|
||||||
|
ids?: string[];
|
||||||
|
title?: string;
|
||||||
|
titleVersion?: number;
|
||||||
|
by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Result {
|
||||||
|
opId: string;
|
||||||
|
status: 'applied' | 'conflict' | 'rejected';
|
||||||
|
reason?: string;
|
||||||
|
version?: number;
|
||||||
|
conflictBlockId?: string;
|
||||||
|
seq?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlatBlock extends Block {
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyDoc(): Doc {
|
||||||
|
return { title: '', titleVersion: 0, blocks: new Map() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneDoc(d: Doc): Doc {
|
||||||
|
const blocks = new Map<string, Block>();
|
||||||
|
d.blocks.forEach((b, id) => blocks.set(id, { ...b, props: { ...b.props } }));
|
||||||
|
return { title: d.title, titleVersion: d.titleVersion, blocks };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes props so that missing and false/empty values compare equal (the server omits them in JSON). */
|
||||||
|
export function normProps(p?: Props): Props {
|
||||||
|
const out: Props = {};
|
||||||
|
if (p?.checked) out.checked = true;
|
||||||
|
if (p?.collapsed) out.collapsed = true;
|
||||||
|
if (p?.lang) out.lang = p.lang;
|
||||||
|
if (p?.conflictOf) out.conflictOf = p.conflictOf;
|
||||||
|
if (p?.conflictAuthor) out.conflictAuthor = p.conflictAuthor;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function propsEqual(a?: Props, b?: Props): boolean {
|
||||||
|
const x = normProps(a);
|
||||||
|
const y = normProps(b);
|
||||||
|
return (
|
||||||
|
x.checked === y.checked &&
|
||||||
|
x.collapsed === y.collapsed &&
|
||||||
|
x.lang === y.lang &&
|
||||||
|
x.conflictOf === y.conflictOf &&
|
||||||
|
x.conflictAuthor === y.conflictAuthor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function siblingLess(a: Block, b: Block): number {
|
||||||
|
if (a.orderKey !== b.orderKey) return a.orderKey < b.orderKey ? -1 : 1;
|
||||||
|
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live children of parentId in display order. */
|
||||||
|
export function children(d: Doc, parentId: string): Block[] {
|
||||||
|
const out: Block[] = [];
|
||||||
|
d.blocks.forEach((b) => {
|
||||||
|
if (!b.deleted && b.parentId === parentId) out.push(b);
|
||||||
|
});
|
||||||
|
return out.sort(siblingLess);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All live blocks in display order with their nesting level. */
|
||||||
|
export function flatten(d: Doc): FlatBlock[] {
|
||||||
|
const byParent = new Map<string, Block[]>();
|
||||||
|
d.blocks.forEach((b) => {
|
||||||
|
if (b.deleted) return;
|
||||||
|
const list = byParent.get(b.parentId);
|
||||||
|
if (list) list.push(b);
|
||||||
|
else byParent.set(b.parentId, [b]);
|
||||||
|
});
|
||||||
|
byParent.forEach((list) => list.sort(siblingLess));
|
||||||
|
const out: FlatBlock[] = [];
|
||||||
|
const walk = (parent: string, level: number) => {
|
||||||
|
for (const b of byParent.get(parent) || []) {
|
||||||
|
out.push({ ...b, level });
|
||||||
|
walk(b.id, level + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk('', 0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtree(d: Doc, id: string): string[] {
|
||||||
|
const kids = new Map<string, string[]>();
|
||||||
|
d.blocks.forEach((b) => {
|
||||||
|
if (b.deleted) return;
|
||||||
|
const list = kids.get(b.parentId);
|
||||||
|
if (list) list.push(b.id);
|
||||||
|
else kids.set(b.parentId, [b.id]);
|
||||||
|
});
|
||||||
|
const out = [id];
|
||||||
|
for (let i = 0; i < out.length; i++) out.push(...(kids.get(out[i]) || []));
|
||||||
|
return [out[0], ...out.slice(1).sort()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveAncestor(d: Doc, id: string): string {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (id && !seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
const b = d.blocks.get(id);
|
||||||
|
if (!b) return '';
|
||||||
|
if (!b.deleted) return id;
|
||||||
|
id = b.parentId;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAncestor(d: Doc, ancestorId: string, id: string): boolean {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (id && !seen.has(id)) {
|
||||||
|
if (id === ancestorId) return true;
|
||||||
|
seen.add(id);
|
||||||
|
const b = d.blocks.get(id);
|
||||||
|
if (!b) return false;
|
||||||
|
id = b.parentId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutates the document with one effect, exactly like the server. */
|
||||||
|
export function applyEffect(d: Doc, e: Effect): void {
|
||||||
|
switch (e.kind) {
|
||||||
|
case 'insert':
|
||||||
|
d.blocks.set(e.blockId!, {
|
||||||
|
id: e.blockId!,
|
||||||
|
parentId: e.parentId || '',
|
||||||
|
orderKey: e.orderKey || '',
|
||||||
|
type: e.type || '',
|
||||||
|
content: e.content || '',
|
||||||
|
props: normProps(e.props),
|
||||||
|
version: e.version || 0,
|
||||||
|
deleted: false,
|
||||||
|
updatedBy: e.by || '',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'set_content': {
|
||||||
|
const b = d.blocks.get(e.blockId!);
|
||||||
|
if (b) {
|
||||||
|
b.content = e.content || '';
|
||||||
|
b.version = e.version || 0;
|
||||||
|
b.updatedBy = e.by || '';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'set_props': {
|
||||||
|
const b = d.blocks.get(e.blockId!);
|
||||||
|
if (b) {
|
||||||
|
b.type = e.type || '';
|
||||||
|
if (e.props) b.props = normProps(e.props);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'move': {
|
||||||
|
const b = d.blocks.get(e.blockId!);
|
||||||
|
if (b) {
|
||||||
|
b.parentId = e.parentId || '';
|
||||||
|
b.orderKey = e.orderKey || '';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delete':
|
||||||
|
for (const id of e.ids || []) {
|
||||||
|
const b = d.blocks.get(id);
|
||||||
|
if (b) b.deleted = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'restore':
|
||||||
|
for (const id of e.ids || []) {
|
||||||
|
const b = d.blocks.get(id);
|
||||||
|
if (b) b.deleted = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'set_title':
|
||||||
|
d.title = e.title || '';
|
||||||
|
d.titleVersion = e.titleVersion || 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies an operation the way the server does (without leases). The client uses it to show its own
|
||||||
|
* unconfirmed operations on top of the confirmed state; tests use it as a stand-in server.
|
||||||
|
* conflictId derives the ID of a conflict copy (the server uses a hash of the op ID).
|
||||||
|
*/
|
||||||
|
export function applyOp(d: Doc, op: Op, by = '', conflictId: (opId: string) => string = (id) => `blk_cf_${id}`): { effects: Effect[]; result: Result } {
|
||||||
|
const effects: Effect[] = [];
|
||||||
|
const emit = (e: Effect) => {
|
||||||
|
e.by = by;
|
||||||
|
applyEffect(d, e);
|
||||||
|
effects.push(e);
|
||||||
|
};
|
||||||
|
const done = (r: Omit<Result, 'opId'>) => ({ effects, result: { opId: op.opId, ...r } as Result });
|
||||||
|
const reject = (reason: string) => done({ status: 'rejected', reason });
|
||||||
|
|
||||||
|
const restore = (ids: string[]) => {
|
||||||
|
const set = new Set(ids);
|
||||||
|
const sorted = [...ids].sort();
|
||||||
|
emit({ kind: 'restore', ids: sorted });
|
||||||
|
for (const id of sorted) {
|
||||||
|
const b = d.blocks.get(id)!;
|
||||||
|
if (b.parentId === '' || set.has(b.parentId)) continue;
|
||||||
|
const p = d.blocks.get(b.parentId);
|
||||||
|
if (p && !p.deleted) continue;
|
||||||
|
emit({ kind: 'move', blockId: b.id, parentId: liveAncestor(d, b.parentId), orderKey: b.orderKey });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const keyAfter = (b: Block): string => {
|
||||||
|
const sibs = children(d, b.parentId);
|
||||||
|
let next = '';
|
||||||
|
for (const s of sibs) {
|
||||||
|
if (s.orderKey > b.orderKey) {
|
||||||
|
next = s.orderKey;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return keyBetween(b.orderKey, next);
|
||||||
|
} catch {
|
||||||
|
return keyBetween(b.orderKey, '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (op.kind) {
|
||||||
|
case 'insert': {
|
||||||
|
if (!op.blockId || op.blockId.length > 64) return reject('invalid_id');
|
||||||
|
if (d.blocks.has(op.blockId)) return reject('exists');
|
||||||
|
let parent = op.parentId || '';
|
||||||
|
if (parent) {
|
||||||
|
if (!d.blocks.has(parent)) return reject('parent_missing');
|
||||||
|
parent = liveAncestor(d, parent);
|
||||||
|
}
|
||||||
|
if (!op.orderKey || !validOrderKey(op.orderKey)) return reject('invalid_order_key');
|
||||||
|
emit({
|
||||||
|
kind: 'insert',
|
||||||
|
blockId: op.blockId,
|
||||||
|
parentId: parent,
|
||||||
|
orderKey: op.orderKey,
|
||||||
|
type: op.type || 'bullet',
|
||||||
|
content: op.content || '',
|
||||||
|
props: normProps(op.props),
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
return done({ status: 'applied', version: 1 });
|
||||||
|
}
|
||||||
|
case 'set_content': {
|
||||||
|
const b = d.blocks.get(op.blockId || '');
|
||||||
|
if (!b) return reject('missing');
|
||||||
|
if (op.content === undefined) return reject('no_content');
|
||||||
|
if (b.deleted) restore([b.id]);
|
||||||
|
if (op.content === b.content) return done({ status: 'applied', version: b.version });
|
||||||
|
if ((op.baseVersion ?? 0) !== b.version) {
|
||||||
|
const id = conflictId(op.opId);
|
||||||
|
if (!d.blocks.has(id)) {
|
||||||
|
emit({
|
||||||
|
kind: 'insert',
|
||||||
|
blockId: id,
|
||||||
|
parentId: b.parentId,
|
||||||
|
orderKey: keyAfter(b),
|
||||||
|
type: b.type,
|
||||||
|
content: op.content,
|
||||||
|
props: { conflictOf: b.id },
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return done({ status: 'conflict', version: b.version, conflictBlockId: id });
|
||||||
|
}
|
||||||
|
emit({ kind: 'set_content', blockId: b.id, content: op.content, version: b.version + 1 });
|
||||||
|
return done({ status: 'applied', version: b.version });
|
||||||
|
}
|
||||||
|
case 'set_props': {
|
||||||
|
const b = d.blocks.get(op.blockId || '');
|
||||||
|
if (!b || b.deleted) return reject('missing');
|
||||||
|
if (!op.patch) return reject('no_patch');
|
||||||
|
const type = op.patch.type ? op.patch.type : b.type;
|
||||||
|
const props = normProps({
|
||||||
|
...b.props,
|
||||||
|
...(op.patch.checked !== undefined ? { checked: op.patch.checked } : {}),
|
||||||
|
...(op.patch.collapsed !== undefined ? { collapsed: op.patch.collapsed } : {}),
|
||||||
|
...(op.patch.lang !== undefined ? { lang: op.patch.lang } : {}),
|
||||||
|
});
|
||||||
|
if (type !== b.type || !propsEqual(props, b.props)) emit({ kind: 'set_props', blockId: b.id, type, props });
|
||||||
|
return done({ status: 'applied' });
|
||||||
|
}
|
||||||
|
case 'move': {
|
||||||
|
const b = d.blocks.get(op.blockId || '');
|
||||||
|
if (!b || b.deleted) return reject('missing');
|
||||||
|
const parent = op.parentId || '';
|
||||||
|
if (parent) {
|
||||||
|
const p = d.blocks.get(parent);
|
||||||
|
if (!p || p.deleted) return reject('parent_missing');
|
||||||
|
if (isAncestor(d, b.id, parent)) return reject('cycle');
|
||||||
|
}
|
||||||
|
if (!op.orderKey || !validOrderKey(op.orderKey)) return reject('invalid_order_key');
|
||||||
|
if (parent !== b.parentId || op.orderKey !== b.orderKey) emit({ kind: 'move', blockId: b.id, parentId: parent, orderKey: op.orderKey });
|
||||||
|
return done({ status: 'applied' });
|
||||||
|
}
|
||||||
|
case 'delete': {
|
||||||
|
const b = d.blocks.get(op.blockId || '');
|
||||||
|
if (!b) return reject('missing');
|
||||||
|
if (!b.deleted) emit({ kind: 'delete', ids: subtree(d, b.id) });
|
||||||
|
return done({ status: 'applied' });
|
||||||
|
}
|
||||||
|
case 'restore': {
|
||||||
|
const ids = (op.ids || []).filter((id) => d.blocks.get(id)?.deleted);
|
||||||
|
if (ids.length > 0) restore(ids);
|
||||||
|
return done({ status: 'applied' });
|
||||||
|
}
|
||||||
|
case 'set_title': {
|
||||||
|
if (op.title === undefined) return reject('no_title');
|
||||||
|
if (op.title === d.title) return done({ status: 'applied', version: d.titleVersion });
|
||||||
|
if ((op.baseVersion ?? 0) !== d.titleVersion) return done({ status: 'conflict', version: d.titleVersion });
|
||||||
|
emit({ kind: 'set_title', title: op.title, titleVersion: d.titleVersion + 1 });
|
||||||
|
return done({ status: 'applied', version: d.titleVersion });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reject('unknown_kind');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a Doc from snapshot blocks (as delivered by GET /docs/{id}/snapshot). */
|
||||||
|
export function docFromSnapshot(title: string, titleVersion: number, blocks: Block[]): Doc {
|
||||||
|
const d: Doc = { title, titleVersion, blocks: new Map() };
|
||||||
|
for (const b of blocks) {
|
||||||
|
d.blocks.set(b.id, {
|
||||||
|
id: b.id,
|
||||||
|
parentId: b.parentId || '',
|
||||||
|
orderKey: b.orderKey,
|
||||||
|
type: b.type || '',
|
||||||
|
content: b.content || '',
|
||||||
|
props: normProps(b.props),
|
||||||
|
version: b.version || 0,
|
||||||
|
deleted: !!b.deleted,
|
||||||
|
updatedBy: b.updatedBy || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docsEqual(a: Doc, b: Doc): boolean {
|
||||||
|
if (a.title !== b.title || a.titleVersion !== b.titleVersion || a.blocks.size !== b.blocks.size) return false;
|
||||||
|
for (const [id, x] of a.blocks) {
|
||||||
|
const y = b.blocks.get(id);
|
||||||
|
if (
|
||||||
|
!y ||
|
||||||
|
x.parentId !== y.parentId ||
|
||||||
|
x.orderKey !== y.orderKey ||
|
||||||
|
x.type !== y.type ||
|
||||||
|
x.content !== y.content ||
|
||||||
|
x.version !== y.version ||
|
||||||
|
!!x.deleted !== !!y.deleted ||
|
||||||
|
!propsEqual(x.props, y.props)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Order keys sort siblings (ties broken by block ID). Port of internal/collab/orderkey.go; both sides must
|
||||||
|
// produce identical keys, see orderKey.test.ts for shared test vectors.
|
||||||
|
const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||||
|
const SMALLEST_INTEGER = 'A00000000000000000000000000';
|
||||||
|
|
||||||
|
function digitIndex(c: string): number {
|
||||||
|
return DIGITS.indexOf(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
function integerLength(head: string): number {
|
||||||
|
if (head >= 'a' && head <= 'z') return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2;
|
||||||
|
if (head >= 'A' && head <= 'Z') return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function integerPart(key: string): string | null {
|
||||||
|
if (!key) return null;
|
||||||
|
const n = integerLength(key[0]);
|
||||||
|
if (n === 0 || n > key.length) return null;
|
||||||
|
return key.slice(0, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validOrderKey(k: string): boolean {
|
||||||
|
if (!k || k.length > 256 || k === SMALLEST_INTEGER) return false;
|
||||||
|
const ip = integerPart(k);
|
||||||
|
if (ip === null) return false;
|
||||||
|
for (let i = 1; i < k.length; i++) {
|
||||||
|
if (digitIndex(k[i]) < 0) return false;
|
||||||
|
}
|
||||||
|
const frac = k.slice(ip.length);
|
||||||
|
return frac === '' || frac[frac.length - 1] !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementInteger(x: string): string | null {
|
||||||
|
const head = x[0];
|
||||||
|
const digs = x.slice(1).split('');
|
||||||
|
let carry = true;
|
||||||
|
for (let i = digs.length - 1; carry && i >= 0; i--) {
|
||||||
|
const d = digitIndex(digs[i]) + 1;
|
||||||
|
if (d === DIGITS.length) {
|
||||||
|
digs[i] = '0';
|
||||||
|
} else {
|
||||||
|
digs[i] = DIGITS[d];
|
||||||
|
carry = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!carry) return head + digs.join('');
|
||||||
|
if (head === 'Z') return 'a0';
|
||||||
|
if (head === 'z') return null;
|
||||||
|
const h = String.fromCharCode(head.charCodeAt(0) + 1);
|
||||||
|
if (h > 'a') digs.push('0');
|
||||||
|
else digs.pop();
|
||||||
|
return h + digs.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementInteger(x: string): string | null {
|
||||||
|
const head = x[0];
|
||||||
|
const digs = x.slice(1).split('');
|
||||||
|
let borrow = true;
|
||||||
|
for (let i = digs.length - 1; borrow && i >= 0; i--) {
|
||||||
|
const d = digitIndex(digs[i]) - 1;
|
||||||
|
if (d === -1) {
|
||||||
|
digs[i] = 'z';
|
||||||
|
} else {
|
||||||
|
digs[i] = DIGITS[d];
|
||||||
|
borrow = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!borrow) return head + digs.join('');
|
||||||
|
if (head === 'a') return 'Z' + DIGITS[DIGITS.length - 1];
|
||||||
|
if (head === 'A') return null;
|
||||||
|
const h = String.fromCharCode(head.charCodeAt(0) - 1);
|
||||||
|
if (h < 'Z') digs.push('z');
|
||||||
|
else digs.pop();
|
||||||
|
return h + digs.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function midpoint(a: string, b: string): string {
|
||||||
|
if (b !== '') {
|
||||||
|
let n = 0;
|
||||||
|
while (n < b.length) {
|
||||||
|
const ca = n < a.length ? a[n] : '0';
|
||||||
|
if (ca !== b[n]) break;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
if (n > 0) return b.slice(0, n) + midpoint(n < a.length ? a.slice(n) : '', b.slice(n));
|
||||||
|
}
|
||||||
|
const da = a !== '' ? digitIndex(a[0]) : 0;
|
||||||
|
const db = b !== '' ? digitIndex(b[0]) : DIGITS.length;
|
||||||
|
if (db - da > 1) return DIGITS[Math.floor((da + db) / 2)];
|
||||||
|
if (b !== '' && b.length > 1) return b.slice(0, 1);
|
||||||
|
return DIGITS[da] + midpoint(a.length > 1 ? a.slice(1) : '', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a key strictly between a and b; '' means open end. Throws on an invalid range. */
|
||||||
|
export function keyBetween(a: string, b: string): string {
|
||||||
|
if ((a !== '' && !validOrderKey(a)) || (b !== '' && !validOrderKey(b)) || (a !== '' && b !== '' && a >= b)) {
|
||||||
|
throw new Error(`invalid order key range ${a}..${b}`);
|
||||||
|
}
|
||||||
|
if (a === '' && b === '') return 'a0';
|
||||||
|
if (a === '') {
|
||||||
|
const ib = integerPart(b)!;
|
||||||
|
const fb = b.slice(ib.length);
|
||||||
|
if (ib === SMALLEST_INTEGER) return ib + midpoint('', fb);
|
||||||
|
if (ib < b) return ib;
|
||||||
|
const res = decrementInteger(ib);
|
||||||
|
if (res === null) throw new Error('order key range exhausted');
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
if (b === '') {
|
||||||
|
const ia = integerPart(a)!;
|
||||||
|
const i = incrementInteger(ia);
|
||||||
|
return i !== null ? i : ia + midpoint(a.slice(ia.length), '');
|
||||||
|
}
|
||||||
|
const ia = integerPart(a)!;
|
||||||
|
const ib = integerPart(b)!;
|
||||||
|
if (ia === ib) return ia + midpoint(a.slice(ia.length), b.slice(ib.length));
|
||||||
|
const i = incrementInteger(ia);
|
||||||
|
if (i === null) throw new Error('order key range exhausted');
|
||||||
|
return i < b ? i : ia + midpoint(a.slice(ia.length), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns n increasing keys between a and b, spread out so bulk inserts stay short. */
|
||||||
|
export function keysBetween(a: string, b: string, n: number): string[] {
|
||||||
|
if (n <= 0) return [];
|
||||||
|
if (n === 1) return [keyBetween(a, b)];
|
||||||
|
if (b === '') {
|
||||||
|
const out: string[] = [];
|
||||||
|
let prev = a;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
prev = keyBetween(prev, '');
|
||||||
|
out.push(prev);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (a === '') {
|
||||||
|
const out: string[] = new Array(n);
|
||||||
|
let next = b;
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
next = keyBetween('', next);
|
||||||
|
out[i] = next;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const mid = Math.floor(n / 2);
|
||||||
|
const c = keyBetween(a, b);
|
||||||
|
return [...keysBetween(a, c, mid), c, ...keysBetween(c, b, n - mid - 1)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Persists the sync state of each note in IndexedDB so that typed text survives reloads, crashes and
|
||||||
|
// offline phases. Falls back to memory when IndexedDB is unavailable (private mode, old browsers).
|
||||||
|
import type { Block, Op } from './model';
|
||||||
|
|
||||||
|
export interface StoredDocState {
|
||||||
|
docId: string;
|
||||||
|
title: string;
|
||||||
|
titleVersion: number;
|
||||||
|
lastSeq: number;
|
||||||
|
role: string;
|
||||||
|
blocks: Block[];
|
||||||
|
pending: Op[];
|
||||||
|
inflight: Op[] | null;
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncStorage {
|
||||||
|
load(docId: string): Promise<StoredDocState | null>;
|
||||||
|
save(state: StoredDocState): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_NAME = 'mindsift-sync';
|
||||||
|
const STORE = 'docs';
|
||||||
|
|
||||||
|
export class MemoryStorage implements SyncStorage {
|
||||||
|
private data = new Map<string, StoredDocState>();
|
||||||
|
async load(docId: string) {
|
||||||
|
const s = this.data.get(docId);
|
||||||
|
return s ? JSON.parse(JSON.stringify(s)) : null;
|
||||||
|
}
|
||||||
|
async save(state: StoredDocState) {
|
||||||
|
this.data.set(state.docId, JSON.parse(JSON.stringify(state)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IndexedDBStorage implements SyncStorage {
|
||||||
|
private dbPromise: Promise<IDBDatabase> | null = null;
|
||||||
|
|
||||||
|
constructor(private factory: IDBFactory = indexedDB) {}
|
||||||
|
|
||||||
|
private open(): Promise<IDBDatabase> {
|
||||||
|
if (!this.dbPromise) {
|
||||||
|
this.dbPromise = new Promise((resolve, reject) => {
|
||||||
|
const req = this.factory.open(DB_NAME, 1);
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE, { keyPath: 'docId' });
|
||||||
|
};
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
this.dbPromise.catch(() => {
|
||||||
|
this.dbPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(docId: string): Promise<StoredDocState | null> {
|
||||||
|
const db = await this.open();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = db.transaction(STORE, 'readonly').objectStore(STORE).get(docId);
|
||||||
|
req.onsuccess = () => resolve((req.result as StoredDocState) || null);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(state: StoredDocState): Promise<void> {
|
||||||
|
const db = await this.open();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readwrite');
|
||||||
|
tx.objectStore(STORE).put(state);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
tx.onabort = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IndexedDB when available, otherwise memory (state is then lost on reload). */
|
||||||
|
export function defaultStorage(): SyncStorage {
|
||||||
|
try {
|
||||||
|
if (typeof indexedDB !== 'undefined') return new IndexedDBStorage(indexedDB);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return new MemoryStorage();
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// HTTP transport of the note sync protocol (see Doku/api/rest.md). Every data exchange is a plain request,
|
||||||
|
// so it works behind proxies that block WebSockets.
|
||||||
|
import { getToken } from '../api';
|
||||||
|
import type { Block, Effect, Op, Result } from './model';
|
||||||
|
|
||||||
|
export interface LeaseInfo {
|
||||||
|
blockId: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Presence {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapshotResponse {
|
||||||
|
docId: string;
|
||||||
|
title: string;
|
||||||
|
titleVersion: number;
|
||||||
|
headSeq: number;
|
||||||
|
blocks: Block[];
|
||||||
|
role: string;
|
||||||
|
shared: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
seq: number;
|
||||||
|
opId?: string;
|
||||||
|
userId: string;
|
||||||
|
clientId: string;
|
||||||
|
effect: Effect;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncRequest {
|
||||||
|
clientId: string;
|
||||||
|
lastSeq: number;
|
||||||
|
ops: Op[];
|
||||||
|
leases?: { hold: string[]; release: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncResponse {
|
||||||
|
results: Result[];
|
||||||
|
ops: LogEntry[];
|
||||||
|
headSeq: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
resync?: boolean;
|
||||||
|
role: string;
|
||||||
|
shared: boolean;
|
||||||
|
sessions: number;
|
||||||
|
presence: Presence[];
|
||||||
|
leases: { granted: string[]; denied: string[]; others: LeaseInfo[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** status 0 = network error or timeout; otherwise the HTTP status. */
|
||||||
|
export class TransportError extends Error {
|
||||||
|
constructor(message: string, public status: number) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transport {
|
||||||
|
snapshot(docId: string): Promise<SnapshotResponse>;
|
||||||
|
sync(docId: string, body: SyncRequest, opts?: { keepalive?: boolean }): Promise<SyncResponse>;
|
||||||
|
leave(docId: string, clientId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
|
async function call<T>(path: string, init: RequestInit & { keepalive?: boolean } = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
const token = getToken();
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(path, { ...init, headers, cache: 'no-store', signal: init.keepalive ? undefined : controller.signal });
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new TransportError(err?.message || 'Netzwerkfehler', 0);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `HTTP ${res.status}`;
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.error) msg = data.error;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new TransportError(msg, res.status);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const httpTransport: Transport = {
|
||||||
|
snapshot: (docId) => call(`/api/v1/docs/${encodeURIComponent(docId)}/snapshot`),
|
||||||
|
sync: (docId, body, opts) =>
|
||||||
|
call(`/api/v1/docs/${encodeURIComponent(docId)}/sync`, { method: 'POST', body: JSON.stringify(body), keepalive: opts?.keepalive }),
|
||||||
|
leave: async (docId, clientId) => {
|
||||||
|
await call(`/api/v1/docs/${encodeURIComponent(docId)}/leave`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ clientId }),
|
||||||
|
keepalive: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -173,6 +173,8 @@ export interface DocumentBlock {
|
|||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
|
conflictOf?: string; // set on conflict copies: ID of the contested block, or 'title'
|
||||||
|
conflictAuthor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocBlocksPatchRequest {
|
export interface DocBlocksPatchRequest {
|
||||||
|
|||||||
@@ -145,6 +145,9 @@ func (s *Server) handleDocSync(w http.ResponseWriter, r *http.Request) {
|
|||||||
resp.Leases.Granted = append(resp.Leases.Granted, granted...)
|
resp.Leases.Granted = append(resp.Leases.Granted, granted...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if changed {
|
||||||
|
s.afterDocChange(docID, req.Ops, resp.Results)
|
||||||
|
}
|
||||||
|
|
||||||
entries, head, err := s.store.GetDocOpsSince(docID, req.LastSeq, syncMaxLog)
|
entries, head, err := s.store.GetDocOpsSince(docID, req.LastSeq, syncMaxLog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -168,6 +171,25 @@ func (s *Server) handleDocSync(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// afterDocChange runs side effects of applied operations: linked tasks follow their blocks, and a new
|
||||||
|
// title reaches the note lists of everyone with access.
|
||||||
|
func (s *Server) afterDocChange(docID string, ops []collab.Op, results []collab.Result) {
|
||||||
|
titleChanged := false
|
||||||
|
for i, res := range results {
|
||||||
|
if i < len(ops) && ops[i].Kind == collab.OpSetTitle && res.Seq > 0 {
|
||||||
|
titleChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, taskID := range s.store.ReconcileDocTaskLinks(docID) {
|
||||||
|
if th, err := s.store.GetThoughtByIDUnchecked(taskID); err == nil {
|
||||||
|
s.wsHub.BroadcastToUsers(s.taskAudience(th), "TASK_UPDATED", th)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if titleChanged {
|
||||||
|
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// notifyDocChanged wakes long-poll requests and pokes WebSocket/SSE clients in the note's room.
|
// notifyDocChanged wakes long-poll requests and pokes WebSocket/SSE clients in the note's room.
|
||||||
func (s *Server) notifyDocChanged(docID string, head int64) {
|
func (s *Server) notifyDocChanged(docID string, head int64) {
|
||||||
s.notifier.Notify(docID)
|
s.notifier.Notify(docID)
|
||||||
|
|||||||
+103
-2
@@ -355,9 +355,21 @@ func persistDocState(tx *sql.Tx, docID string, before, after *collab.Doc, head i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
blocksJSON, _ := json.Marshal(flat)
|
blocksJSON, _ := json.Marshal(flat)
|
||||||
|
tagSet := map[string]bool{}
|
||||||
|
for _, b := range flat {
|
||||||
|
for _, tag := range domain.ExtractTags(b.Content) {
|
||||||
|
tagSet[tag] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags := make([]string, 0, len(tagSet))
|
||||||
|
for tag := range tagSet {
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
sort.Strings(tags)
|
||||||
|
tagsJSON, _ := json.Marshal(tags)
|
||||||
if _, err := tx.Exec(
|
if _, err := tx.Exec(
|
||||||
`UPDATE documents SET title = ?, title_version = ?, head_seq = ?, markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`,
|
`UPDATE documents SET title = ?, title_version = ?, head_seq = ?, markdown_cache = ?, content = ?, blocks_json = ?, tags = ?, updated_at = ? WHERE id = ?`,
|
||||||
after.Title, after.TitleVersion, head, domain.CompileBlocksToMarkdown(flat), plain.String(), string(blocksJSON), now, docID,
|
after.Title, after.TitleVersion, head, domain.CompileBlocksToMarkdown(flat), plain.String(), string(blocksJSON), string(tagsJSON), now, docID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -377,6 +389,71 @@ func persistDocState(tx *sql.Tx, docID string, before, after *collab.Doc, head i
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// docTaskLinks maps block IDs of a note to the IDs of tasks created from them ("Erweitert").
|
||||||
|
func (s *Store) docTaskLinks(docID string) map[string]string {
|
||||||
|
links := map[string]string{}
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, converted_ref_id FROM thoughts WHERE converted_type = 'document_block' AND converted_ref_id LIKE ?`,
|
||||||
|
docID+":%")
|
||||||
|
if err != nil {
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var taskID, ref string
|
||||||
|
if rows.Scan(&taskID, &ref) == nil {
|
||||||
|
if _, blockID, ok := strings.Cut(ref, ":"); ok {
|
||||||
|
links[blockID] = taskID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReconcileDocTaskLinks brings tasks linked to blocks in line with the note: a ticked block completes its
|
||||||
|
// task, a deleted block unlinks it (the task itself stays). Returns the IDs of changed tasks.
|
||||||
|
func (s *Store) ReconcileDocTaskLinks(docID string) []string {
|
||||||
|
links := s.docTaskLinks(docID)
|
||||||
|
if len(links) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d, _, err := loadCollabDoc(s.db, docID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var changed []string
|
||||||
|
for blockID, taskID := range links {
|
||||||
|
b, ok := d.Blocks[blockID]
|
||||||
|
if !ok || b.Deleted {
|
||||||
|
if _, err := s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID); err == nil {
|
||||||
|
changed = append(changed, taskID)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kanban := "todo"
|
||||||
|
if b.Props.Checked {
|
||||||
|
kanban = "done"
|
||||||
|
}
|
||||||
|
res, err := s.db.Exec(
|
||||||
|
`UPDATE thoughts SET is_completed = ?, kanban_status = ? WHERE id = ? AND is_completed != ?`,
|
||||||
|
boolInt(b.Props.Checked), kanban, taskID, boolInt(b.Props.Checked))
|
||||||
|
if err == nil {
|
||||||
|
if n, _ := res.RowsAffected(); n > 0 {
|
||||||
|
changed = append(changed, taskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(changed)
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications.
|
||||||
|
func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) {
|
||||||
|
return scanThought(s.db.QueryRow(
|
||||||
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
||||||
|
FROM thoughts WHERE id = ?`, thoughtID))
|
||||||
|
}
|
||||||
|
|
||||||
// GetDocOpsSince returns up to limit log entries after seq, plus the current head.
|
// GetDocOpsSince returns up to limit log entries after seq, plus the current head.
|
||||||
func (s *Store) GetDocOpsSince(docID string, since int64, limit int) ([]DocLogEntry, int64, error) {
|
func (s *Store) GetDocOpsSince(docID string, since int64, limit int) ([]DocLogEntry, int64, error) {
|
||||||
var head int64
|
var head int64
|
||||||
@@ -495,12 +572,36 @@ const legacyClientID = "legacy"
|
|||||||
// replaceDocBlocksInDB stores a complete flat block list by diffing it against the current tree and
|
// replaceDocBlocksInDB stores a complete flat block list by diffing it against the current tree and
|
||||||
// applying the difference as regular operations, so it shows up in the log like any other change.
|
// applying the difference as regular operations, so it shows up in the log like any other change.
|
||||||
func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.DocumentBlock, _ string) {
|
func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.DocumentBlock, _ string) {
|
||||||
|
blocks = s.renameForeignBlockIDs(docID, blocks)
|
||||||
a := collab.Actor{UserID: userID, ClientID: legacyClientID}
|
a := collab.Actor{UserID: userID, ClientID: legacyClientID}
|
||||||
if _, _, err := s.applyDocChange(docID, a, nil, func(d *collab.Doc) []collab.Op { return legacyOps(d, blocks) }); err != nil {
|
if _, _, err := s.applyDocChange(docID, a, nil, func(d *collab.Doc) []collab.Op { return legacyOps(d, blocks) }); err != nil {
|
||||||
slog.Error("Speichern der Notiz fehlgeschlagen", "doc_id", docID, "error", err)
|
slog.Error("Speichern der Notiz fehlgeschlagen", "doc_id", docID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renameForeignBlockIDs gives new IDs to blocks whose ID already belongs to another note (e.g. fixed IDs of
|
||||||
|
// starter content or copied blocks). Block IDs are global, so keeping them would make the insert fail.
|
||||||
|
func (s *Store) renameForeignBlockIDs(docID string, blocks []domain.DocumentBlock) []domain.DocumentBlock {
|
||||||
|
out := blocks
|
||||||
|
copied := false
|
||||||
|
for i, b := range blocks {
|
||||||
|
id := strings.TrimSpace(b.ID)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var owner string
|
||||||
|
if s.db.QueryRow(`SELECT document_id FROM blocks WHERE id = ?`, id).Scan(&owner) != nil || owner == docID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !copied {
|
||||||
|
out = append([]domain.DocumentBlock(nil), blocks...)
|
||||||
|
copied = true
|
||||||
|
}
|
||||||
|
out[i].ID = domain.NewID("blk")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// applyLegacyTitle records a title change from the whole-document API in the log.
|
// applyLegacyTitle records a title change from the whole-document API in the log.
|
||||||
func (s *Store) applyLegacyTitle(docID, userID, title string) {
|
func (s *Store) applyLegacyTitle(docID, userID, title string) {
|
||||||
a := collab.Actor{UserID: userID, ClientID: legacyClientID}
|
a := collab.Actor{UserID: userID, ClientID: legacyClientID}
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ func (w *simWorld) edit(c *simClient) {
|
|||||||
switch n := w.r.Intn(100); {
|
switch n := w.r.Intn(100); {
|
||||||
case n < 45 && target != nil: // typing
|
case n < 45 && target != nil: // typing
|
||||||
content := target.Content + fmt.Sprintf(" %s%d", c.actor.ClientID, c.counter)
|
content := target.Content + fmt.Sprintf(" %s%d", c.actor.ClientID, c.counter)
|
||||||
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpSetContent, BlockID: target.ID, Content: &content})
|
// Base = the version the user sees while typing (like the browser engine)
|
||||||
|
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpSetContent, BlockID: target.ID, BaseVersion: target.Version, Content: &content})
|
||||||
c.holds[target.ID] = true
|
c.holds[target.ID] = true
|
||||||
case n < 65: // Enter: new block after target (or first block)
|
case n < 65: // Enter: new block after target (or first block)
|
||||||
parent, after := "", ""
|
parent, after := "", ""
|
||||||
@@ -133,7 +134,7 @@ func (w *simWorld) edit(c *simClient) {
|
|||||||
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpSetProps, BlockID: target.ID, Patch: &collab.PropsPatch{Checked: &checked}})
|
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpSetProps, BlockID: target.ID, Patch: &collab.PropsPatch{Checked: &checked}})
|
||||||
case n < 96: // rename note
|
case n < 96: // rename note
|
||||||
title := fmt.Sprintf("Titel %s%d", c.actor.ClientID, c.counter)
|
title := fmt.Sprintf("Titel %s%d", c.actor.ClientID, c.counter)
|
||||||
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpSetTitle, Title: &title})
|
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpSetTitle, BaseVersion: v.TitleVersion, Title: &title})
|
||||||
default: // stop editing some block
|
default: // stop editing some block
|
||||||
for id := range c.holds {
|
for id := range c.holds {
|
||||||
delete(c.holds, id)
|
delete(c.holds, id)
|
||||||
@@ -142,15 +143,23 @@ func (w *simWorld) edit(c *simClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// formBatch takes pending operations, keeps only the last text change per block and fills in base versions.
|
// formBatch takes pending operations and keeps only the last text change per block; its base version is
|
||||||
|
// the one of the first change, i.e. what the user saw when starting to type.
|
||||||
func (c *simClient) formBatch() []collab.Op {
|
func (c *simClient) formBatch() []collab.Op {
|
||||||
lastText := map[string]int{}
|
lastText := map[string]int{}
|
||||||
lastTitle := -1
|
firstBase := map[string]int{}
|
||||||
|
lastTitle, firstTitleBase := -1, 0
|
||||||
for i, op := range c.pending {
|
for i, op := range c.pending {
|
||||||
switch op.Kind {
|
switch op.Kind {
|
||||||
case collab.OpSetContent:
|
case collab.OpSetContent:
|
||||||
|
if _, ok := lastText[op.BlockID]; !ok {
|
||||||
|
firstBase[op.BlockID] = op.BaseVersion
|
||||||
|
}
|
||||||
lastText[op.BlockID] = i
|
lastText[op.BlockID] = i
|
||||||
case collab.OpSetTitle:
|
case collab.OpSetTitle:
|
||||||
|
if lastTitle < 0 {
|
||||||
|
firstTitleBase = op.BaseVersion
|
||||||
|
}
|
||||||
lastTitle = i
|
lastTitle = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,15 +170,12 @@ func (c *simClient) formBatch() []collab.Op {
|
|||||||
if lastText[op.BlockID] != i {
|
if lastText[op.BlockID] != i {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
op.BaseVersion = 1
|
op.BaseVersion = firstBase[op.BlockID]
|
||||||
if b, ok := c.confirmed.Blocks[op.BlockID]; ok {
|
|
||||||
op.BaseVersion = b.Version
|
|
||||||
}
|
|
||||||
case collab.OpSetTitle:
|
case collab.OpSetTitle:
|
||||||
if i != lastTitle {
|
if i != lastTitle {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
op.BaseVersion = c.confirmed.TitleVersion
|
op.BaseVersion = firstTitleBase
|
||||||
}
|
}
|
||||||
batch = append(batch, op)
|
batch = append(batch, op)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,3 +184,57 @@ func TestBackfillConvertsFlatBlocksToTree(t *testing.T) {
|
|||||||
want := flatBlocks([3]interface{}{"x", 0, "x"}, [3]interface{}{"y", 1, "y"}, [3]interface{}{"z", 1, "z"}, [3]interface{}{"w", 0, "w"})
|
want := flatBlocks([3]interface{}{"x", 0, "x"}, [3]interface{}{"y", 1, "y"}, [3]interface{}{"z", 1, "z"}, [3]interface{}{"w", 0, "w"})
|
||||||
sameOutline(t, got.Blocks, want)
|
sameOutline(t, got.Blocks, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskLinksFollowTheirBlock(t *testing.T) {
|
||||||
|
s, u := newSyncTestStore(t)
|
||||||
|
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "📄", "", []domain.DocumentBlock{{ID: "todo1", Type: "todo", Content: "Anrufen"}}, nil, false)
|
||||||
|
task, err := s.CreateTaskFromDocBlock(u.ID, doc.ID, "todo1", "Anrufen", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := s.GetDocumentByID(u.ID, doc.ID)
|
||||||
|
if got.Blocks[0].TaskID != task.ID {
|
||||||
|
t.Fatalf("block must carry its task ID after loading, got %q", got.Blocks[0].TaskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
a := collab.Actor{UserID: u.ID, ClientID: "tab"}
|
||||||
|
yes := true
|
||||||
|
if _, _, err := s.ApplyDocOps(doc.ID, a, []collab.Op{{OpID: "chk", Kind: collab.OpSetProps, BlockID: "todo1", Patch: &collab.PropsPatch{Checked: &yes}}}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if changed := s.ReconcileDocTaskLinks(doc.ID); len(changed) != 1 {
|
||||||
|
t.Fatalf("ticking the block must complete the task, changed=%v", changed)
|
||||||
|
}
|
||||||
|
if th, _ := s.GetThoughtByID(u.ID, task.ID); !th.IsCompleted {
|
||||||
|
t.Fatal("task not completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := s.ApplyDocOps(doc.ID, a, []collab.Op{{OpID: "del", Kind: collab.OpDelete, BlockID: "todo1"}}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.ReconcileDocTaskLinks(doc.ID)
|
||||||
|
th, err := s.GetThoughtByID(u.ID, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("deleting the block must not delete the task")
|
||||||
|
}
|
||||||
|
if th.ConvertedType == "document_block" {
|
||||||
|
t.Fatal("deleting the block must unlink the task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFixedBlockIDsWorkInSeveralNotes(t *testing.T) {
|
||||||
|
s, u := newSyncTestStore(t)
|
||||||
|
other, _ := s.CreateUser("second@example.com", "hash", "Second", "member")
|
||||||
|
blocks := flatBlocks([3]interface{}{"blk_1", 0, "Willkommen"}, [3]interface{}{"blk_2", 1, "Unterpunkt"})
|
||||||
|
first, _ := s.CreateDocument(u.ID, "", "Start", "📄", "", blocks, nil, false)
|
||||||
|
second, _ := s.CreateDocument(other.ID, "", "Start", "📄", "", blocks, nil, false)
|
||||||
|
|
||||||
|
a, _ := s.GetDocumentByID(u.ID, first.ID)
|
||||||
|
b, _ := s.GetDocumentByID(other.ID, second.ID)
|
||||||
|
if len(a.Blocks) != 2 || len(b.Blocks) != 2 || b.Blocks[0].Content != "Willkommen" || b.Blocks[1].Level != 1 {
|
||||||
|
t.Fatalf("second note must get its own copies of the blocks: %+v", b.Blocks)
|
||||||
|
}
|
||||||
|
if a.Blocks[0].ID == b.Blocks[0].ID {
|
||||||
|
t.Fatal("block IDs must differ between notes")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1448,7 +1448,13 @@ func (s *Store) migrateWorkspaceToPureDB() {
|
|||||||
|
|
||||||
func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock {
|
func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock {
|
||||||
if d, _, err := loadCollabDoc(s.db, docID); err == nil && len(d.Blocks) > 0 {
|
if d, _, err := loadCollabDoc(s.db, docID); err == nil && len(d.Blocks) > 0 {
|
||||||
return flatDocumentBlocks(docID, d)
|
blocks := flatDocumentBlocks(docID, d)
|
||||||
|
if links := s.docTaskLinks(docID); len(links) > 0 {
|
||||||
|
for i := range blocks {
|
||||||
|
blocks[i].TaskID = links[blocks[i].ID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
// Very old notes only stored blocks_json; convert them once
|
// Very old notes only stored blocks_json; convert them once
|
||||||
@@ -2228,18 +2234,18 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
|||||||
for _, b := range doc.Blocks {
|
for _, b := range doc.Blocks {
|
||||||
remainingBlockIDs[b.ID] = true
|
remainingBlockIDs[b.ID] = true
|
||||||
}
|
}
|
||||||
rows, err := s.db.Query(`SELECT id, converted_ref_id, block_id FROM thoughts WHERE (doc_id = ? OR converted_ref_id LIKE ?) AND converted_type = 'document_block'`, docID, docID+":%")
|
rows, err := s.db.Query(`SELECT id, converted_ref_id FROM thoughts WHERE converted_ref_id LIKE ? AND converted_type = 'document_block'`, docID+":%")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var tid, refID, bID string
|
var tid, refID string
|
||||||
if err := rows.Scan(&tid, &refID, &bID); err == nil {
|
if err := rows.Scan(&tid, &refID); err == nil {
|
||||||
bid := bID
|
bid := ""
|
||||||
if bid == "" && strings.Contains(refID, ":") {
|
if strings.Contains(refID, ":") {
|
||||||
parts := strings.SplitN(refID, ":", 2)
|
parts := strings.SplitN(refID, ":", 2)
|
||||||
bid = parts[1]
|
bid = parts[1]
|
||||||
}
|
}
|
||||||
if !remainingBlockIDs[bid] {
|
if !remainingBlockIDs[bid] {
|
||||||
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '', doc_id = '', block_id = '' WHERE id = ?`, tid)
|
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, tid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4111,7 +4117,7 @@ func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '', doc_id = '', block_id = '' WHERE id = ?`, taskID)
|
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
|
func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
|
||||||
|
|||||||
Reference in new issue
Block a user