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/.gitea/workflows/build-and-publish.yaml b/.gitea/workflows/build-and-publish.yaml index 28ead19..600a9a4 100644 --- a/.gitea/workflows/build-and-publish.yaml +++ b/.gitea/workflows/build-and-publish.yaml @@ -37,6 +37,12 @@ jobs: echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" echo "tag=${DOCKER_TAG}" >> "$GITHUB_OUTPUT" echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT" + # Releases (tags) show the tag as version in the app header + APP_VERSION="" + case "${GITHUB_REF:-}" in + refs/tags/*) APP_VERSION="${REF_NAME}" ;; + esac + echo "app_version=${APP_VERSION}" >> "$GITHUB_OUTPUT" echo "Target Docker image: ${IMAGE_NAME}:${DOCKER_TAG} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})" - name: Login to Gitea Container Registry @@ -50,6 +56,6 @@ jobs: run: | FULL_IMAGE="${{ steps.prep.outputs.image }}:${{ steps.prep.outputs.tag }}" echo "🚀 Building container & executing Frontend/Backend checks inside image: ${FULL_IMAGE}" - DOCKER_BUILDKIT=1 docker build --progress=plain -t "${FULL_IMAGE}" . + DOCKER_BUILDKIT=1 docker build --progress=plain --build-arg APP_VERSION="${{ steps.prep.outputs.app_version }}" -t "${FULL_IMAGE}" . echo "✅ All checks & tests passed inside container! Pushing ${FULL_IMAGE}..." docker push "${FULL_IMAGE}" 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/Dockerfile b/Dockerfile index 0bfc856..1bca035 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,9 @@ WORKDIR /app/frontend COPY frontend/package*.json ./ RUN npm install --no-audit --no-fund COPY frontend/ ./ -RUN npx tsc --noEmit && npm run test:run && npm run build +# Version shown in the header: the git tag for releases (set by CI), otherwise "-dev" +ARG APP_VERSION="" +RUN npx tsc --noEmit && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build # ============================================================================== # Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer) diff --git a/Doku/README.md b/Doku/README.md index f9be5aa..6a2c4c6 100644 --- a/Doku/README.md +++ b/Doku/README.md @@ -13,6 +13,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code | [modules/triage.md](modules/triage.md) | Schnellerfassung und Triage | | [modules/tasks.md](modules/tasks.md) | Aufgaben, Kanban, Alarme | | [modules/workspace.md](modules/workspace.md) | Outliner-Notizen, Ordner, Tags | +| [modules/collaboration.md](modules/collaboration.md) | Gemeinsames Bearbeiten von Notizen: Log, Sperren, Konflikte, Offline, Transport-Stufen | | [modules/lists.md](modules/lists.md) | Geteilte Listen | | [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben | | [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare | @@ -23,9 +24,10 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code - **Ein Go-Binary** liefert REST-API, WebSocket/SSE und das eingebettete React-Frontend (`go:embed`). Es gibt genau einen Container und eine SQLite-Datei (`/data/mindsift.db`). - **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`). -- **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen. Verteilt werden Ereignisse nach REST-Änderungen. Es gibt keine CRDTs, Sperren oder Cursor-Presence. +- **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen (WebSocket, SSE als Ersatz). Verteilt werden Ereignisse nach Änderungen. +- **Notizen gemeinsam bearbeiten:** server-autoritatives Operations-Log mit Versionen und Sperren pro Bullet, Konfliktkopien statt Überschreiben, Offline-Warteschlange in IndexedDB, Abgleich auch hinter restriktiven Proxys ([modules/collaboration.md](modules/collaboration.md)). - **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks. -- **Freigaben:** Aufgabenlisten und geteilte Listen mit Rolle Bearbeiter oder Leser, Notizen und Ordner nur lesend. +- **Freigaben:** Aufgabenlisten, geteilte Listen, Notizen und Ordner jeweils mit Rolle Bearbeiter oder Leser. ## Entwickeln diff --git a/Doku/api/rest.md b/Doku/api/rest.md index e14f675..6dc26a8 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -13,15 +13,15 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` | | POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) | | POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen | -| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md) | -| GET | `/events` | SSE-Fallback | -| POST | `/events/rooms` | SSE-Räume beitreten/verlassen ([FLAW-02](../flaws.md)) | +| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig | +| GET | `/events` | SSE-Fallback; Token als `?token=` nötig | ## Konto und Allgemeines (angemeldet) | Methode | Pfad | Zweck | |---|---|---| | GET | `/auth/me` | Benutzer und Kennzahlen | +| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff | | POST | `/auth/logout` | Token sperren | | POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) | | PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` | @@ -67,17 +67,50 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein | GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) | | GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen | | PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) | -| GET/POST/DELETE | `/workspace/folders/shared/{id}/shares[/{shareId}]` | Freigaben (nur Lesen wirksam) | +| GET/POST/DELETE | `/workspace/folders/shared/{id}/shares[/{shareId}]` | Freigaben (`permission`: `read`/`write`) | | GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details | | PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) | | GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) | -| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`); Löschen | -| PATCH | `/documents/{id}/blocks` | Teil-Update der Blöcke (`upserted`, `deletedIds`, Reihenfolge); löst `DOC_BLOCKS_PATCHED` aus | +| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen | | PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) | | POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) | -| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`; wirksam nur Lesen) | +| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) | -Nur der Eigentümer schreibt in eine Notiz; Empfänger erhalten `403`/„nur Lesezugriff“ ([FLAW-17](../flaws.md)). +Der Inhalt einer Notiz (Blöcke) wird ausschließlich über `POST /docs/{id}/sync` geändert. `POST /documents` nimmt beim Anlegen weiterhin eine Start-Blockliste an; sie wird intern in Operationen umgerechnet. + +## Gemeinsames Bearbeiten von Notizen + +Konzept und Regeln: [modules/collaboration.md](../modules/collaboration.md). Zugriff über die Rolle `owner`/`editor`/`viewer` (`Store.DocRole`); ohne Rolle `404`. + +| Methode | Pfad | Zweck | +|---|---|---| +| GET | `/docs/{id}/snapshot` | Kompletter Blockbaum inkl. Grabsteinen, `title`, `titleVersion`, `headSeq`, `role`, `shared` | +| POST | `/docs/{id}/sync` | Ein Rundlauf: eigene Operationen senden, Leases halten/abgeben, Log ab `lastSeq` abholen | +| GET | `/docs/{id}/changes?since=N&wait=S` | Long-Polling: antwortet sofort, wenn `headSeq > N`, sonst bei der nächsten Änderung (auch Leases/Präsenz) oder nach höchstens 25 s. Antwort `{headSeq, changed}`; Daten holt der Client danach per `/sync` | +| POST | `/docs/{id}/leave` | Sitzung beenden, alle Leases des Clients freigeben (`{clientId}`) | + +`/sync`-Anfrage: + +```json +{ + "clientId": "tab-7f3…", + "lastSeq": 412, + "ops": [ + { "opId": "c1a…", "kind": "set_content", "blockId": "b9…", "baseVersion": 7, "content": "Milch und Brot" }, + { "opId": "c1b…", "kind": "insert", "blockId": "bA…", "parentId": "b2…", "orderKey": "a0V", "type": "bullet", "content": "" } + ], + "leases": { "hold": ["b9…", "bA…"], "release": [] } +} +``` + +- Operationsarten: `insert`, `set_content`, `set_props` (`patch`: `type`, `checked`, `collapsed`, `lang`, `clearConflict`), `move`, `delete`, `restore` (`ids`), `set_title` (`baseVersion`, `title`). +- `leases.hold` ist die vollständige Liste der Blöcke, die der Client halten will; alle anderen Leases des Clients werden freigegeben. Neu eingefügte Blöcke und Konfliktkopien bekommt der Autor automatisch. +- Höchstens 500 Operationen pro Anfrage. Leser dürfen keine Operationen senden (`403`), aber abfragen. +- `skipLog: true` liefert kein Log zurück; gedacht für einzelne Änderungen ohne geöffneten Editor (z. B. Abhaken in der To-Do-Übersicht). + +Antwort: `results` (je Operation `status` `applied`/`conflict`/`rejected`, ggf. `conflictBlockId`, `version`, `seq`), `ops` (Log-Einträge `{seq, opId, userId, clientId, effect}` ab `lastSeq`, höchstens 1000; `hasMore` bei mehr), `headSeq`, `resync` (Snapshot neu laden: der Client ist dem Server voraus, oder sein `lastSeq` liegt vor der verdichteten Grenze `log_floor`; die gesendeten Operationen werden trotzdem angewendet), `role`, `shared`, `sessions`, `presence`, `leases` (`granted`, `denied`, `others`). + +Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Antwort enthält das gespeicherte Ergebnis. ## Geteilte Listen diff --git a/Doku/api/websocket.md b/Doku/api/websocket.md index c2fe7e7..a1f1312 100644 --- a/Doku/api/websocket.md +++ b/Doku/api/websocket.md @@ -11,9 +11,18 @@ Der Server verteilt Änderungen als JSON-Ereignisse an verbundene Clients. Die E | WebSocket | `GET /api/v1/ws` (auch `/ws/lists/{id}`, `/ws/spaces/{id}`) | Ping alle 30 s, Lesefrist 60 s, Nachrichten bis 4 MB | | SSE | `GET /api/v1/events` | Kommentar-Ping alle 15 s, Header `X-Accel-Buffering: no` | -Der Client versucht zuerst WebSocket. Gelingt der Aufbau nicht innerhalb von 2,5 s oder bricht die Verbindung unsauber ab, wechselt er auf SSE und bleibt dabei (Firmen-Proxys). Dort liefert der Server `SSE_CONNECTED` mit einer `sseId`; Räume werden über `POST /api/v1/events/rooms` (`sseId`, `room`, `action`: `join`/`leave`) abonniert. +Der Client versucht zuerst WebSocket und wechselt in folgenden Fällen für die Sitzung des Tabs auf SSE: -**Authentifizierung:** Token als `?token=` oder `Authorization`-Header. Ohne gültiges Token wird die Verbindung als `anonymous` akzeptiert ([FLAW-01](../flaws.md), [FLAW-02](../flaws.md)). Das Token wird nur beim Verbinden geprüft. +- Aufbau gelingt nicht innerhalb von 2,5 s, oder die Verbindung bricht unsauber ab (z. B. Code 1006/1008). +- **Herzschlag:** Alle 15 s schickt der Client `{"event":"PING"}`, der Server antwortet `PONG`. Kommt 35 s lang keine Nachricht, gilt der WebSocket als stumm (Proxy nimmt das Upgrade an, leitet aber nichts weiter). + +Über SSE liefert der Server zuerst `SSE_CONNECTED` mit einer `sseId`; erst dann gilt SSE als verbunden. Bleibt dieses erste Ereignis 5 s nach dem Öffnen aus, puffert ein Proxy den Stream: SSE wird für den Tab abgeschaltet. Räume werden über `POST /api/v1/events/rooms` (`sseId`, `room`, `action`: `join`/`leave`) abonniert. + +Ohne WebSocket und SSE nutzt die Notiz-Synchronisation Long-Polling (`GET /docs/{id}/changes`, bis 25 s), und wenn auch das dreimal scheitert, eine Abfrage alle 3 s (siehe [modules/workspace.md](../modules/workspace.md)). + +**Authentifizierung:** Token als `?token=` oder `Authorization`-Header. Ohne gültiges Token antworten `/ws` und `/events` mit `401`. Das Token wird nur beim Verbinden geprüft ([FLAW-22](../flaws.md)). + +**Räume:** Jeder Beitritt (Raum in der URL, `JOIN_ROOM`, SSE-Startparameter `rooms`/`docId`, `POST /events/rooms`) wird geprüft: `doc:` nur mit Lesezugriff auf die Notiz, sonst muss die ID eine lesbare geteilte Liste sein. Nicht erlaubte Räume werden ignoriert, `POST /events/rooms` antwortet dann mit `403`. SSE-Räume kann nur der Besitzer der jeweiligen `sseId` ändern. ## Nachrichtenformat @@ -21,7 +30,7 @@ Der Client versucht zuerst WebSocket. Gelingt der Aufbau nicht innerhalb von 2,5 { "event": "TASK_UPDATED", "listId": "", "payload": { … }, "timestamp": "2026-10-27T12:00:00Z" } ``` -Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":""}` und `{"event":"LEAVE_ROOM","room":""}`. Räume heißen `doc:` für Notizen oder `` für geteilte Listen. +Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":""}`, `{"event":"LEAVE_ROOM","room":""}` und `{"event":"PING"}` (Antwort `PONG`). Räume heißen `doc:` für Notizen oder `` für geteilte Listen. ## Zustellung @@ -29,20 +38,20 @@ Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":""}` |---|---| | Benutzer-Kanal (`BroadcastToUser`) | alle Verbindungen eines Benutzers | | Raum (`BroadcastRoom`, `Broadcast`) | Verbindungen im Raum | -| Alle (`BroadcastToAll`) | **jede** Verbindung, auch unangemeldete | +| Mehrere Benutzer (`BroadcastToUsers`) | alle Verbindungen einer berechneten Benutzergruppe, z. B. Eigentümer und Mitglieder einer Aufgabenliste | ## Ereignisse | Ereignis | Auslöser | Kanal | |---|---|---| -| `TASK_CREATED` | `POST /inbox` | Benutzer | -| `TASK_UPDATED` | `PATCH /thoughts/{id}` (Triage: Benutzer) | alle | -| `TASK_DELETED` | `DELETE /thoughts/{id}` | alle | -| `TASK_REORDERED` | Aufgaben umsortieren | alle | -| `TASK_LIST_UPDATED` / `TASK_LIST_DELETED` | Liste ändern, (un)teilen, löschen | alle | +| `TASK_CREATED` | `POST /inbox`, Webhook | Eigentümer und Benutzer der Liste | +| `TASK_UPDATED` | `PATCH /thoughts/{id}`, Triage | Eigentümer und Benutzer der alten und neuen Liste | +| `TASK_DELETED` | `DELETE /thoughts/{id}` | Eigentümer und Benutzer der Liste | +| `TASK_REORDERED` | Aufgaben umsortieren | Benutzer aller betroffenen Listen | +| `TASK_LIST_UPDATED` / `TASK_LIST_DELETED` | Liste ändern, (un)teilen, löschen; Payload nur `{id}`, Clients laden ihre Listen neu | Benutzer vor und nach der Änderung | | `DOC_CREATED`, `DOC_UPDATED`, `DOC_DELETED` | Notiz anlegen/ändern/löschen, Freigaben | berechtigte Benutzer | -| `DOC_BLOCKS_PATCHED` | `PATCH /documents/{id}/blocks` (`docId`, `updatedBy`, `upserted`, `deletedIds`, `blockOrder`) | Raum `doc:` und berechtigte Benutzer | -| `WORKSPACE_FOLDERS_UPDATED` | Ordner/Mounts ändern | Benutzer | +| `DOC_SEQ` | Änderung an Notiz, Leases oder Präsenz (`docId`, `headSeq`); Clients holen die Daten per `/docs/{id}/sync` | Raum `doc:` | +| `WORKSPACE_FOLDERS_UPDATED` | Ordner/Mounts ändern | Benutzer selbst; bei geteilten Ordnern Eigentümer und alle Beteiligten | | `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste | | `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste | | `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum | @@ -52,4 +61,4 @@ Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":""}` - Die globale Verbindung (ein Singleton) leitet jede Nachricht als Browser-Ereignis `mindsift:ws` weiter; Ansichten (`TasksView`, `WorkspaceView`, `SharedListsView`) hören darauf und laden bzw. patchen ihren Zustand. - Nach einem Abbruch verbindet sich der Client mit Verzögerung neu. -- Weil Notizen nur vom Eigentümer geschrieben werden, dient `DOC_BLOCKS_PATCHED` vor allem dazu, Lesenden und weiteren Geräten des Eigentümers Änderungen zu zeigen. Es gibt keine Konfliktauflösung (letzter Schreiber gewinnt). +- Notiz-Inhalte kommen nie per Push, sondern immer über `POST /docs/{id}/sync`; `DOC_SEQ` ist nur der Weckruf. Konflikte entscheidet der Server (Versionen und Sperren, siehe [modules/collaboration.md](../modules/collaboration.md)). diff --git a/Doku/architecture.md b/Doku/architecture.md index e084702..4d677ce 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -34,10 +34,13 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `internal/storage/` | `sqlite.go` (Schema, Migrationen, gesamte Datenlogik) | | `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion | | `internal/ws/` | `hub.go` | +| `internal/collab/` | Kern der Notiz-Synchronisation ohne Datenbank: Blockbaum, Operationen und Konfliktregeln, Sortierschlüssel, Leases, Long-Poll-Benachrichtigung | | `internal/auth/` | Tokens und Passwörter | | `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 @@ -46,7 +49,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ 2. `storage.NewStore()` öffnet SQLite (`busy_timeout`, WAL, max. 8 Verbindungen) und führt `migrate()` aus. 3. SMTP-Werte aus der Umgebung werden in `system_settings` übernommen, sofern dort noch nichts steht. 4. Mit Demo-Flag legt `EnsureDemoAccounts` die Konten `admin/admin` und `user/user` an. -5. `api.NewServer` startet den **Alarm-Ticker** (jede Minute) und baut den Router. Server-Timeouts: Lesen 15 s, Schreiben 30 s, Leerlauf 60 s. Beim Beenden wird sauber heruntergefahren. +5. `api.NewServer` startet den **Alarm-Ticker** (jede Minute), die **Verdichtung des Notiz-Logs** (beim Start, dann alle 6 Stunden) und baut den Router. Server-Timeouts: Lesen 15 s, Schreiben 30 s, Leerlauf 60 s. Beim Beenden wird sauber heruntergefahren. ## Konfiguration @@ -76,6 +79,10 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist `due_date <= heute` (Serverzeit), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md). +## Hintergrundjob: Verdichtung der Notiz-Synchronisation + +`startCompaction` räumt beim Start und dann alle 6 Stunden auf: abgelaufene Leases und Sitzungen im Speicher, Log-Einträge älter als 30 Tage (private Notizen: 7 Tage), Grabsteine älter als 30 Tage und Idempotenz-Ergebnisse älter als 90 Tage. Details: [database.md](database.md#verdichtung-des-notiz-logs). + ## Build ``` @@ -84,4 +91,6 @@ Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift ( Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata ``` +Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.2`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.2-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`. + Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`). diff --git a/Doku/database.md b/Doku/database.md index eaccf8c..001fe4c 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -27,12 +27,14 @@ Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND ### Workspace | Tabelle | Zweck | |---|---| -| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived`, `markdown_cache`, `shared_folder_id`, `sub_folder_path`, Altspalten `content`, `blocks_json` | -| `blocks` | Outliner-Blöcke: `document_id`, `type`, `content`, `level`, `position` (REAL), `checked`, `collapsed`, `lang` | +| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived`, `markdown_cache`, `shared_folder_id`, `sub_folder_path`, `head_seq` (letzte Log-Position), `title_version`, `log_floor` (bis hierhin ist das Log verdichtet), Altspalten `content`, `blocks_json` | +| `blocks` | Outliner-Blöcke als Baum: `document_id`, `parent_id` (`''` = oberste Ebene), `order_key` (Sortierschlüssel unter Geschwistern), `type`, `content`, `checked`, `collapsed`, `lang`, `version` (Textversion), `conflict_of`/`conflict_author` (Konfliktkopien), `updated_by`, `deleted` (Grabstein) und `deleted_at` (Zeitpunkt des Löschens, für das Aufräumen). `level`/`position` sind Altspalten und werden nicht mehr gepflegt; Ebene und Reihenfolge berechnet der Server beim Lesen aus dem Baum. | +| `doc_ops` | Änderungs-Log je Notiz: `seq` (fortlaufend pro Notiz), `op_id`, `user_id`, `client_id`, `kind`, `block_id`, `payload` (Effekt als JSON), `created_at`. Abspielen aller Einträge ab einem Snapshot ergibt den Stand. Wird verdichtet (siehe unten). | +| `doc_op_results` | Ergebnis je Client-Operation (`op_id` eindeutig) für idempotente Wiederholungen; wird nach 90 Tagen gelöscht | | `block_tags` | `#Hashtags` je Block (`tag`, `document_id`, `block_id`) | | `tag_pages` | Freitext-Notiz je Benutzer und Tag | | `doc_pins` | Favoriten je Benutzer | -| `doc_shares`, `doc_team_shares` | Notiz-Freigaben an Benutzer bzw. Gruppen (`permission` effektiv immer `read`) | +| `doc_shares`, `doc_team_shares` | Notiz-Freigaben an Benutzer bzw. Gruppen (`permission`: `read` = Leser, `write` = Bearbeiter) | | `workspace_folders` | Explizit angelegte Ordnerpfade | | `workspace_shared_folders` | Geteilte Ordner (Entität mit Eigentümer) | | `workspace_folder_shares` | Freigaben geteilter Ordner | @@ -74,10 +76,23 @@ teams 1─n team_members n─1 users - Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert). - Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`. - `users.username` wird aus dem E-Mail-Präfix aufgefüllt. -- Alle Dokument-, Team- und Ordnerfreigaben werden auf `read` gesetzt (bei **jedem** Start, siehe [FLAW-17](flaws.md)). +- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt. - Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=teams_only`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer. - Die Tabelle `teams` wird einmalig ohne Fremdschlüssel auf `organizations` neu aufgebaut. ## Sicherung Die gesamte Instanz besteht aus der Datei `mindsift.db` (plus `-wal`/`-shm` im laufenden Betrieb). Konsistent sichern per `sqlite3 mindsift.db ".backup 'backup.db'"` oder durch Stoppen des Containers und Kopieren des `data/`-Verzeichnisses. Benutzerexport: `GET /api/v1/export` (JSON mit Gedanken, Notizen, Listen, Aufgabenlisten). + +## Verdichtung des Notiz-Logs + +`Store.CompactDocs` läuft beim Start und danach alle 6 Stunden (`startCompaction` in `internal/api/docsync.go`): + +| Was | Aufbewahrung | Folge | +|---|---|---| +| `doc_ops` geteilter Notizen | 30 Tage | ältere Einträge werden gelöscht, `documents.log_floor` merkt sich die Grenze | +| `doc_ops` privater Notizen | 7 Tage | wie oben | +| Grabsteine in `blocks` | 30 Tage nach `deleted_at` | endgültig gelöscht; das ist selbst ein Log-Eintrag `purge`, geöffnete Editoren übernehmen ihn ohne Neuladen. Ein Grabstein bleibt, solange ein jüngerer gelöschter Unterblock auf ihn zeigt. | +| `doc_op_results` | 90 Tage | gelöscht | + +Ein Client, dessen `lastSeq` unter `log_floor` liegt (z. B. Gerät wochenlang offline), bekommt von `/sync` `resync: true` und lädt den Snapshot neu; seine noch nicht gesendeten Änderungen bleiben in der Warteschlange und werden danach angewendet. Text für einen inzwischen endgültig gelöschten Block wird als neuer Block am Ende der Notiz angelegt, ein neuer Block unter einem endgültig gelöschten Elternblock landet auf der obersten Ebene. diff --git a/Doku/flaws.md b/Doku/flaws.md index b7a4112..d54c80b 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -10,9 +10,9 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | ID | Bereich | Kurzbeschreibung | Schwere | |---|---|---|---| -| FLAW-01 | Realtime | `BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen, auch unauthentifizierte | **Hoch** | -| FLAW-02 | Realtime | Räume (WS `JOIN_ROOM`, SSE `/events/rooms`, `?listId=`) ohne Berechtigungsprüfung | **Hoch** | -| FLAW-03 | Aufgaben | `ReorderTasks` ändert beliebige Aufgaben per ID, ohne Zugriffsprüfung (IDOR) | **Hoch** | +| FLAW-01 | Realtime | ~~`BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen~~ | **Behoben** | +| FLAW-02 | Realtime | ~~Räume ohne Berechtigungsprüfung~~ | **Behoben** | +| FLAW-03 | Aufgaben | ~~`ReorderTasks` ändert beliebige Aufgaben per ID (IDOR)~~ | **Behoben** | | FLAW-04 | Auth | Fest eincodiertes Fallback-`JWT_SECRET` | **Hoch** | | FLAW-05 | Benachrichtigungen | Discord-/Webhook-URLs ungeprüft (SSRF) | Mittel | | FLAW-06 | Aufgaben | Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben | Mittel | @@ -20,13 +20,13 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-08 | Auth | Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active` | Mittel | | FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel | | FLAW-10 | Freigaben | `sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten | Mittel | -| FLAW-11 | Workspace | Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht | Mittel | +| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** | | FLAW-12 | Benachrichtigungen | `notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder | Mittel | | FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel | | FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel | -| FLAW-15 | Realtime | `WriteTimeout` von 30 s trifft vermutlich auch SSE-Streams *(nicht live verifiziert)* | Mittel | +| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** | | FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel | -| FLAW-17 | Workspace | Dokument- und Ordnerfreigaben sind per Migration erzwungen schreibgeschützt; tote Schreibpfade | Niedrig | +| FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** | | FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig | | FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig | | FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig | @@ -34,22 +34,27 @@ 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 | ~~Struktur-Aktionen an fremd gesperrten Bullets nur teilweise ausgeführt~~ | **Behoben** | +| FLAW-26 | Workspace | ~~Nach Enter landet sehr schnell Getipptes noch im vorherigen Bullet~~ | **Behoben** | --- ## Details ### FLAW-01: Broadcast an alle Verbindungen (Datenabfluss) +- **Status: Behoben.** `BroadcastToAll` existiert nicht mehr. Aufgabenereignisse gehen an Eigentümer und Benutzer der Liste (`GetTaskListUserIDs`), Listenereignisse tragen nur noch die ID, Ordnerereignisse gehen an Benutzer bzw. Ordnerbeteiligte (`GetSharedFolderUserIDs`). Test: `TestTaskEventsOnlyReachAuthorizedUsers`. - **Fundstellen:** [`internal/ws/hub.go`](../internal/ws/hub.go) `BroadcastToAll`; Aufrufer in [`internal/api/server.go`](../internal/api/server.go): `handleUpdateThought` (`TASK_UPDATED`), `handleDeleteThought` (`TASK_DELETED`), `handleReorderTasks` (`TASK_REORDERED`), `handleUpdateTaskList`, `handleCreateTaskListShare`, `handleDeleteTaskListShare` (`TASK_LIST_UPDATED`), `handleDeleteTaskList`. - **Problem:** `BroadcastToAll` schreibt an `allClients` und `sseClients`. Dazu zählen auch Verbindungen mit `userID = "anonymous"`, denn `handleWebSocket` und `handleSSE` liegen außerhalb der Auth-Gruppe und lassen jede Verbindung zu. Der vollständige Aufgabeninhalt (Titel, Notizen, Unteraufgaben) und die Listen samt Mitgliedern gehen so an jeden, der `/api/v1/events` öffnet. - **Maßnahme:** Ereignisse an die berechtigten Nutzer adressieren (wie bei `DOC_*` über `GetAuthorizedDocUsers`), unauthentifizierte Verbindungen ablehnen. ### FLAW-02: Räume ohne Berechtigungsprüfung +- **Status: Behoben.** `/ws` und `/events` verlangen ein gültiges Token (sonst `401`). Jeder Raumbeitritt läuft über `Hub.SetRoomAuthorizer` → `Server.canJoinRoom` (Notiz bzw. Liste muss lesbar sein). `/events/rooms` liegt hinter der Anmeldung und akzeptiert nur die eigene `sseId`. Tests: `TestRealtimeEndpointsRequireAuth`, `TestSSERoomJoinRequiresAccess`, `TestRoomJoinsRequireAuthorization`. - **Fundstellen:** `Hub.readPump` (`JOIN_ROOM`), `handleSSERooms` (Route `/api/v1/events/rooms` liegt **außerhalb** der Auth-Gruppe), `Hub.Register` (Raum aus `?listId=`/`?spaceId=`). - **Problem:** Wer eine Raum-ID kennt (`doc:`, Listen-ID), tritt bei und erhält Inhalte wie `DOC_BLOCKS_PATCHED` oder `ITEM_*`. Raum-IDs tauchen in Ereignissen auf und sind nicht geheim. Für den SSE-Beitritt reicht die `sseId`, die der Server selbst im Stream mitsendet. - **Maßnahme:** Beitritt nur nach Zugriffsprüfung (`GetDocumentByID`, `GetListByID`); `/events/rooms` hinter `authMiddleware`. ### FLAW-03: `ReorderTasks` ohne Zugriffsprüfung (IDOR) +- **Status: Behoben.** `handleReorderTasks` lehnt die ganze Anfrage ab, sobald eine Aufgabe nicht zugreifbar (`404`) oder schreibgeschützt (`403`) ist. Test: `TestReorderRejectsForeignTasks`. - **Fundstelle:** `Store.ReorderTasks` (`UPDATE thoughts SET sort_order … WHERE id = ?`) und `handleReorderTasks`. - **Problem:** Die SQL-Anweisung ist weder auf `user_id` noch auf die Liste eingeschränkt. Jeder angemeldete Nutzer kann `sortOrder`, `kanbanStatus` und damit `isCompleted` fremder Aufgaben ändern, sofern er die ID kennt. Die Leser-Prüfung für Aufgabenlisten überspringt Aufgaben, auf die der Nutzer gar keinen Zugriff hat (`GetThoughtByID` liefert dann einen Fehler), und schützt davor nicht. - **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen. @@ -93,6 +98,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Eine zentrale Funktion `canShareWith(owner, target)` und in allen Share-Handlern verwenden; Verzeichnisse darauf einschränken. ### FLAW-11: Block-Upsert prüft das Dokument nicht +- **Status: Behoben.** Alle Blockänderungen laufen über `Store.applyDocChange`: Einfügen mit einer Block-ID aus einer anderen Notiz wird abgelehnt (`exists`), und der Upsert ändert nur Zeilen derselben Notiz (`WHERE blocks.document_id = excluded.document_id`). Test: `TestInsertCannotReuseBlockIDOfAnotherNote`. - **Fundstelle:** `Store.PatchDocumentBlocks`, `INSERT INTO blocks … ON CONFLICT(id) DO UPDATE`. - **Problem:** `blocks.id` ist global eindeutig. Existiert die ID bereits in einem **anderen** Dokument, überschreibt der Konflikt-Zweig dessen Inhalt, ohne `document_id` zu prüfen. Voraussetzung: Schreibrecht im eigenen Dokument und Kenntnis einer fremden Block-ID. - **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen. @@ -117,9 +123,11 @@ 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 *(nicht live verifiziert)* +### FLAW-15: WriteTimeout und SSE +- **Status: Behoben.** `ServeSSE` hebt die Schreibfrist für die eigene Antwort auf (`http.ResponseController.SetWriteDeadline`). Test mit 1-s-Timeout: `TestSSEStreamOutlivesWriteTimeout`. Der Test hat den Abbruch im alten Code bestätigt. - **Fundstelle:** [`cmd/server/main.go`](../cmd/server/main.go) `WriteTimeout: 30 * time.Second`. - **Problem:** Go beendet nach diesem Zeitraum das Schreiben der Antwort. Für den SSE-Fallback (`ServeSSE`) bedeutet das voraussichtlich einen Verbindungsabbruch alle 30 s. WebSockets sind durch Hijacking nicht betroffen. Der Client verbindet sich neu, daher fällt es kaum auf. - **Maßnahme:** Im SSE-Handler `http.ResponseController.SetWriteDeadline(time.Time{})` setzen oder `WriteTimeout` für diese Route aufheben. @@ -130,6 +138,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Setup per einmaligem Token aus dem Server-Log absichern; Demo-Modus nur zusammen mit expliziter Warnung/Bestätigung. ### FLAW-17: Schreibgeschützte Dokumentfreigaben +- **Status: Behoben.** Zwangsmigration entfernt, Backend prüft Bearbeiter-Rechte (`DocRole`, `/docs/{id}/sync`), der Editor speichert über die Sync-Engine, und der Freigabe-Dialog bietet „Bearbeiten“ an. Browser-Test: `e2e/share.e2e.mjs`. - **Fundstellen:** Migration in `Store.migrate` (`UPDATE doc_shares SET permission = 'read' …`, ebenso `doc_team_shares`, `workspace_folder_shares`), `PatchDocumentBlocks`/`UpdateDocument` (nur Eigentümer). - **Hinweis:** Das ist offenbar gewollt: Nur der Eigentümer schreibt in ein Dokument, Empfänger lesen. Es gibt daher **kein** kollaboratives Bearbeiten. Das Schema (`CHECK(permission IN ('read','write'))`), die Zweige für `write` im Code und frühere Dokumentation suggerieren etwas anderes. Es gibt weder Sperren noch Konfliktauflösung, noch Cursor-Presence. - **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen. @@ -166,6 +175,23 @@ 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 +- **Status: Behoben.** Fremd gesperrte Bullets sind im Editor schreibgeschützt und zeigen, wer sie bearbeitet. Löschen, Zusammenführen, Ein-/Ausrücken und Ziehen werden nicht gestartet, wenn ein beteiligter Bullet (oder einer im Ast) gesperrt ist; stattdessen erscheint ein Hinweis. Ebenso für „Übernehmen“/„Verwerfen“ und Rückgängig. Browser-Test: `e2e/lock.e2e.mjs`. +- **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 +- **Status: Behoben.** Neu eingehängte Bullets bekommen den Fokus sofort beim Einhängen. Browser-Test: „Sofort nach Enter Getipptes landet im neuen Bullet“. +- **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`). + +### FLAW-27: Grenzen nach der Verdichtung +- **Status: Bekannt, bewusst so.** +- **Fundstellen:** `CompactDocs` in [`internal/storage/docsync.go`](../internal/storage/docsync.go), `reviveAsNewBlock` in [`internal/collab/ops.go`](../internal/collab/ops.go). +- **Verhalten:** Nach 30 Tagen sind gelöschte Blöcke endgültig weg. Rückgängig für ein so altes Löschen ist nicht mehr möglich (Hinweis im Editor). Text, der wochenlang offline in einem inzwischen endgültig gelöschten Block getippt wurde, kommt als neuer Block am Ende der Notiz zurück, nicht an der alten Stelle. Wer eine Operation erst nach mehr als 90 Tagen erneut sendet (Antwort ging verloren, Gerät so lange offline), wird nicht mehr als Wiederholung erkannt; Textänderungen erzeugen dann höchstens eine Konfliktkopie, kein stilles Überschreiben. + --- ## Aus der früheren Fassung entfernt diff --git a/Doku/modules/collaboration.md b/Doku/modules/collaboration.md new file mode 100644 index 0000000..1e3d640 --- /dev/null +++ b/Doku/modules/collaboration.md @@ -0,0 +1,162 @@ +# Modul: Gemeinsames Bearbeiten von Notizen + +Code: [internal/collab/](../../internal/collab/) (Regeln, ohne Datenbank), [internal/storage/docsync.go](../../internal/storage/docsync.go) (Log, Snapshot, Rollen, Verdichtung), [internal/api/docsync.go](../../internal/api/docsync.go) (Endpunkte, Hintergrundjob), [frontend/src/lib/docSync/](../../frontend/src/lib/docSync/) (Engine im Browser), Anzeige in [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx) und [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx). + +Mehrere Personen und Geräte bearbeiten dieselbe Notiz gleichzeitig. Jede Notiz, auch eine private, speichert über diesen Weg; es gibt keinen zweiten Speicherweg. + +## Anforderungen + +1. **Kein Datenverlust.** Was jemand getippt hat, steht danach in der Notiz, auch nach Verbindungsabbruch, Neuladen oder gleichzeitigem Bearbeiten desselben Bullets. +2. **Funktioniert ohne WebSocket.** Hinter Proxys, die WebSockets blocken oder Streams puffern, funktioniert die Bearbeitung voll, nur mit etwas mehr Verzögerung. +3. **Funktioniert bei schlechtem Netz.** Eingaben werden lokal gesichert und später zuverlässig übertragen, auch nach Tagen offline. +4. **Klare Regel bei Konflikten.** Bearbeiten zwei Personen denselben Bullet, bekommt eine das Recht. Der Text der anderen bleibt als Konfliktkopie erhalten. +5. **Berechtigungen.** Notizen und Ordner lassen sich als *Bearbeiter* oder *Leser* teilen; der Server prüft das bei jeder Änderung. + +## Ansatz + +**Server-autoritatives Operations-Log mit Versionen pro Block und Block-Sperren (Leases).** Daten fließen immer über gewöhnliche HTTP-Anfragen; WebSocket, SSE und Long-Polling sind nur der Weckruf. + +| Baustein | Aufgabe | +|---|---| +| **Operations-Log** | Jede Änderung ist eine kleine Operation (Text ändern, Block einfügen, verschieben, löschen …). Der Server prüft und wendet sie an und schreibt die Auswirkungen (Effekte) mit fortlaufender Nummer `seq` pro Notiz ins Log. Wer seine `seq` kennt, holt alles danach. | +| **Version pro Block** | Jede Textänderung nennt die Blockversion, die der Nutzer beim Tippen vor sich hatte. Passt sie nicht mehr, liegt ein Konflikt vor. | +| **Lease pro Block** | Wer in einen Bullet klickt, hält ihn für 20 s und verlängert das, solange er dort arbeitet. Andere sehen „Anna bearbeitet …“ und können genau diesen Bullet so lange nicht ändern, alle anderen schon, auch im selben Ast. | +| **Konfliktkopie** | Kommt es trotzdem zum Konflikt (Lease abgelaufen, jemand war offline), behält der Block die zuerst gespeicherte Fassung; der andere Text wird als markierter Block direkt darunter eingefügt. | +| **Lokale Warteschlange** | Jede Änderung landet zuerst in IndexedDB und wird von dort gesendet. Jede Operation hat eine eindeutige ID; doppeltes Senden ist harmlos. | + +### Warum nicht CRDT (Yjs/Automerge) oder OT + +- **CRDT** führt Zeichen für Zeichen zusammen. Dafür bräuchte das Go-Backend eine vollständige Yjs-Implementierung, um Rechte, Hashtags und Markdown serverseitig auszuwerten; die gibt es in Go nicht ausgereift. Ohne sie wäre der Server nur ein Speicher für Binärdaten, den er nicht prüfen kann. Ein früherer Versuch ist daran gescheitert. +- **OT** braucht einen fehlerfreien Transformations-Kern für jede Operationsart; schwer korrekt zu bekommen und schwer zu testen. +- Gewollt ist, dass bei einem Bullet **einer das Recht bekommt**. Sperren plus Versionsprüfung sind einfach zu verstehen, vollständig serverseitig prüfbar und gegen Netzfehler testbar. +- Bewusster Verzicht: Zwei Personen können nicht gleichzeitig im **selben** Bullet tippen. In **verschiedenen** Bullets, auch im selben Ast, geht es. + +## Datenmodell + +Details in [database.md](../database.md). + +- **Baum statt flacher Liste.** `blocks.parent_id` (leer = oberste Ebene) und `blocks.order_key` sortieren Geschwister. Einrücken oder Verschieben eines Astes ist *eine* Operation auf *einem* Block. Ebene und Position für die Oberfläche berechnet der Server beim Lesen. +- **Sortierschlüssel (Fractional Index).** Ein neuer Block zwischen `a0` und `a1` bekommt einen Schlüssel dazwischen; umnummeriert wird nie. Gleiche Schlüssel (zwei gleichzeitige Einfügungen) sortiert die Block-ID. Go (`orderkey.go`) und TypeScript (`orderKey.ts`) erzeugen identische Schlüssel, geprüft mit gemeinsamen Testvektoren. +- **Grabsteine.** Gelöschte Blöcke bleiben mit `deleted = 1` und `deleted_at` erhalten. Eine verspätete Textänderung stellt den Block wieder her, statt verloren zu gehen. Nach 30 Tagen werden sie endgültig entfernt (siehe [Verdichtung](#verdichtung)). +- **Log** `doc_ops` (Effekte je `seq`) und **Idempotenz** `doc_op_results` (Ergebnis je Operations-ID). Das Abspielen des Logs ab einem Snapshot ergibt exakt den Serverstand. +- **Leases und Sitzungen liegen im Arbeitsspeicher** (`collab.LeaseManager`). Sie sind kurzlebig, nach einem Neustart fordern Clients sie automatisch neu an. MindSift läuft als eine Instanz, das passt. +- **Abgeleitete Daten** (`markdown_cache`, Suchtext, `tags`, `block_tags`) entstehen in derselben Transaktion wie die Änderung. + +## Protokoll + +Endpunkte und Felder: [api/rest.md](../api/rest.md#gemeinsames-bearbeiten-von-notizen). + +| Methode | Pfad | Zweck | +|---|---|---| +| GET | `/api/v1/docs/{id}/snapshot` | Alle Blöcke inkl. Grabsteine, Titel, `headSeq`, eigene Rolle, `shared` | +| POST | `/api/v1/docs/{id}/sync` | **Ein Aufruf für alles:** eigene Operationen senden, Leases halten, neue Log-Einträge abholen | +| GET | `/api/v1/docs/{id}/changes?since=N&wait=25` | Long-Polling: antwortet, sobald sich die Notiz (oder Sperren/Präsenz) ändert, spätestens nach `wait` Sekunden | +| POST | `/api/v1/docs/{id}/leave` | Sitzung beenden, Leases freigeben | + +Operationen: `insert`, `set_content`, `set_props` (Typ, abgehakt, eingeklappt, Sprache, Konflikt erledigt), `move`, `delete`, `restore`, `set_title`. Im Log steht zusätzlich `purge` (Verdichtung). + +### Regeln auf dem Server + +Alle Operationen einer Notiz laufen nacheinander: ein Mutex pro Notiz und eine SQLite-Transaktion pro `/sync`. „Wer zuerst kommt“ ist dadurch immer eindeutig. + +1. **Idempotenz:** Ist die Operations-ID bekannt, kommt das gespeicherte Ergebnis zurück, ohne erneut anzuwenden. Verlorene Antworten sind harmlos. +2. **Berechtigung:** Operationen nur mit Rolle `owner` oder `editor`, sonst `403`. Block-IDs anderer Notizen sind tabu. +3. **`set_content`:** Hält ein *anderer* Client eine Lease oder passt die Basisversion nicht → **Konflikt**. Gleicher Text → angewendet ohne neue Version. Gelöschter Block → wiederherstellen (bei gelöschtem Elternblock unter dem nächsten lebenden Vorfahren) und anwenden. Endgültig entfernter Block → Text wird als neuer Block am Ende angelegt. +4. **Konflikt:** Der Block behält seinen Inhalt. Direkt danach entsteht ein Block mit dem abgelehnten Text, `conflictOf` und `conflictAuthor` gesetzt, mit deterministischer ID (`blk_cf_` + Hash der Operations-ID), damit Wiederholungen keine zweite Kopie erzeugen. Für den Titel gilt dasselbe mit eigener Titelversion. +5. **`move`:** abgelehnt bei Zyklus, bei gelöschtem Ziel-Elternblock oder wenn jemand anders den Block selbst hält. Unterpunkte wandern mit; wer darin tippt, tippt einfach an der neuen Stelle weiter. +6. **`delete`:** abgelehnt, wenn jemand anders einen Block im Ast hält; sonst Grabstein für den ganzen Ast. +7. **`insert`:** Ist der Elternblock gelöscht, wird unter dem nächsten lebenden Vorfahren eingefügt, ist er endgültig entfernt, auf oberster Ebene. +8. **Leases:** Vergabe nur, wenn frei, abgelaufen oder schon im Besitz desselben Clients; gültig 20 s ab letzter Bestätigung. `leases.hold` ist immer die vollständige Liste. Neu eingefügte Blöcke und Konfliktkopien bekommt ihr Autor automatisch. +9. **Keine Lease auf Elternblöcke:** Einfügen, Ein- und Ausrücken unter einem Elternblock brauchen keine Sperre auf diesen. + +### Ablauf beim Tippen + +```mermaid +sequenceDiagram + participant A as Client Anna + participant S as Server + participant B as Client Ben + A->>S: /sync leases.hold=[b9] + S-->>A: granted b9 + S--)B: DOC_SEQ (Weckruf) + B->>S: /sync lastSeq=412 + S-->>B: leases.others: b9 = Anna (Bullet gesperrt) + A->>S: /sync op set_content b9 base=7 + S-->>A: applied, seq 414 + S--)B: DOC_SEQ headSeq=414 + B->>S: /sync lastSeq=413 + S-->>B: ops [set_content b9 v8] +``` + +## Client (`DocSyncEngine`) + +- **Zustände:** *bestätigter Stand* (vom Server, mit `lastSeq`) und *Warteschlange* (eigene, unbestätigte Operationen). Angezeigt wird der bestätigte Stand mit der Warteschlange darübergelegt; das macht `model.ts` mit denselben Regeln wie der Server. +- **Editor-Anbindung:** Die Editor-Komponenten arbeiten mit einer flachen Liste (Ebene + Reihenfolge). `diff.ts` übersetzt jede Änderung per Vergleich in Operationen. +- **Persistenz:** Warteschlange und bestätigter Stand liegen in IndexedDB. Beim Öffnen erscheint sofort der gespeicherte Stand, auch offline. +- **Senden:** gesammelt nach 300 ms, immer nur ein `/sync` gleichzeitig; Wiederholung mit Backoff 1 s, 2 s, 4 s … höchstens 30 s. Mehrere Textänderungen eines Blocks werden zusammengefasst; die Basisversion bleibt die der ersten (die der Nutzer beim Tippen sah), damit nie eine fremde Änderung überschrieben wird. +- **Leases:** + - Tippen wartet nie auf eine Sperre. Beim Fokussieren wird sie sofort angemeldet. + - Neue Bullets (Enter, Einfügen) gehören sofort dem Ersteller. + - Verlassene Bullets bleiben 20 s gehalten; nach 60 s ohne Eingabe wird auch der fokussierte Bullet freigegeben. + - Fremd gesperrte Bullets zeigen ein Schloss mit Namen und sind schreibgeschützt; Enter darin legt einen neuen Bullet darunter an. Löschen, Zusammenführen, Ein-/Ausrücken und Ziehen, die einen gesperrten Bullet berühren, werden gar nicht erst ausgeführt (Hinweis unten im Editor). +- **Konflikte:** Konfliktkopien sind farbig markiert, mit „Übernehmen“ (ersetzt den Originaltext) und „Verwerfen“. Beide prüfen vorher die Sperren von Original und Kopie. +- **Hintergrund und Netz:** Beim Verbergen der Seite sendet die Engine Ausstehendes mit `keepalive` und gibt ihre Sperren frei; bei `online` und Rückkehr in den Tab gleicht sie sofort ab. +- **Rechteentzug:** Der nächste `/sync` liefert `403` bzw. `404`. Die Engine verwirft die Warteschlange nicht, hört aber auf zu senden und zeigt den Fehler. +- **Neu laden:** Meldet der Server `resync` (Client voraus oder hinter der verdichteten Log-Grenze) oder fehlt eine `seq`, lädt die Engine den Snapshot; die Warteschlange bleibt erhalten und wird danach angewendet. + +## Transport: robust auch hinter Proxys + +Daten fließen nur über `/sync` und `/snapshot`. Push-Kanäle melden nur „Notiz X hat jetzt `headSeq` N“ (`DOC_SEQ` im Raum `doc:`, siehe [api/websocket.md](../api/websocket.md)). + +| Stufe | Ausfall erkannt durch | Wechsel zu | +|---|---|---| +| 1. WebSocket | Verbindungsfehler oder 35 s ohne `PONG` auf das `PING` alle 15 s (WebSocket angenommen, aber nichts kommt durch) | SSE | +| 2. SSE | Erstes Ereignis kommt nicht innerhalb von 5 s (Proxy puffert); gilt dann für den ganzen Tab | Long-Polling | +| 3. Long-Polling (`/changes`, 25 s) | 3 Fehlschläge hintereinander | Intervall | +| 4. Intervall | – | Abfrage alle 3 s; bei `online` wird Long-Polling erneut versucht | + +Zusätzlich, solange Stufe 1–3 läuft: Abgleich alle 7 s, wenn die Engine Sperren hält (Verlängerung), sonst alle 30 s zur Sicherheit. Der Editor zeigt den Weg an („Live“, „Live (Long-Polling)“, „Abgleich alle 3 s“). Gemessen im Browser-Test: 0,2–0,3 s über WebSocket, SSE und Long-Polling, rund 3 s im Intervall. + +Long-Polling bleibt mit 25 s unter üblichen Proxy-Grenzen (Nginx 60 s, Cloudflare 100 s). Den Schreib-Timeout hebt der Server für Streams und Long-Polling auf. Räume betritt nur, wer die Notiz lesen darf. Proxy-Beispiele: [reverse-proxy.md](../reverse-proxy.md). + +## Berechtigungen + +- Rollen je Notiz: `owner`, `editor`, `viewer`. Die effektive Rolle ist die höchste aus Eigentum, direkter Freigabe, Gruppenfreigabe und Freigabe des geteilten Ordners. Eine zentrale Funktion `Store.DocRole` entscheidet für Snapshot, Sync, Long-Polling, Raumbeitritt und Aufgaben-Verknüpfungen. +- Nur der Eigentümer verwaltet Freigaben und löscht die Notiz. Bearbeiter dürfen sonst alles: Inhalt, Struktur, Titel und Symbol. +- Freigaben liegen in `doc_shares`, `doc_team_shares` und `workspace_folder_shares` (`read`/`write`). Eine Zusammenlegung zu einer Tabelle wurde bewusst nicht umgesetzt: `DocRole` entscheidet bereits zentral, das Umschreiben der rund 20 Sichtbarkeitsabfragen brächte nur Risiko. Mehr: [sharing-and-permissions.md](../sharing-and-permissions.md). + +## Private Notizen + +Gleiches Modell, aber sparsam: Solange eine Notiz nicht geteilt ist (`shared = false`) und nur in einem Tab offen ist (`sessions = 1`), gibt es keine Leases, keinen Herzschlag und nur die 30-s-Abfrage. Öffnet ein zweites Gerät die Notiz oder wird sie geteilt, schalten alle offenen Clients mit der nächsten Antwort auf den vollen Ablauf um. Gegenüber dem früheren Speichern des ganzen Dokuments alle 800 ms überträgt und schreibt das Modell nur geänderte Blöcke. + +## Rückgängig und Wiederholen + +- `Strg+Z`/`Cmd+Z` nimmt die letzte **eigene** Änderung zurück, `Strg+Umschalt+Z` bzw. `Strg+Y` wiederholt. Fremde Änderungen bleiben unberührt. +- Zu jeder eigenen Änderung merkt sich die Engine den Gegenschritt (`undo.ts`) und sendet ihn als normale Operation; Server und Log brauchen dafür nichts Besonderes. +- Tippen im selben Bullet ist ein Schritt bis zu einer Pause von 1 s; Einfügen, Löschen, Einrücken, Verschieben, Abhaken und mehrzeiliges Einfügen sind jeweils ein Schritt. Bis zu 200 Schritte pro Tab und Notiz. +- Hat inzwischen jemand anders den Bullet geändert oder hält ihn gerade, wird der Schritt mit Hinweis übersprungen, nie blind überschrieben. +- Das Undo des Textfelds selbst wird abgefangen (`keydown` und `beforeinput` mit `historyUndo`), damit Browser-Undo und eigenes Undo nicht gegeneinander arbeiten. + +## Verdichtung + +Ein Hintergrundjob (`startCompaction`) läuft beim Start und danach alle 6 Stunden: + +| Was | Aufbewahrung | +|---|---| +| Log geteilter Notizen | 30 Tage; die Grenze steht in `documents.log_floor` | +| Log privater Notizen | 7 Tage | +| Grabsteine | 30 Tage nach `deleted_at`, dann endgültig entfernt per Log-Eintrag `purge` (offene Editoren übernehmen das ohne Neuladen) | +| Idempotenz-Ergebnisse | 90 Tage | +| Leases und Sitzungen im Speicher | abgelaufene werden entfernt | + +Clients hinter der Grenze laden den Snapshot neu und behalten ihre Warteschlange. Grenzen dieses Vorgehens: [FLAW-27](../flaws.md). + +## Tests + +| Ebene | Was | Wo | +|---|---|---| +| Regeln | Konfliktmatrix aus Lease, Version, Grabstein, Verschieben, Löschen, Purge | `internal/collab/ops_test.go` | +| Simulation | 4 Clients, 25 Seeds × 600 Schritte über ein Netz, das Anfragen und Antworten verliert und verdoppelt; Clients gehen offline; zwischendurch Verdichtung. Geprüft: Konvergenz, gültiger Baum, jede Operation genau einmal angewendet, kein Text verloren. Per Gegenprobe (absichtlich eingebaute Fehler) abgesichert. | `internal/storage/docsync_sim_test.go` | +| Speicher und API | Log ↔ Snapshot, Rollen, Long-Polling, Verdichtung | `internal/storage/docsync_test.go`, `internal/api/docsync_test.go` | +| Browser-Engine | Vitest mit gefälschtem Server und Netz, `fake-indexeddb`: offline, Neuladen, verlorene Antworten, Push-Wechsel, Verdichtung | `frontend/src/lib/docSync/__tests__/` | +| Ende-zu-Ende | Playwright mit zwei Benutzern: gemeinsames Tippen, Freigabe, Sperren, Konflikte, Undo, alle Transport-Stufen mit blockiertem WebSocket/SSE | `e2e/` (`e2e/run.sh`) | diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 857c1ee..c19a5c2 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -1,10 +1,10 @@ # Modul: Workspace (Outliner-Notizen) -Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockItem.tsx](../../frontend/src/features/workspace/CodeBlockItem.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, `Store.PatchDocumentBlocks`. +Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockItem.tsx](../../frontend/src/features/workspace/CodeBlockItem.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, Sync-Handler in `internal/api/docsync.go`, `internal/collab`, `internal/storage/docsync.go`. ## Datenhaltung -- Eine Notiz ist ein Datensatz in `documents` plus geordnete Zeilen in `blocks` (`position` als Kommazahl, `level` für die Einrückung). Alles liegt in SQLite; es gibt keine Dateien auf der Platte. +- Eine Notiz ist ein Datensatz in `documents` plus ein Baum von Blöcken in `blocks` (`parent_id`, Sortierschlüssel `order_key`). Jede Änderung läuft über `internal/collab` und wird im Log `doc_ops` festgehalten. Ebene und Position für die Oberfläche berechnet der Server beim Lesen. Das Log und gelöschte Blöcke werden regelmäßig verdichtet ([database.md](../database.md#verdichtung-des-notiz-logs)). Alles liegt in SQLite; es gibt keine Dateien auf der Platte. - Bei jeder Änderung wird `documents.markdown_cache` neu erzeugt; `block_tags` wird aus `#Hashtags` der Blöcke neu aufgebaut. - Der Export als `.md` erfolgt im Browser (Button im Editor, Blob-Download). - Blocktypen: Überschriften `h1`–`h3`, `paragraph`/`text`, `bullet`, `todo`, `code`, `callout`, `divider`, `table`, `progress`. @@ -17,7 +17,20 @@ 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**, nach Verfügbarkeit: + 1. **Push** über WebSocket oder SSE: Der Server meldet `DOC_SEQ`, die Engine gleicht sofort ab (im Test 0,2–0,3 s). + 2. **Long-Polling** (`/docs/{id}/changes`), wenn ein Proxy WebSocket und SSE blockiert oder SSE puffert: ebenso schnell. + 3. **Abfrage alle 3 s**, wenn auch Long-Polling dreimal scheitert (bei `online` wird Long-Polling erneut versucht). + + Solange 1. oder 2. läuft, gleicht die Engine zusätzlich alle 7 s ab, wenn sie Sperren hält (Verlängerung), sonst alle 30 s zur Sicherheit. Ein Fokuswechsel meldet die Sperre sofort an. Der Editor zeigt den Weg an („Live“, „Live (Long-Polling)“, „Abgleich alle 3 s“). +- **Private Notizen**, die nur einmal geöffnet sind, gleichen alle 30 s ab, außerdem sofort bei Rückkehr ins Netz oder in den Tab. Beim Verlassen sendet die Engine 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. Bullets, die jemand anderes hält, zeigen ein Schloss mit Namen und sind schreibgeschützt; Enter darin legt einen neuen Bullet darunter an. Löschen, Zusammenführen, Ein-/Ausrücken und Ziehen werden bei gesperrten Bullets nicht ausgeführt (Hinweis unten im Editor). +- **Präsenz:** Im Kopf der Notiz steht, wer sie gerade sonst noch geöffnet hat. +- **Konfliktkopien** sind farbig markiert („Konflikt – Version von …“) mit „Übernehmen“ (Text ersetzt den ursprünglichen Punkt bzw. den Titel, die Kopie verschwindet) und „Verwerfen“. +- **Rückgängig:** `Strg+Z`/`Cmd+Z` nimmt die eigene letzte Änderung zurück, `Strg+Umschalt+Z` oder `Strg+Y` wiederholt (auch im Titel und über das Kontextmenü/mobile Tastaturen). Tippen im selben Punkt bis 1 s Pause ist ein Schritt; bis zu 200 Schritte je Tab. Hat jemand anderes den Punkt inzwischen geändert oder hält ihn gesperrt, wird der Schritt mit Hinweis übersprungen. +- `PUT /documents/{id}` ändert nur noch Symbol, Anheften und Ordner (Titel wird ins Log übernommen); Inhalte laufen ausschließlich über die Engine. Das To-Do-Rollup hakt per einzelner Operation über denselben Sync-Endpunkt ab. ## Explorer, Tags, To-Dos @@ -30,7 +43,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md), [FLAW-17](../flaws.md)). - Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt. -- Änderungen erreichen offene Fenster über `DOC_BLOCKS_PATCHED`/`DOC_UPDATED` ([websocket.md](../api/websocket.md)). +- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)). ## Grenzen diff --git a/Doku/reverse-proxy.md b/Doku/reverse-proxy.md index ebf6a6d..864449c 100644 --- a/Doku/reverse-proxy.md +++ b/Doku/reverse-proxy.md @@ -193,6 +193,10 @@ VirtualHost-Konfiguration (`/etc/apache2/sites-available/mindsift.conf`): --- +## Wenn der Proxy nicht angepasst werden kann + +MindSift funktioniert auch hinter Proxys, die WebSocket blocken, Streams puffern oder nur kurze Anfragen erlauben. Die Notiz-Synchronisation wählt automatisch den besten verfügbaren Weg (WebSocket → SSE → Long-Polling → Abfrage alle 3 s); der Editor zeigt ihn an. Long-Polling-Anfragen dauern höchstens 25 s und bleiben damit unter üblichen Proxy-Grenzen. Details: [api/websocket.md](api/websocket.md). + ## 🔍 Diagnose & Verifikation Mit den folgenden Befehlen lässt sich die Erreichbarkeit und Streaming-Funktion prüfen: diff --git a/Doku/sharing-and-permissions.md b/Doku/sharing-and-permissions.md index 426c175..157f658 100644 --- a/Doku/sharing-and-permissions.md +++ b/Doku/sharing-and-permissions.md @@ -14,10 +14,10 @@ |---|---|---|---|---| | **Aufgabenliste** (`task_list_shares.permission`) | alles, löschen, (un)teilen | Aufgaben anlegen/ändern/abhaken/verschieben/löschen, Liste umbenennen, Spalten ändern | nur ansehen | Benutzer, Gruppen | | **Geteilte Liste** (`list_shares.role`) | alles, löschen, (un)teilen | Einträge anlegen/ändern/abhaken/löschen | nur ansehen | Benutzer, Gruppen | -| **Notiz** (`doc_shares`, `doc_team_shares`) | schreiben | – | nur lesen | Benutzer, Gruppen | -| **Geteilter Ordner** (`workspace_folder_shares`) | schreiben | – | nur lesen | Benutzer, Gruppen | +| **Notiz** (`doc_shares`, `doc_team_shares`) | alles, Freigaben, löschen | Inhalt, Struktur, Titel | nur lesen | Benutzer, Gruppen | +| **Geteilter Ordner** (`workspace_folder_shares`) | alles | Notizen darin bearbeiten | nur lesen | Benutzer, Gruppen | -- **Notizen und Ordner sind absichtlich schreibgeschützt für Empfänger.** Das erzwingt eine Migration beim Start (`permission = 'read'`); Schreibpfade im Code sind tot ([FLAW-17](flaws.md)). +- **Notizen und Ordner:** Die effektive Rolle berechnet `Store.DocRole` (Eigentümer, Eigentümer des geteilten Ordners = Bearbeiter, direkte Freigabe, Gruppenfreigabe, Ordnerfreigabe; höchste gewinnt). Im Freigabe-Dialog sind für Notizen und geteilte Ordner „Bearbeiten“ und „Nur Lesen“ wählbar (gespeichert als `write`/`read`). Bearbeiter ändern Inhalt, Struktur und Titel über die Sync-Engine; Leser sehen Änderungen live. - Bei Aufgabenlisten und Listen gilt: Hat jemand mehrere Zugriffswege (direkt und über Gruppen), zählt die **höchste** Stufe. - Für Gruppen gilt die Freigabe für alle aktuellen und künftigen Mitglieder. - Aufgabenlisten besitzen zusätzlich das Altfeld `team_id`: Mitglieder dieser Gruppe haben ohne Freigabezeile Bearbeiterrechte. Eine Gruppenfreigabe über den Dialog setzt `team_id`, falls es noch leer ist. diff --git a/README.md b/README.md index 026fd25..dabf877 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) +[![Version](https://img.shields.io/badge/Version-v0.2-16a34a?style=flat-square)](#-schnellstart) -**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. +**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.

MindSift Triage Desktop @@ -61,8 +62,17 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn * Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`. * Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen. * Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`. -* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt. -* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen **zum Lesen** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein. +* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt; `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her. +* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen als **Bearbeiter** oder **Leser** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein. + +#### 🤝 Gemeinsam bearbeiten + +* **Live:** Mehrere Personen schreiben gleichzeitig in derselben Notiz; Änderungen erscheinen bei den anderen in Bruchteilen einer Sekunde. Der Kopf der Notiz zeigt, wer gerade mitliest. +* **Kein Überschreiben:** Wer in einem Bullet schreibt, hält ihn kurz für sich; andere sehen ein Schloss mit Namen und arbeiten in den übrigen Bullets weiter. Kommt es doch zu einem Konflikt (z. B. nach Offline-Arbeit), bleibt der zweite Text als markierte Kopie mit „Übernehmen“/„Verwerfen“ erhalten. +* **Offline-fest:** Jede Eingabe wird zuerst im Browser gesichert und später übertragen, auch nach Neuladen oder Tagen ohne Netz. +* **Hinter strengen Proxys:** WebSocket → SSE → Long-Polling → Abfrage alle 3 s; MindSift wählt automatisch, was durchkommt. Der aktive Weg steht im Editor („Live“, „Abgleich alle 3 s“). + +Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md).

MindSift Workspace Outliner @@ -118,7 +128,7 @@ Minimal: ```yaml services: mindsift: - image: git.dsit.me/sandrodach/mindsift:dev + image: git.dsit.me/sandrodach/mindsift:v0.2 # feste Version; :dev = neuester Entwicklungsstand restart: unless-stopped ports: ["8080:8080"] volumes: ["./data:/data"] @@ -146,11 +156,12 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) | -| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin`, `user/user` an. **Nie auf öffentlichen Servern!** | +| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** | -* **Reverse-Proxy:** WebSocket-Upgrade und ungepufferte SSE-Streams müssen durchgereicht werden. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md). +* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md). * **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer. -* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. +* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen. +* **Image-Tags:** Jeder Git-Tag erzeugt ein gleichnamiges Image (`:v0.2`), jeder Branch eines mit dem Branch-Namen (`:dev`). --- @@ -169,6 +180,9 @@ 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 +APP_VERSION=v0.2 e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten) ``` 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. @@ -179,7 +193,7 @@ Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:em ## 📚 Dokumentation -Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank, REST- und Echtzeit-API, Module, Freigaben und Berechtigungen, Themes. Bekannte Schwachstellen und offene Punkte stehen in [Doku/flaws.md](Doku/flaws.md). +Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank, REST- und Echtzeit-API, Module (inkl. gemeinsamem Bearbeiten), Freigaben und Berechtigungen, Reverse-Proxy, Themes. Bekannte Schwachstellen und offene Punkte stehen in [Doku/flaws.md](Doku/flaws.md). --- diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..28a892f --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,43 @@ +# 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 + +## share.e2e.mjs + +Der Eigentümer teilt eine Notiz über den Freigabe-Dialog mit Rolle „Bearbeiten“; geprüft wird, dass die Freigabe als `write` gespeichert, im Dialog als „Bearbeiter“ angezeigt wird und der Empfänger die Notiz bearbeiten kann. Zum Schluss hakt der Eigentümer ein To-Do in der To-Do-Übersicht ab. + +## lock.e2e.mjs + +Sperren, Präsenz, Rückgängig und Konfliktkopien mit zwei Browsern: Schloss und Schreibschutz am fremd bearbeiteten Bullet, Hinweis statt halb ausgeführter Aktion, `Strg+Z`/`Strg+Umschalt+Z`, Tippen direkt nach Enter, Markierung von Konfliktkopien sowie „Übernehmen“ (verweigert bei fremder Sperre, erfolgreich für den Sperrinhaber). + +## channels.e2e.mjs + +Der Eigentümer tippt mit normaler Verbindung, der Bearbeiter sitzt hinter einem simulierten Proxy. Für jede Situation wird der gewählte Weg und die Zeit bis zur Anzeige gemessen: nichts blockiert (WebSocket), WebSocket abgelehnt (SSE), WebSocket angenommen aber stumm (SSE nach Herzschlag-Ausfall), WebSocket und SSE blockiert sowie SSE hängend (Long-Polling), nur normale Anfragen (Abfrage alle 3 s). + +`collab.e2e.mjs`, `lock.e2e.mjs` und `channels.e2e.mjs` dauern je etwa eine Minute. Die Tests laufen nicht in der CI, weil sie Browser-Images brauchen. + +## Screenshots + +`e2e/screenshots.sh` baut das Image, startet es im Demo-Modus mit frischer Datenbank und erzeugt die Bilder in `screenshots/` neu (`screenshots.mjs`). Für die Versionsangabe im Kopf: `APP_VERSION=v0.2 e2e/screenshots.sh`. In der Workspace-Aufnahme bearbeitet der Demo-Benutzer `user` gleichzeitig einen Bullet, damit Sperre und Präsenz zu sehen sind. diff --git a/e2e/channels.e2e.mjs b/e2e/channels.e2e.mjs new file mode 100644 index 0000000..b1f0ae0 --- /dev/null +++ b/e2e/channels.e2e.mjs @@ -0,0 +1,119 @@ +// How fast changes arrive behind different (simulated) proxies. The admin edits with a normal connection; +// the editor's browser blocks WebSocket, SSE and/or long polling. Needs a server in demo mode. +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) { + const end = Date.now() + ms; + while (Date.now() < end) { + try { + const v = await fn(); + if (v) return v; + } catch {} + await sleep(100); + } + return null; +} + +const editorValues = (page) => + page.$$eval('textarea', (els) => els.filter((e) => !e.placeholder?.startsWith('Titel')).map((e) => e.value)); +const channelOf = (page) => page.getAttribute('[data-sync-channel]', 'data-sync-channel'); + +async function openDoc(browser, token, title, block = {}) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + // A blocking proxy refuses the connection (policy violation); a clean close would just mean "reconnect" + if (block.ws === 'reject') await ctx.routeWebSocket(/\/api\/v1\/ws/, (ws) => ws.close({ code: 1008, reason: 'blocked by proxy' })); + if (block.ws === 'silent') await ctx.routeWebSocket(/\/api\/v1\/ws/, () => {}); // accepted, nothing forwarded + if (block.sse === 'abort') await ctx.route('**/api/v1/events**', (r) => r.abort()); + if (block.sse === 'hang') await ctx.route('**/api/v1/events**', () => {}); // never answered + if (block.longpoll) await ctx.route('**/changes**', (r) => r.abort()); + await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token); + const page = await ctx.newPage(); + 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 }; +} + +async function typeAtEnd(page, text) { + await page.bringToFront(); + // Place the cursor via script: Playwright's actionability checks wait for animation frames, which + // pages in the background do not get while the other browser window is in front + const ok = await page.evaluate(() => { + const el = Array.from(document.querySelectorAll('textarea')).find((e) => e.value.startsWith('Live-Test')); + if (!el) return false; + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); + return true; + }); + if (!ok) throw new Error('Live-Test field not found'); + await page.keyboard.type(text); +} + +const TITLE = `Kanal-Test ${Date.now()}`; + +const MODES = [ + { name: 'nichts blockiert', block: {}, channel: 'ws', maxMs: 2500 }, + { name: 'WebSocket-Handshake abgelehnt', block: { ws: 'reject' }, channel: 'sse', maxMs: 2500 }, + { name: 'WebSocket angenommen, leitet nichts weiter', block: { ws: 'silent' }, channel: 'sse', maxMs: 2500, switchMs: 60000 }, + { name: 'WebSocket und SSE blockiert', block: { ws: 'reject', sse: 'abort' }, channel: 'longpoll', maxMs: 2500 }, + { name: 'SSE-Verbindung hängt', block: { ws: 'reject', sse: 'hang' }, channel: 'longpoll', maxMs: 2500 }, + { name: 'nur normale Anfragen möglich', block: { ws: 'reject', sse: 'abort', longpoll: true }, channel: 'interval', maxMs: 6000, switchMs: 15000 }, +]; + +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: `ch_${Date.now()}`, type: 'bullet', content: 'Live-Test', 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); + + for (const [i, mode] of MODES.entries()) { + const B = await openDoc(browser, user, TITLE, mode.block); + const channel = await waitFor(async () => ((await channelOf(B.page)) === mode.channel ? mode.channel : null), mode.switchMs || 10000); + check(channel === mode.channel, `${mode.name}: Weg ${(await channelOf(B.page)) || '?'} (erwartet ${mode.channel})`); + + const marker = ` r${i}`; + const start = Date.now(); + await typeAtEnd(A.page, marker); + const arrived = await waitFor(async () => (await editorValues(B.page)).some((v) => v.endsWith(marker)), mode.maxMs + 4000); + const ms = Date.now() - start; + check(!!arrived && ms <= mode.maxMs, `${mode.name}: Änderung kam nach ${arrived ? ms : '–'} ms an (Ziel ≤ ${mode.maxMs} ms)`); + await B.ctx.close(); + } + + 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/collab.e2e.mjs b/e2e/collab.e2e.mjs new file mode 100644 index 0000000..577b7b3 --- /dev/null +++ b/e2e/collab.e2e.mjs @@ -0,0 +1,178 @@ +// 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 sleep(3500); // the admin's browser learns about it with its next sync (every 3 s in shared notes) + 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/lock.e2e.mjs b/e2e/lock.e2e.mjs new file mode 100644 index 0000000..d1d9d47 --- /dev/null +++ b/e2e/lock.e2e.mjs @@ -0,0 +1,170 @@ +// Locks, presence, undo and conflict copies with two real browsers. 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) { + const end = Date.now() + ms; + while (Date.now() < end) { + try { + const v = await fn(); + if (v) return v; + } catch {} + await sleep(250); + } + return null; +} + +const serverBlocks = async (token, docId) => + (await api(token, 'GET', `/api/v1/docs/${docId}/snapshot`)).blocks.filter((b) => !b.deleted); +const editorValues = (page) => + page.$$eval('textarea', (els) => els.filter((e) => !e.placeholder?.startsWith('Titel')).map((e) => e.value)); + +// Puts the cursor at the end of the text field showing `text` (works whether or not it is focused already) +async function focusField(page, text) { + const handle = await page.waitForFunction( + (t) => Array.from(document.querySelectorAll('textarea')).find((e) => e.value === t) || null, + text, + { timeout: 5000, polling: 100 } // rAF polling stalls in background pages + ); + const el = handle.asElement(); + await el.click(); + await page.keyboard.press('End'); +} + +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(); + 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 = `Sperr-Test ${Date.now()}`; +const id = (n) => `lk_${n}_${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 ids = { a: id('a'), b: id('b') }; + const doc = await api(admin, 'POST', '/api/v1/documents', { + title: TITLE, + blocks: [ + { id: ids.a, type: 'bullet', content: 'Admin schreibt hier', level: 0 }, + { id: ids.b, type: 'bullet', content: 'Darunter', 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); + + // Presence + const presence = await waitFor(async () => B.page.getByTitle('Hat diese Notiz gerade geöffnet').innerText(), 8000); + check(!!presence && /Admin/i.test(presence), `Bearbeiter sieht, wer noch in der Notiz ist: ${presence}`); + + // Admin clicks into the first bullet -> user sees the lock + await A.page.getByText('Admin schreibt hier', { exact: true }).first().click(); + const lockBadge = await waitFor(async () => (await B.page.getByTitle(/bearbeitet diesen Punkt gerade/).count()) > 0, 10000); + check(!!lockBadge, 'Bearbeiter sieht das Schloss am gesperrten Bullet'); + const readOnly = await B.page.$$eval('textarea', (els) => els.find((e) => e.value === 'Admin schreibt hier')?.readOnly); + check(readOnly === true, 'Gesperrter Bullet ist beim Bearbeiter schreibgeschützt'); + + // User tries to merge "Darunter" into the locked bullet + await B.page.getByText('Darunter', { exact: true }).first().click(); + await B.page.keyboard.press('Home'); + await B.page.keyboard.press('Backspace'); + const notice = await waitFor(async () => B.page.getByRole('status').innerText(), 3000); + check(!!notice && /bearbeitet diesen Punkt gerade/.test(notice), `Hinweis statt halber Aktion: ${notice}`); + await sleep(1500); + const afterMerge = (await serverBlocks(admin, doc.id)).map((b) => b.content).sort(); + check(JSON.stringify(afterMerge) === JSON.stringify(['Admin schreibt hier', 'Darunter']), `Nichts wurde teilweise ausgeführt: ${JSON.stringify(afterMerge)}`); + + // Undo on the admin side + await focusField(A.page, 'Admin schreibt hier'); + await A.page.keyboard.type(' und noch mehr'); + await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier und noch mehr'), 5000); + await A.page.keyboard.press('Control+z'); + const undone = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier'), 5000); + check(!!undone, 'Strg+Z nimmt den getippten Text zurück (auch auf dem Server)'); + const fieldUndone = await waitFor(async () => (await editorValues(A.page)).includes('Admin schreibt hier'), 3000); + check(!!fieldUndone, 'Das fokussierte Feld zeigt den zurückgenommenen Text'); + await A.page.keyboard.press('Control+Shift+z'); + const redone = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier und noch mehr'), 5000); + check(!!redone, 'Strg+Umschalt+Z stellt ihn wieder her'); + + // Typing right after Enter lands in the new bullet (no pause) + await A.page.keyboard.press('End'); + await A.page.keyboard.press('Enter'); + await A.page.keyboard.type('Direkt nach Enter'); + const newBullet = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Direkt nach Enter'), 5000); + check(!!newBullet, 'Sofort nach Enter Getipptes landet im neuen Bullet'); + + // Conflict copy: two writes on the same base version via the API + const before = (await serverBlocks(admin, doc.id)).find((b) => b.id === ids.b); + await api(admin, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'api-1', lastSeq: 0, ops: [{ opId: `${ids.b}-x`, kind: 'set_content', blockId: ids.b, baseVersion: before.version, content: 'Gewinner-Text' }] }); + await api(user, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'api-2', lastSeq: 0, ops: [{ opId: `${ids.b}-y`, kind: 'set_content', blockId: ids.b, baseVersion: before.version, content: 'Verlierer-Text' }] }); + const conflictShown = await waitFor(async () => (await A.page.getByText(/Version von/).count()) > 0, 8000); + check(!!conflictShown, 'Konfliktkopie wird markiert angezeigt'); + // The user's cursor is still in the original bullet (it holds the lock), so the admin may not overwrite it + await A.page.getByRole('button', { name: 'Übernehmen' }).first().click(); + const refused = await waitFor(async () => A.page.getByRole('status').innerText(), 3000); + check(!!refused && /bearbeitet/.test(refused), `Übernehmen in einen fremd gesperrten Punkt wird verweigert: ${refused}`); + // The user holding the original can accept once the copy's author (here a separate API client that never + // renews its lease) no longer holds the copy, i.e. after at most 20 s + await waitFor(async () => (await B.page.getByText(/Version von/).count()) > 0, 8000); + const copyId = (await serverBlocks(admin, doc.id)).find((b) => b.props?.conflictOf)?.id; + const copyFree = await waitFor(async () => { + const probe = await api(admin, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'probe', lastSeq: 0 }); + return !probe.leases.others.some((l) => l.blockId === copyId); + }, 25000); + check(!!copyFree, 'Sperre der Konfliktkopie läuft ab'); + await sleep(3500); // the user's browser learns about it with its next sync + // Both API writes became conflict copies (the user's cursor locked the original); accept the first one + const copiesBefore = (await serverBlocks(admin, doc.id)).filter((b) => b.props?.conflictOf === ids.b); + const firstCopyText = await B.page.$$eval( + 'textarea', + (els, texts) => els.map((e) => e.value).find((v) => texts.includes(v)), + copiesBefore.map((c) => c.content) + ); + await B.page.getByRole('button', { name: 'Übernehmen' }).first().click(); + const accepted = await waitFor(async () => { + const blocks = await serverBlocks(admin, doc.id); + const copiesNow = blocks.filter((b) => b.props?.conflictOf === ids.b); + return blocks.find((b) => b.id === ids.b)?.content === firstCopyText && copiesNow.length === copiesBefore.length - 1; + }, 6000); + check(!!accepted, `„Übernehmen“ ersetzt den ursprünglichen Text durch „${firstCopyText}“ und entfernt diese Kopie`); + + 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..5152375 --- /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 && node share.e2e.mjs && node lock.e2e.mjs && node channels.e2e.mjs' diff --git a/e2e/screenshots.mjs b/e2e/screenshots.mjs new file mode 100644 index 0000000..d8da61d --- /dev/null +++ b/e2e/screenshots.mjs @@ -0,0 +1,84 @@ +// Takes the README screenshots from a fresh server in demo mode (sample data of admin/admin). +// Usage: e2e/screenshots.sh (builds the image) or MINDSIFT_URL=… SHOTS_DIR=… node screenshots.mjs +import { chromium } from 'playwright'; + +const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080'; +const OUT = process.env.SHOTS_DIR || '../screenshots'; +const DESKTOP = { viewport: { width: 1518, height: 886 }, deviceScaleFactor: 2 }; +const MOBILE = { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function login(user) { + const res = await fetch(`${BASE}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ login: user, password: user }), + }); + if (!res.ok) throw new Error(`login ${user}: ${res.status}`); + return (await res.json()).token; +} + +async function open(browser, token, options) { + const ctx = await browser.newContext({ ...options, colorScheme: 'dark', locale: 'de-DE' }); + 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.waitForLoadState('networkidle'); + return page; +} + +const tab = (page, name) => page.getByRole('button', { name: new RegExp(name) }).first().click(); + +async function shot(page, file) { + await sleep(800); // animations + await page.mouse.move(0, 0); + await page.screenshot({ path: `${OUT}/${file}` }); + console.log(`OK ${file}`); +} + +const main = async () => { + const admin = await login('admin'); + const user = await login('user'); + const browser = await chromium.launch({ args: ['--lang=de-DE'] }); + + const page = await open(browser, admin, DESKTOP); + await tab(page, 'Triage'); + await shot(page, 'Triage_desktop.png'); + + await tab(page, 'Aufgaben'); + await page.getByText('Homelab', { exact: true }).first().click().catch(() => {}); + await page.getByRole('button', { name: /Kanban|Board/ }).first().click().catch(() => {}); + await shot(page, 'aufgaben_desktop.png'); + + // The demo user edits a bullet of the shared note, so the screenshot shows lock and presence + const other = await open(browser, user, DESKTOP); + await tab(other, 'Workspace'); + await other.getByText(/^Homelab 2\.0/).first().click(); + await other.waitForFunction(() => document.querySelectorAll('textarea').length > 5, null, { timeout: 10000 }); + await other.getByText('Endlich Platz für die Dienste', { exact: false }).first().click(); + await other.keyboard.press('End'); + await other.keyboard.type(': Paperless und Immich', { delay: 20 }); + + await tab(page, 'Workspace'); + await page.getByText(/^Homelab 2\.0/).first().click(); + await page.waitForFunction(() => document.querySelectorAll('textarea').length > 5, null, { timeout: 10000 }); + await page.getByText('User', { exact: false }).first().waitFor({ timeout: 15000 }).catch(() => {}); + await sleep(2000); + await shot(page, 'workspace_desktop.png'); + + await tab(page, 'Listen'); + await page.getByText('Wocheneinkauf', { exact: true }).first().click().catch(() => {}); + await shot(page, 'Listen_desktop.png'); + + const mobile = await open(browser, admin, MOBILE); + await mobile.getByText('Später im Browser weiter nutzen').click({ timeout: 5000 }).catch(() => {}); // install prompt + await shot(mobile, 'Mobile_Triage.png'); + + await browser.close(); +}; + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/e2e/screenshots.sh b/e2e/screenshots.sh new file mode 100755 index 0000000..040553e --- /dev/null +++ b/e2e/screenshots.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Builds MindSift, starts it in demo mode with a fresh database and renews the README screenshots +# in screenshots/. Requires podman (or docker via ENGINE=docker). Usage: e2e/screenshots.sh +set -euo pipefail +ENGINE="${ENGINE:-podman}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$(mktemp -d)" +trap '"$ENGINE" rm -f mindsift-shots >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT + +echo "Baue Image …" +# Version in the header, e.g. APP_VERSION=v0.2 e2e/screenshots.sh +"$ENGINE" build -q --build-arg APP_VERSION="${APP_VERSION:-}" -t mindsift-e2e-image "$ROOT" >/dev/null +"$ENGINE" run -d --name mindsift-shots --network host -e PORT=18081 -e MINDA_DEMO=true \ + -e JWT_SECRET=screenshots-only-secret -v "$WORK":/data:Z mindsift-e2e-image >/dev/null +for _ in $(seq 1 60); do curl -s -m 1 http://localhost:18081/api/v1/health >/dev/null && break; sleep 0.5; done + +echo "Erstelle Screenshots …" +"$ENGINE" run --rm --network host -e MINDSIFT_URL=http://localhost:18081 -e SHOTS_DIR=/shots -e LANG=de_DE.UTF-8 \ + -v "$ROOT/e2e":/e2e:Z -v "$ROOT/screenshots":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \ + sh -c 'apt-get update -qq >/dev/null && apt-get install -y -qq fonts-inter >/dev/null 2>&1; npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs' diff --git a/e2e/share.e2e.mjs b/e2e/share.e2e.mjs new file mode 100644 index 0000000..24c67c0 --- /dev/null +++ b/e2e/share.e2e.mjs @@ -0,0 +1,125 @@ +// Shares a note as editor through the share dialog and checks that the editor can then edit it. +// 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) { + const end = Date.now() + ms; + while (Date.now() < end) { + try { + const v = await fn(); + if (v) return v; + } catch {} + await sleep(250); + } + return null; +} + +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(); + 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 = `Freigabe-Test ${Date.now()}`; +const TODO_ID = `fr_todo_${Date.now()}`; +const TODO_TEXT = `Rollup-Aufgabe ${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 doc = await api(admin, 'POST', '/api/v1/documents', { + title: TITLE, + blocks: [ + { id: `fr_${Date.now()}`, type: 'bullet', content: 'Vom Eigentümer', level: 0 }, + { id: TODO_ID, type: 'todo', content: TODO_TEXT, level: 0 }, + ], + }); + + const browser = await chromium.launch(); + const A = await openDoc(browser, admin, TITLE); + + // Share as editor through the dialog + await A.page.getByTitle('Notiz teilen und Berechtigungen').click(); + await A.page.getByRole('button', { name: 'Benutzer', exact: true }).click(); + const search = A.page.getByPlaceholder(/Benutzer suchen/); + await search.click(); + await search.type('user'); + await sleep(300); + await search.press('ArrowDown'); + await search.press('Enter'); + const roleSelect = A.page.locator('select').filter({ has: A.page.locator('option[value="editor"]') }).last(); + check((await roleSelect.isEnabled()) && (await roleSelect.isVisible()), 'Rollenauswahl ist im Dialog verfügbar'); + await roleSelect.selectOption('editor'); + await A.page.getByRole('button', { name: /Benutzer hinzufügen/ }).click(); + const shares = await waitFor(async () => { + const list = await api(admin, 'GET', `/api/v1/documents/${doc.id}/shares`); + return list.length > 0 ? list : null; + }, 5000); + check(shares && shares[0].permission === 'write', `Freigabe gespeichert als ${shares?.[0]?.permission}`); + const badge = await A.page + .getByText('Bearbeiter', { exact: true }) + .first() + .waitFor({ state: 'visible', timeout: 5000 }) + .then(() => true) + .catch(() => false); + check(badge, 'Dialog zeigt die Rolle „Bearbeiter“'); + + // The editor can edit + const snap = await api(user, 'GET', `/api/v1/docs/${doc.id}/snapshot`); + check(snap.role === 'editor', `Empfänger hat die Rolle ${snap.role}`); + const B = await openDoc(browser, user, TITLE); + check(!(await B.page.getByText('schreibgeschützt mit dir geteilt', { exact: false }).first().isVisible().catch(() => false)), 'Kein Nur-Lesen-Hinweis beim Bearbeiter'); + await B.page.getByText('Vom Eigentümer', { exact: true }).first().click(); + await B.page.keyboard.press('End'); + await B.page.keyboard.type(' und vom Bearbeiter'); + const saved = await waitFor(async () => { + const s = await api(admin, 'GET', `/api/v1/docs/${doc.id}/snapshot`); + return s.blocks.some((b) => b.content === 'Vom Eigentümer und vom Bearbeiter'); + }, 6000); + check(!!saved, 'Bearbeiter kann die geteilte Notiz bearbeiten'); + + // Ticking a to-do in the overview goes through the sync endpoint as well + await A.page.bringToFront(); + await A.page.getByRole('button', { name: 'Schließen', exact: true }).last().click(); // the share dialog is still open + await A.page.locator('button:visible', { hasText: 'To-Dos' }).first().click(); + await A.page.getByTitle('Alle To-Dos als einfache Liste').click(); + await A.page.locator('div', { hasText: TODO_TEXT }).last().locator('button').first().click(); + const ticked = await waitFor(async () => { + const s = await api(admin, 'GET', `/api/v1/docs/${doc.id}/snapshot`); + return s.blocks.find((b) => b.id === TODO_ID)?.props?.checked === true; + }, 5000); + check(!!ticked, 'Abhaken in der To-Do-Übersicht wird gespeichert'); + + 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/frontend/package-lock.json b/frontend/package-lock.json index 695fc5d..4154551 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "1.0.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "1.0.0", + "version": "0.2.0", "dependencies": { "@types/prismjs": "^1.26.6", "framer-motion": "^11.15.0", @@ -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..935837f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "1.0.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", @@ -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/App.tsx b/frontend/src/App.tsx index 0a2d73f..e503801 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,6 +52,7 @@ import { GroupsView } from './features/teams/GroupsView'; import { useMobileActions } from './context/MobileActionContext'; import { MobileHeaderMenu } from './components/MobileHeaderMenu'; import { UserAvatar } from './components/UserAvatar'; +import { APP_VERSION } from './version'; type ActiveTab = | 'triage' @@ -648,7 +649,7 @@ export const App: React.FC = () => { {authStatus.instanceName || 'MindSift'} - v0.1 + {APP_VERSION} {activeTabLabelMap[activeTab]} @@ -713,7 +714,7 @@ export const App: React.FC = () => { {authStatus.instanceName || 'MindSift'} - v0.1 + {APP_VERSION} diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 5dd7628..2ee010e 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -220,6 +220,9 @@ export const TasksView: React.FC = ({ } } else if (msg.event === 'TASK_REORDERED') { loadData(); + } else if (msg.event === 'TASK_LIST_UPDATED' || msg.event === 'TASK_LIST_DELETED') { + // Payload only carries the list ID; reload so permissions reflect this user's view + api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {}); } }; diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 8b8c1b7..71fc774 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,104 @@ 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]); + + // Blocks other users are editing right now (blockId -> name) + const lockedBlocks = useMemo(() => { + const m = new Map(); + for (const l of engineState?.lockedByOthers || []) m.set(l.blockId, l.name || 'Jemand'); + return m; + }, [engineState?.lockedByOthers]); + + const otherPeople = useMemo( + () => (engineState?.presence || []).filter((p) => p.userId !== currentUser?.id), + [engineState?.presence, currentUser?.id] + ); + + // Short hint at the bottom of the editor (blocked actions, skipped undo) + const [notice, setNotice] = useState(null); + const noticeTimerRef = useRef(null); + const showNotice = useCallback((message: string) => { + setNotice(message); + if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); + noticeTimerRef.current = window.setTimeout(() => setNotice(null), 3500); + }, []); + useEffect(() => () => { + if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); + }, []); + + const handleLockedAction = useCallback( + (holder: string) => showNotice(`${holder} bearbeitet diesen Punkt gerade – bitte kurz warten`), + [showNotice] + ); + const handleUndo = useCallback(() => { + const res = engineRef.current?.undo(); + if (res?.message) showNotice(res.message); + }, [showNotice]); + const handleRedo = useCallback(() => { + const res = engineRef.current?.redo(); + if (res?.message) showNotice(res.message); + }, [showNotice]); + const handleResolveConflict = useCallback( + (blockId: string, accept: boolean) => { + const res = engineRef.current?.resolveConflict(blockId, accept); + if (res?.message) showNotice(res.message); + }, + [showNotice] + ); + + // 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 +479,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 +501,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 +522,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 +537,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 +605,7 @@ export const DocumentEditor: React.FC = ({ return ensureNonEmptyBlocks(docId, copy); }); }, - [docId, canEdit] + [docId, canEdit, applyLocalBlocks] ); const handleTogglePin = async () => { @@ -714,7 +687,7 @@ export const DocumentEditor: React.FC = ({ } return ( -

+
{/* Top Document Action Bar */}
@@ -793,6 +766,39 @@ export const DocumentEditor: React.FC = ({ {/* Right Action Buttons */}
+ {/* How changes of others arrive (only relevant for shared notes) */} + {engineState && (engineState.shared || engineState.sessions > 1) && ( + + + {engineState.channel === 'longpoll' ? 'Live (Long-Polling)' : engineState.channel === 'interval' ? 'Abgleich alle 3 s' : 'Live'} + + )} + + {/* Who else has this note open */} + {otherPeople.length > 0 && ( + + + {otherPeople.map((p) => p.name).join(', ')} + + )} {/* Sharing Section */} {doc && (() => { const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin'); @@ -977,6 +983,12 @@ export const DocumentEditor: React.FC = ({ onTagClick={onSelectTag} onWikiLinkClick={onWikiLinkClick} initialFocusedBlockId={initialFocusedBlockId} + onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)} + lockedBlocks={lockedBlocks} + onLockedAction={handleLockedAction} + onResolveConflict={handleResolveConflict} + onUndo={handleUndo} + onRedo={handleRedo} /> )}
@@ -1084,6 +1096,12 @@ export const DocumentEditor: React.FC = ({ e.target.style.height = `${e.target.scrollHeight}px`; }} onKeyDown={(e) => { + if ((e.ctrlKey || e.metaKey) && !e.altKey && ['z', 'y'].includes(e.key.toLowerCase())) { + e.preventDefault(); + if (e.key.toLowerCase() === 'y' || e.shiftKey) handleRedo(); + else handleUndo(); + return; + } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); outlinerTreeRef.current?.focusFirstBlock(); @@ -1113,10 +1131,25 @@ export const DocumentEditor: React.FC = ({ onTagClick={onSelectTag} onWikiLinkClick={onWikiLinkClick} initialFocusedBlockId={initialFocusedBlockId} + onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)} + lockedBlocks={lockedBlocks} + onLockedAction={handleLockedAction} + onResolveConflict={handleResolveConflict} + onUndo={handleUndo} + onRedo={handleRedo} /> )}
+ + {notice && ( +
+ {notice} +
+ )}
); }; diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index b0cdc06..9a28d14 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -18,6 +18,8 @@ import { Maximize2, Link as LinkIcon, FileText, + Lock, + AlertTriangle, } from 'lucide-react'; import { DocumentBlock, BlockType } from '../../lib/types'; import { LinkModal } from './LinkModal'; @@ -70,6 +72,11 @@ export interface OutlinerBlockProps { isTargeted?: boolean; registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void; onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void; + lockedBy?: string; // someone else is editing this block + canAppendBelow?: boolean; // the user may edit the note, so Enter in a locked block still adds one below + onResolveConflict?: (blockId: string, accept: boolean) => void; + onUndo?: () => void; + onRedo?: () => void; } // Rendered markdown only drops syntax characters (**, `, [..](url) …), so its text is a subsequence of the raw @@ -112,10 +119,17 @@ export const OutlinerBlock: React.FC = ({ isTargeted, registerTextareaRef, onPasteMultiLines, + lockedBy, + canAppendBelow, + onResolveConflict, + onUndo, + onRedo, }) => { const [localContent, setLocalContent] = useState(block.content || ''); const [isLocallyFocused, setIsLocallyFocused] = useState(false); const activeFocus = isFocused || isLocallyFocused; + // A block someone else is editing can still get the cursor (read-only), e.g. to press Enter at its end + const canFocus = canEdit || (!!lockedBy && !!canAppendBelow); const [slashSearch, setSlashSearch] = useState(null); const [slashIndex, setSlashIndex] = useState(0); const [showLinkModal, setShowLinkModal] = useState(false); @@ -164,6 +178,13 @@ export const OutlinerBlock: React.FC = ({ [] ); + // Right after undo/redo the focused field must show the engine's text, not keep its own + const acceptRemoteUntilRef = useRef(0); + const onUndoRef = useRef(onUndo); + onUndoRef.current = onUndo; + const onRedoRef = useRef(onRedo); + onRedoRef.current = onRedo; + // Sync incoming remote changes ONLY when this textarea is NOT actively being edited // Condition: isFocusedRef.current === true && document.hasFocus() useEffect(() => { @@ -174,7 +195,8 @@ export const OutlinerBlock: React.FC = ({ textareaRef.current !== null && document.activeElement === textareaRef.current; - if (!isActivelyEditing && block.content !== localContentRef.current) { + const acceptAfterUndo = Date.now() < acceptRemoteUntilRef.current; + if ((!isActivelyEditing || acceptAfterUndo) && block.content !== localContentRef.current) { setLocalContent(block.content || ''); localContentRef.current = block.content || ''; } @@ -406,7 +428,44 @@ export const OutlinerBlock: React.FC = ({ onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines); }; + // Ctrl/Cmd+Z undoes the user's own changes in the whole note (not the browser's per-field undo) + const runHistory = (redo: boolean) => { + if (debounceTimerRef.current !== null) { + flushDebounce(localContentRef.current); + } + acceptRemoteUntilRef.current = Date.now() + 500; + if (redo) onRedoRef.current?.(); + else onUndoRef.current?.(); + }; + + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + // Mobile keyboards and the context menu undo via beforeinput instead of key events + const onBeforeInput = (e: InputEvent) => { + if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') { + e.preventDefault(); + runHistory(e.inputType === 'historyRedo'); + } + }; + el.addEventListener('beforeinput', onBeforeInput); + return () => el.removeEventListener('beforeinput', onBeforeInput); + }); + const handleKeyDown = (e: React.KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && !e.altKey && ['z', 'y'].includes(e.key.toLowerCase())) { + e.preventDefault(); + e.stopPropagation(); + runHistory(e.key.toLowerCase() === 'y' || e.shiftKey); + return; + } + // Enter in a block someone else is editing cannot split it (read-only), so it adds a new block below + // without touching the locked one. (Chromium does not move the caret with End in read-only fields.) + if (lockedBy && canAppendBelow && e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + onCreateBlockBelowRef.current(block.id, ''); + return; + } if (!canEdit) return; // Slash command navigation @@ -577,7 +636,7 @@ export const OutlinerBlock: React.FC = ({ : isTargeted ? 'bg-panel-elevated' : '' - }`} + } ${block.conflictOf ? 'border-l-2 border-status-warn bg-status-warn/5' : ''}`} style={{ paddingLeft: `${visualLevel * 22 + 4}px`, }} @@ -585,7 +644,7 @@ export const OutlinerBlock: React.FC = ({ onDrop={() => onDropBlock?.(index)} onClick={(e) => { if ((e.target as HTMLElement).closest('button, a, input, select')) return; - if (!activeFocus && canEdit && textareaRef.current) { + if (!activeFocus && canFocus && textareaRef.current) { textareaRef.current.focus(); const len = textareaRef.current.value.length; textareaRef.current.setSelectionRange(len, len); @@ -728,7 +787,7 @@ export const OutlinerBlock: React.FC = ({ {/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
{ - if (!activeFocus && canEdit && textareaRef.current) { + if (!activeFocus && canFocus && textareaRef.current) { // Keep the row's click handler from moving the caret back to the end e.stopPropagation(); focusAtPoint(e.clientX, e.clientY); @@ -875,6 +934,37 @@ export const OutlinerBlock: React.FC = ({
)} + {/* Conflict copy: text that lost against someone else's change */} + {block.conflictOf && ( +
+ + + {block.conflictOf === 'title' ? 'Konflikt beim Titel' : 'Konflikt'} + {block.conflictAuthor ? ` – Version von ${block.conflictAuthor}` : ''} + + {onResolveConflict && ( + <> + + + + )} +
+ )} + {/* Rich Tokens Bar (Wiki-Links & #Tags) if present */} {hasSpecialTokens && !isFocused && (
@@ -980,6 +1070,17 @@ export const OutlinerBlock: React.FC = ({ + {/* Someone else is editing this block */} + {lockedBy && ( + + + {lockedBy} + + )} + {/* Delete button on hover (Trash2 icon) */} {canEdit && (