diff --git a/.dockerignore b/.dockerignore index fab7364..d6be359 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,4 @@ frontend/dist *.db-shm *.db-wal .env +e2e diff --git a/.gitignore b/.gitignore index 1fc8b72..de8430c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ dev-data/ .DS_Store .idea/ .vscode/ +e2e/node_modules/ diff --git a/Doku/architecture.md b/Doku/architecture.md index 4f5a235..b6a16c6 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -39,6 +39,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `internal/config/` | Umgebungsvariablen | | `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/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 | ## Start und Laufzeit diff --git a/Doku/flaws.md b/Doku/flaws.md index f826cef..518f8fc 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -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-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig | | 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 - **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. +- **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. ### 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`). - **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 diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index b384211..4dcc6a1 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -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`). - **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. -- **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 diff --git a/Doku/plans/kollaboration-notizen.md b/Doku/plans/kollaboration-notizen.md index 4418fa0..b7e9c1b 100644 --- a/Doku/plans/kollaboration-notizen.md +++ b/Doku/plans/kollaboration-notizen.md @@ -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 | | 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 | -| 3 | Leases und Präsenz in UI, Konfliktblöcke mit Übernehmen/Verwerfen, Rückgängig/Wiederholen | Gemeinsames Bearbeiten ohne Kollisionen, Strg+Z | +| 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. 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 | | 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 | diff --git a/README.md b/README.md index 026fd25..7a22a50 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ npm run dev # Vite-Entwicklungsserver npx tsc --noEmit # Typprüfung npm run test:run # Komponententests (Vitest, jsdom) 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. diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..74f8156 --- /dev/null +++ b/e2e/README.md @@ -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. diff --git a/e2e/collab.e2e.mjs b/e2e/collab.e2e.mjs new file mode 100644 index 0000000..10d6446 --- /dev/null +++ b/e2e/collab.e2e.mjs @@ -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); +}); diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..d4261ab --- /dev/null +++ b/e2e/package-lock.json @@ -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" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..4aabf5b --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,8 @@ +{ + "name": "mindsift-e2e", + "private": true, + "type": "module", + "dependencies": { + "playwright": "1.48.2" + } +} diff --git a/e2e/run.sh b/e2e/run.sh new file mode 100755 index 0000000..2ac77e9 --- /dev/null +++ b/e2e/run.sh @@ -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' diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 695fc5d..5fc62ea 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "fake-indexeddb": "^6.2.6", "jsdom": "^25.0.1", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", @@ -2238,6 +2239,16 @@ "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": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 09c81cf..a502f5d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "fake-indexeddb": "^6.2.6", "jsdom": "^25.0.1", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 8b8c1b7..c533eff 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -16,7 +16,8 @@ import { Search, } from 'lucide-react'; 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'; 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 { id: string | null; title: string; @@ -224,6 +248,10 @@ export const DocumentEditor: React.FC = ({ const [emojiSearch, setEmojiSearch] = useState(''); const [zoomedBlockId, setZoomedBlockId] = useState(null); + // Sync engine (operation log): stores every change locally and syncs it with the server + const engineRef = useRef(null); + const [engineState, setEngineState] = useState(null); + const filteredEmojis = useMemo(() => { const q = emojiSearch.trim().toLowerCase(); if (!q) return EMOJI_CATALOG; @@ -232,13 +260,6 @@ export const DocumentEditor: React.FC = ({ ); }, [emojiSearch]); - // AutoSave state (REST persistence) - const [, setSaveStatus] = useState('saved'); - const [, setSaveErrorMessage] = useState(null); - const saveTimeoutRef = useRef(null); - const isSavingRef = useRef(false); - const lastSavedSnapshotRef = useRef(''); - const isInitialLoadRef = useRef(true); const titleInputRef = useRef(null); const outlinerTreeRef = useRef(null); @@ -249,7 +270,6 @@ export const DocumentEditor: React.FC = ({ currentTitleRef.current = title; const currentBlocksRef = useRef(blocks); currentBlocksRef.current = blocks; - const lastKnownUpdatedAtRef = useRef(initialDoc?.updatedAt || null); useEffect(() => { if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) { @@ -283,11 +303,12 @@ export const DocumentEditor: React.FC = ({ return computeBreadcrumbs(title || doc?.title || 'Dokument', 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(() => { - if (!doc) return true; - return Boolean(doc.isOwner || doc.permission === 'owner'); - }, [doc]); + if (engineState?.role) return engineState.role === 'owner' || engineState.role === 'editor'; + if (!doc) return false; + return Boolean(doc.isOwner || doc.permission === 'owner' || doc.permission === 'write'); + }, [doc, engineState?.role]); // Extract all #tags present across the current blocks const extractedTags = useMemo(() => { @@ -303,124 +324,6 @@ export const DocumentEditor: React.FC = ({ return Array.from(tagSet); }, [blocks]); - const currentTagsRef = useRef(extractedTags); - currentTagsRef.current = extractedTags; - - // AutoSave snapshot - const currentSnapshot = useMemo(() => { - return JSON.stringify({ - title, - blocks, - tags: extractedTags, - }); - }, [title, blocks, extractedTags]); - - const currentSnapshotRef = useRef(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 useEffect(() => { @@ -436,29 +339,19 @@ export const DocumentEditor: React.FC = ({ if (isCancelled) return; const normalized = ensureNonEmptyBlocks(docId, fetched.blocks); setDoc(fetched); - setTitle(fetched.title || 'Unbenannte Notiz'); - setBlocks(normalized); + if (!engineRef.current?.getState().ready) { + setTitle(fetched.title || 'Unbenannte Notiz'); + currentBlocksRef.current = normalized; + setBlocks(normalized); + } + const links = new Map(); + for (const b of fetched.blocks || []) { + if (b.taskId) links.set(b.id, b.taskId); + } + engineRef.current?.setTaskLinks(links); setLoading(false); 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) => { if (isCancelled) return; @@ -471,6 +364,60 @@ export const DocumentEditor: React.FC = ({ }; }, [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 useEffect(() => { if (!docRef.current) return; @@ -488,27 +435,19 @@ export const DocumentEditor: React.FC = ({ const handleTitleChange = (e: React.ChangeEvent) => { if (!canEdit) return; setTitle(e.target.value); + engineRef.current?.setTitle(e.target.value); }; const handleTitleBlur = () => { - executeAutoSave(); + void engineRef.current?.syncNow(); }; const handleUpdateBlock = useCallback( - (updatedBlock: DocumentBlock, immediate: boolean = false) => { + (updatedBlock: DocumentBlock) => { if (!canEdit) return; - setBlocks((prev) => { - const next = prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b)); - currentBlocksRef.current = next; - return next; - }); - if (immediate) { - window.setTimeout(() => { - executeAutoSave(true); - }, 10); - } + applyLocalBlocks((prev) => prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))); }, - [canEdit, executeAutoSave] + [canEdit, applyLocalBlocks] ); const handleUpdateBlocks = useCallback( @@ -518,17 +457,15 @@ export const DocumentEditor: React.FC = ({ for (const b of updatedBlocks) { map.set(b.id, b); } - setBlocks((prev) => - prev.map((b) => map.get(b.id) || b) - ); + applyLocalBlocks((prev) => prev.map((b) => map.get(b.id) || b)); }, - [canEdit] + [canEdit, applyLocalBlocks] ); const handleCreateBlock = useCallback( (newBlock: DocumentBlock, afterBlockId?: string) => { if (!canEdit) return; - setBlocks((prev) => { + applyLocalBlocks((prev) => { const next = [...prev]; if (afterBlockId) { const idx = next.findIndex((b) => b.id === afterBlockId); @@ -541,13 +478,13 @@ export const DocumentEditor: React.FC = ({ return ensureNonEmptyBlocks(docId, next); }); }, - [docId, canEdit] + [docId, canEdit, applyLocalBlocks] ); const handleInsertBlocks = useCallback( (afterBlockId: string, updatedCurrentBlock: DocumentBlock, newBlocks: DocumentBlock[]) => { if (!canEdit) return; - setBlocks((prev) => { + applyLocalBlocks((prev) => { const next = [...prev]; const idx = next.findIndex((b) => b.id === afterBlockId); if (idx >= 0) { @@ -556,32 +493,24 @@ export const DocumentEditor: React.FC = ({ } else { next.push(updatedCurrentBlock, ...newBlocks); } - const normalized = ensureNonEmptyBlocks(docId, next); - currentBlocksRef.current = normalized; - return normalized; + return ensureNonEmptyBlocks(docId, next); }); - window.setTimeout(() => { - executeAutoSave(true); - }, 50); }, - [docId, canEdit, executeAutoSave] + [docId, canEdit, applyLocalBlocks] ); const handleDeleteBlock = useCallback( (blockId: string) => { if (!canEdit) return; - setBlocks((prev) => { - const filtered = prev.filter((b) => b.id !== blockId); - return ensureNonEmptyBlocks(docId, filtered); - }); + applyLocalBlocks((prev) => ensureNonEmptyBlocks(docId, prev.filter((b) => b.id !== blockId))); }, - [docId, canEdit] + [docId, canEdit, applyLocalBlocks] ); const handleMoveBlock = useCallback( (blockId: string, targetIndex: number, newLevel?: number) => { if (!canEdit) return; - setBlocks((prev) => { + applyLocalBlocks((prev) => { const fromIdx = prev.findIndex((b) => b.id === blockId); if (fromIdx === -1) return prev; @@ -632,7 +561,7 @@ export const DocumentEditor: React.FC = ({ return ensureNonEmptyBlocks(docId, copy); }); }, - [docId, canEdit] + [docId, canEdit, applyLocalBlocks] ); const handleTogglePin = async () => { @@ -977,6 +906,7 @@ export const DocumentEditor: React.FC = ({ onTagClick={onSelectTag} onWikiLinkClick={onWikiLinkClick} initialFocusedBlockId={initialFocusedBlockId} + onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)} /> )} @@ -1113,6 +1043,7 @@ export const DocumentEditor: React.FC = ({ onTagClick={onSelectTag} onWikiLinkClick={onWikiLinkClick} initialFocusedBlockId={initialFocusedBlockId} + onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)} /> )} diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index 6254449..26bee05 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -23,6 +23,7 @@ export interface OutlinerTreeProps { onTagClick?: (tag: string) => void; onWikiLinkClick?: (pageTitle: string) => void; initialFocusedBlockId?: string | null; + onFocusedBlockChange?: (blockId: string | null) => void; } export const OutlinerTree = forwardRef(({ @@ -41,8 +42,15 @@ export const OutlinerTree = forwardRef(({ onTagClick, onWikiLinkClick, initialFocusedBlockId, + onFocusedBlockChange, }, ref) => { const [focusedBlockId, setFocusedBlockId] = useState(null); + + const onFocusedBlockChangeRef = useRef(onFocusedBlockChange); + onFocusedBlockChangeRef.current = onFocusedBlockChange; + useEffect(() => { + onFocusedBlockChangeRef.current?.(focusedBlockId); + }, [focusedBlockId]); const [highlightedBlockId, setHighlightedBlockId] = useState( initialFocusedBlockId || null ); diff --git a/frontend/src/lib/docSync/__tests__/diff.test.ts b/frontend/src/lib/docSync/__tests__/diff.test.ts new file mode 100644 index 0000000..54713aa --- /dev/null +++ b/frontend/src/lib/docSync/__tests__/diff.test.ts @@ -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 => ({ + 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; + } + }); +}); diff --git a/frontend/src/lib/docSync/__tests__/engine.test.ts b/frontend/src/lib/docSync/__tests__/engine.test.ts new file mode 100644 index 0000000..0af7190 --- /dev/null +++ b/frontend/src/lib/docSync/__tests__/engine.test.ts @@ -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(); + roles = new Map(); // clientId -> role + applyCount = new Map(); + + 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(); + }); +}); + diff --git a/frontend/src/lib/docSync/__tests__/orderKey.test.ts b/frontend/src/lib/docSync/__tests__/orderKey.test.ts new file mode 100644 index 0000000..c4afe60 --- /dev/null +++ b/frontend/src/lib/docSync/__tests__/orderKey.test.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/docSync/diff.ts b/frontend/src/lib/docSync/diff.ts new file mode 100644 index 0000000..bc5fbe0 --- /dev/null +++ b/frontend/src/lib/docSync/diff.ts @@ -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(); + 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) => ops.push({ opId: newOpId(), ...op } as Op); + const wanted = new Set(); + + 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): 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, + })); +} diff --git a/frontend/src/lib/docSync/engine.ts b/frontend/src/lib/docSync/engine.ts new file mode 100644 index 0000000..ff977b4 --- /dev/null +++ b/frontend/src/lib/docSync/engine.ts @@ -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(); + const firstTextBase = new Map(); + 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(); + + private focused: string | null = null; + private activity = new Map(); // 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(); + + private syncing = false; + private syncAgain = false; + private stopped = false; + private timer: ReturnType | 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 { + 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): 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 { + 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 { + 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 { + 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 { + 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) + ); + } +} diff --git a/frontend/src/lib/docSync/model.ts b/frontend/src/lib/docSync/model.ts new file mode 100644 index 0000000..c7bf319 --- /dev/null +++ b/frontend/src/lib/docSync/model.ts @@ -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; +} + +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(); + 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(); + 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(); + 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(); + 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(); + 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) => ({ 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; +} diff --git a/frontend/src/lib/docSync/orderKey.ts b/frontend/src/lib/docSync/orderKey.ts new file mode 100644 index 0000000..674aaa8 --- /dev/null +++ b/frontend/src/lib/docSync/orderKey.ts @@ -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)]; +} diff --git a/frontend/src/lib/docSync/persist.ts b/frontend/src/lib/docSync/persist.ts new file mode 100644 index 0000000..bfc90cb --- /dev/null +++ b/frontend/src/lib/docSync/persist.ts @@ -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; + save(state: StoredDocState): Promise; +} + +const DB_NAME = 'mindsift-sync'; +const STORE = 'docs'; + +export class MemoryStorage implements SyncStorage { + private data = new Map(); + 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 | null = null; + + constructor(private factory: IDBFactory = indexedDB) {} + + private open(): Promise { + 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 { + 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 { + 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(); +} diff --git a/frontend/src/lib/docSync/transport.ts b/frontend/src/lib/docSync/transport.ts new file mode 100644 index 0000000..e6de919 --- /dev/null +++ b/frontend/src/lib/docSync/transport.ts @@ -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; + sync(docId: string, body: SyncRequest, opts?: { keepalive?: boolean }): Promise; + leave(docId: string, clientId: string): Promise; +} + +const TIMEOUT_MS = 15000; + +async function call(path: string, init: RequestInit & { keepalive?: boolean } = {}): Promise { + const headers: Record = { '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, + }); + }, +}; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index c07c235..8e29b00 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -173,6 +173,8 @@ export interface DocumentBlock { collapsed?: boolean; sortOrder?: number; updatedAt?: string; + conflictOf?: string; // set on conflict copies: ID of the contested block, or 'title' + conflictAuthor?: string; } export interface DocBlocksPatchRequest { diff --git a/internal/api/docsync.go b/internal/api/docsync.go index 91b4fca..fea628f 100644 --- a/internal/api/docsync.go +++ b/internal/api/docsync.go @@ -145,6 +145,9 @@ func (s *Server) handleDocSync(w http.ResponseWriter, r *http.Request) { 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) if err != nil { @@ -168,6 +171,25 @@ func (s *Server) handleDocSync(w http.ResponseWriter, r *http.Request) { 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. func (s *Server) notifyDocChanged(docID string, head int64) { s.notifier.Notify(docID) diff --git a/internal/storage/docsync.go b/internal/storage/docsync.go index af01f8a..31dd7fd 100644 --- a/internal/storage/docsync.go +++ b/internal/storage/docsync.go @@ -355,9 +355,21 @@ func persistDocState(tx *sql.Tx, docID string, before, after *collab.Doc, head i } } 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( - `UPDATE documents SET title = ?, title_version = ?, head_seq = ?, markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`, - after.Title, after.TitleVersion, head, domain.CompileBlocksToMarkdown(flat), plain.String(), string(blocksJSON), now, docID, + `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), string(tagsJSON), now, docID, ); err != nil { return err } @@ -377,6 +389,71 @@ func persistDocState(tx *sql.Tx, docID string, before, after *collab.Doc, head i 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. func (s *Store) GetDocOpsSince(docID string, since int64, limit int) ([]DocLogEntry, int64, error) { 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 // 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) { + blocks = s.renameForeignBlockIDs(docID, blocks) 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 { 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. func (s *Store) applyLegacyTitle(docID, userID, title string) { a := collab.Actor{UserID: userID, ClientID: legacyClientID} diff --git a/internal/storage/docsync_sim_test.go b/internal/storage/docsync_sim_test.go index e4dc7e5..ae3f6f3 100644 --- a/internal/storage/docsync_sim_test.go +++ b/internal/storage/docsync_sim_test.go @@ -77,7 +77,8 @@ func (w *simWorld) edit(c *simClient) { switch n := w.r.Intn(100); { case n < 45 && target != nil: // typing 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 case n < 65: // Enter: new block after target (or first block) 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}}) case n < 96: // rename note 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 for id := range c.holds { 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 { lastText := map[string]int{} - lastTitle := -1 + firstBase := map[string]int{} + lastTitle, firstTitleBase := -1, 0 for i, op := range c.pending { switch op.Kind { case collab.OpSetContent: + if _, ok := lastText[op.BlockID]; !ok { + firstBase[op.BlockID] = op.BaseVersion + } lastText[op.BlockID] = i case collab.OpSetTitle: + if lastTitle < 0 { + firstTitleBase = op.BaseVersion + } lastTitle = i } } @@ -161,15 +170,12 @@ func (c *simClient) formBatch() []collab.Op { if lastText[op.BlockID] != i { continue } - op.BaseVersion = 1 - if b, ok := c.confirmed.Blocks[op.BlockID]; ok { - op.BaseVersion = b.Version - } + op.BaseVersion = firstBase[op.BlockID] case collab.OpSetTitle: if i != lastTitle { continue } - op.BaseVersion = c.confirmed.TitleVersion + op.BaseVersion = firstTitleBase } batch = append(batch, op) } diff --git a/internal/storage/docsync_test.go b/internal/storage/docsync_test.go index ad438a6..e5318e5 100644 --- a/internal/storage/docsync_test.go +++ b/internal/storage/docsync_test.go @@ -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"}) 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") + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 6bab8a5..fbcc110 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -1448,7 +1448,13 @@ func (s *Store) migrateWorkspaceToPureDB() { func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock { 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 @@ -2228,18 +2234,18 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st for _, b := range doc.Blocks { 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 { for rows.Next() { - var tid, refID, bID string - if err := rows.Scan(&tid, &refID, &bID); err == nil { - bid := bID - if bid == "" && strings.Contains(refID, ":") { + var tid, refID string + if err := rows.Scan(&tid, &refID); err == nil { + bid := "" + if strings.Contains(refID, ":") { parts := strings.SplitN(refID, ":", 2) bid = parts[1] } 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) {