Merge pull request 'Dev' (#7) from dev into main

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
sandrodach committed 2026-10-27 11:42:12 +01:00
commit 5db126128e
34 files changed
+7250 -7037

No files matched your search

+3
View File
@@ -5,7 +5,10 @@ DATABASE_URL=sqlite:///data/mindsift.db
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
BASE_URL=http://localhost:8080
ALLOW_REGISTRATION=false
# Logging level: debug, info, warn, error (Default: info)
LOG_LEVEL=info
# LOG_LEVEL=debug
# DEBUG=true
# Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI)
SMTP_HOST=
+63 -38
View File
@@ -66,55 +66,81 @@ Alle WebSocket-Nachrichten werden als JSON mit einheitlicher Hülle übertragen:
---
## ⚡ 4. CRDT-Echtzeitprotokoll (Yjs Collaborative Editing)
## ⚡ 4. Granulare Workspace-Echtzeitsynchronisation (`/api/v1/ws/docs/{id}`)
Seit Version 2.1 unterstützt MindSift **echte, konfliktfreie Mehrbenutzer-Kollaboration** für Notizen via Yjs CRDT. Clients tauschen binäre Delta-Updates und Live-Awareness-Informationen über dedizierte WebSocket-Nachrichten aus:
MindSift nutzt für die Outliner-Synchronisation eine schlanke, hochgradig reaktive In-Memory-Raumarchitektur (`DocRoom` in [`internal/ws/docroom.go`](../../internal/ws/docroom.go)). Clients verbinden sich mit ihrer eindeutigen `clientId`:
### CRDT-Nachrichtentypen
| Event | Richtung | Zweck / Payload |
```
wss://<domain>/api/v1/ws/docs/<docId>?token=<jwt>&clientId=<cli_...>&userName=<Name>&userColor=<Hex>
```
### 4.1 JSON-Echtzeit-Eventprotokoll
Die Kommunikation erfolgt über leichtgewichtige JSON-Nachrichten ohne binären CRDT-Overhead:
| Event-Typ | Richtung | Zweck / Payload |
|---|---|---|
| `CRDT_JOIN` | Client ➔ Server | Tritt dem Dokumentenraum (`doc:{docId}`) bei. |
| `CRDT_INIT` | Server ➔ Client | Sendet den aktuellen Basiszustand (`stateBlob`) und offene Delta-Updates (`updates[]`) an den Client. |
| `CRDT_UPDATE` | Bidirektional | Überträgt inkrementelle binäre Yjs-Updates (Base64-kodiert). Server speichert in `document_crdt_updates` und verteilt an alle anderen Raumnutzer. |
| `CRDT_COMPACT` | Client ➔ Server | Komprimiert mehrere Updates zu einem konsolidierten Snapshot in `document_crdt_state`. |
| `CRDT_AWARENESS` | Bidirektional | Überträgt Live-Präsenz (Fokus auf `blockId`, Benutzername, Farbe). |
| `CRDT_AWARENESS_LEAVE` | Bidirektional | Signalisiert, dass ein Nutzer den Fokus verloren hat oder das Dokument verlassen hat. |
| `CRDT_LEAVE` | Client ➔ Server | Verlässt den Dokumentenraum explizit. |
| `doc_state` | Server ➔ Client | Wird direkt beim Verbinden gesendet: Liefert den aktuellen In-Memory-Zustand (`title`, `blocks[]`, `activeCursors[]`). |
| `block_updated` | Bidirektional | Feingranulare Inhalts- oder Statusänderung eines Blocks (`blockId`, `content`, `level`, `type`, `checked`, `collapsed`). |
| `block_created` | Bidirektional | Erzeugt einen neuen Block (`block`, `afterBlockId`, `newIndex`). |
| `block_deleted` | Bidirektional | Löscht einen Block anhand seiner `blockId`. |
| `block_moved` | Bidirektional | Verschiebt einen Block / Unterbaum (`blockId`, `newIndex`, `level`). |
| `title_updated` | Bidirektional | Aktualisiert den Dokumententitel (`title`). |
| `cursor_moved` | Bidirektional | **Ephemere Live-Präsenz & Cursor:** Sendet Benutzer-Avatar, Cursorposition und Textselektion (`clientId`, `userId`, `userName`, `userColor`, `blockId`, `selectionStart`, `selectionEnd`). Wird nicht in SQLite gespeichert! |
| `client_left` | Server ➔ Client | Wird automatisch ausgelöst, wenn ein Peer die Verbindung trennt, damit Remote-Cursor sofort verschwinden. |
```mermaid
sequenceDiagram
autonumber
actor UserA as Benutzer A
participant WS as WebSocket Hub & SQLite
actor UserB as Benutzer B
actor UserA as Admin (Fenster A, cli_1)
participant Room as DocRoom (Go RAM)
actor UserB as User (Fenster B, cli_2)
participant DB as SQLite (blocks, block_tags, documents)
UserA->>WS: CRDT_JOIN (docId: "doc_123")
WS-->>UserA: CRDT_INIT (stateBlob, updates)
UserB->>WS: CRDT_JOIN (docId: "doc_123")
WS-->>UserB: CRDT_INIT (stateBlob, updates)
UserA->>Room: Connect (clientId="cli_1", userName="Admin")
Room-->>UserA: doc_state (title, blocks, activeCursors)
UserB->>Room: Connect (clientId="cli_2", userName="User")
Room-->>UserB: doc_state (title, blocks, activeCursors)
UserA->>WS: CRDT_UPDATE (docId, updateBlob)
WS->>WS: Append in document_crdt_updates
WS->>UserB: CRDT_UPDATE (docId, updateBlob)
Note over UserB: Yjs wendet Delta atomar an (0 Konflikte)
UserA->>Room: block_updated (clientId="cli_1", blockId="b1", content="Neu")
Note over Room: Mutiert RAM-Slice & resetet 1.5s Timer
Room-->>UserB: block_updated (clientId="cli_1", blockId="b1", content="Neu")
Note over UserA: Echo Filter: cli_1 erhält kein Selbstecho!
UserA->>WS: CRDT_AWARENESS (docId, focusedBlockId)
WS->>UserB: CRDT_AWARENESS (docId, user, focusedBlockId)
Note over UserB: Avatar-Badge erscheint am Block
UserB->>Room: cursor_moved (clientId="cli_2", blockId="b1", selStart=3)
Room-->>UserA: cursor_moved (clientId="cli_2", blockId="b1")
Note over UserA: Farbiger Cursor-Avatar erscheint an b1
Note over Room: 1.5s Inaktivität ohne neue Tastenanschläge
Room->>DB: Debounce-Flush (blocks, Regex #tags -> block_tags, markdown_cache)
```
---
### 4.2 Strikter Echo-Schutz (`clientId`)
- Jeder Client generiert beim Start einen flüchtigen Schlüssel (`clientIdRef`, z. B. `cli_a8f9_l0z3`).
- Der Go-Hub filtert beim Broadcasten im Raum alle Clients heraus, deren Verbindung oder `clientId` mit dem Absender übereinstimmt:
```go
if evt.ClientID != "" && client.clientID == evt.ClientID {
continue
}
```
- **Vorteil:** Verhindert Cursor-Resets, Fokus-Verluste und Race Conditions durch Selbstechos während des lokalen Tippens.
## 🔒 5. Abwärtskompatibles Document-Lease-Locking
Vor Version 2.1 nutzte MindSift ein serverseitiges Lease-Locking mit 30-Sekunden-Heartbeats. Durch die CRDT-Engine wurde die blockierende Bearbeitungssperre (`isLockedByOther`) abgelöst:
- **Aktuelles Verhalten:** Mehrere Benutzer mit Bearbeitungsrechten können zeitgleich dasselbe Dokument bearbeiten.
- **Presence & Locks:** Die Events `LOCK_DOC` und `HEARTBEAT_DOC` bleiben abwärtskompatibel erhalten und dienen nun der reinen Präsenzanzeige (z.B. gelber Statusindikator „wird gerade von X angesehen").
- **Schreibschutz:** Ein echter Schreibschutz (`isReadOnly`) greift ausschließlich noch dann, wenn der Nutzer nur über Leserechte (`permission === 'read'`) für die Notiz oder den Ordner verfügt.
### 4.3 1.5-Sekunden Debounce-Flush in SQLite
- Das Tippen im Editor erfolgt latenzfrei direkt im Arbeitsspeicher des `DocRoom`.
- Sobald 1,5 Sekunden lang keine neuen Tastenanschläge oder Mutationen eintreffen (oder wenn der letzte Client den Raum verlässt), stößt der Raum einen synchronisierten Flush an:
1. Persistiert alle Blöcke in `blocks`.
2. Extrahiert alle Hashtags (`#tag`) per Regex und aktualisiert `block_tags`.
3. Kompiliert die Blockliste (anhand der `level`-Tiefe) in Standard-Markdown und speichert sie in `documents.markdown_cache`.
---
## 🔄 5. Fallback-Strategie & Proxy-Resilienz (Dual-Transport Architektur)
## 🔒 5. Berechtigungen & Bearbeitungsrechte
- **Kollaboration ohne blockierende Locks:** Alle Nutzer mit Schreibrechten (`permission === 'owner'` oder `'write'`) können zeitgleich im selben Dokument tippen. Pessimistische Locks oder Lease-Heartbeats wurden vollständig entfernt.
- **Schreibschutz:** Verfügt ein Nutzer nur über Leserechte (`permission === 'read'`), ist das Eingabefeld gesperrt (`readOnly`). Er kann das Dokument jedoch in Echtzeit mitlesen und sieht die Cursors der Bearbeiter.
---
## 🔄 6. Fallback-Strategie & Proxy-Resilienz (Dual-Transport Architektur)
In restriktiven Firmennetzwerken, hinter Unternehmens-Proxies (z.B. Zscaler, BlueCoat) oder fehlerhaft konfigurierten Reverse-Proxies wird der `Upgrade: websocket`-Header häufig verworfen (`NS_ERROR_CONNECTION_REFUSED`, Code 1006 / 1002 / 1008).
@@ -153,12 +179,11 @@ MindSift löst dieses Problem durch eine **vollwertige Dual-Transport-Architektu
- Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` verhindern Pufferung im Proxy; periodische Ping-Kommentare (`: ping\n\n`) alle 15 Sekunden halten Timeouts fern.
- Der Client bleibt dadurch **vollständig echtzeitfähig** (`Live (SSE)`).
### 2. REST-Endpunkte für Upstream-Aktionen im SSE-Modus
### 2. REST-Endpunkte für Upstream-Aktionen im SSE-Fallback-Modus
Da SSE unidirektional (Server ➔ Client) ist, stellt MindSift für Schreibvorgänge im SSE-Modus REST-Äquivalente bereit:
- `PUT /api/v1/documents/:id/crdt`: Unterstützt sowohl volles `state` (Compaction) als auch inkrementelles `update` (Delta). Der Hub persistiert das Update und broadcastet es sofort an alle anderen WS- und SSE-Clients im Dokumentenraum.
- `POST /api/v1/documents/:id/awareness`: Überträgt Cursor- und Blockfokus-Präsenzen.
- `DELETE /api/v1/documents/:id/awareness`: Informiert Peers beim Verlassen des Dokuments.
- `POST /api/v1/events/rooms`: Erlaubt SSE-Clients, Dokument- oder Listenräumen dynamisch beizutreten (`join`) oder sie zu verlassen (`leave`).
- `PATCH /api/v1/documents/:id/blocks`: Akzeptiert Block-Mutationen (`upserted`, `deletedIds`, `blockOrder`) und synchronisiert den In-Memory-Raum sowie SQLite.
- `PUT /api/v1/documents/:id`: Aktualisiert Dokumenttitel und Metadaten.
- `POST /api/v1/events/rooms`: Erlaubt SSE-Clients, Räumen dynamisch beizutreten (`join`) oder sie zu verlassen (`leave`).
### 3. Verbindungs-Indikator
- **Grün pulsierend:** Live verbunden über WebSockets (`Live (WebSocket)`).
+34 -23
View File
@@ -27,7 +27,9 @@ erDiagram
lists ||--o{ list_items : "enthält"
lists ||--o{ list_shares : "wird geteilt mit"
documents ||--o{ document_blocks : "besteht aus"
documents ||--o{ blocks : "besteht aus"
documents ||--o{ block_tags : "indiziert"
blocks ||--o{ block_tags : "verknüpft"
documents ||--o{ doc_shares : "geteilt mit Benutzern"
documents ||--o{ doc_team_shares : "geteilt mit Teams"
documents ||--o{ doc_user_folders : "eigene Platzierung"
@@ -207,45 +209,54 @@ CREATE TABLE IF NOT EXISTS task_list_shares (
### 2.4 Workspace: Dokumente, Blöcke & Geteilte Ordner
#### `documents` & `document_blocks` (Database-First Outliner)
Dokumente werden als Kopfdatensatz in `documents` gespeichert, während der Inhalt als feingranulare Blöcke in `document_blocks` verwaltet wird. MindSift speichert alle Notizen zu 100% in SQLite (keine physischen `.md`-Dateien auf Festplatte).
#### `documents`, `blocks` & `block_tags` (Database-First Outliner Schema)
Dokumente werden als Kopfdatensatz in `documents` gespeichert, während der Inhalt als feingranulare Blöcke in `blocks` verwaltet wird. Hashtags (#tags) werden per Regex analysiert und in `block_tags` abgelegt. MindSift speichert alle Notizen zu 100% in SQLite (keine physischen `.md`-Dateien auf Festplatte).
```sql
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY, -- Präfix "doc_..."
user_id TEXT NOT NULL, -- Eigentümer
title TEXT NOT NULL, -- Dokumenttitel
user_id TEXT NOT NULL DEFAULT '', -- Eigentümer (usr_...)
space_id TEXT DEFAULT '',
title TEXT NOT NULL DEFAULT '', -- Dokumenttitel
folder_id TEXT NOT NULL DEFAULT '', -- Ordner-Zuordnung
markdown_cache TEXT NOT NULL DEFAULT '', -- Kompiliertes Standard-Markdown (automatischer Cache)
icon TEXT NOT NULL DEFAULT '📄', -- Emoji-Icon
content TEXT NOT NULL DEFAULT '', -- Plaintext / Text-Repräsentation (Cache)
blocks_json TEXT NOT NULL DEFAULT '[]', -- Legacy JSON-Cache
content TEXT NOT NULL DEFAULT '', -- Plaintext / Text-Repräsentation
blocks_json TEXT NOT NULL DEFAULT '[]', -- Legacy/Zusatz-Cache
tags TEXT NOT NULL DEFAULT '[]', -- JSON Array von Tags
folder_path TEXT NOT NULL DEFAULT '', -- Relativer Ordnerpfad des Eigentümers
shared_folder_id TEXT NOT NULL DEFAULT '', -- ID aus workspace_shared_folders (wenn in geteiltem Ordner)
sub_folder_path TEXT NOT NULL DEFAULT '', -- Relativer Unterordnerpfad innerhalb des geteilten Ordners
is_pinned INTEGER NOT NULL DEFAULT 0, -- Globaler Pin
is_archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_documents_shared_folder ON documents(shared_folder_id);
CREATE TABLE IF NOT EXISTS document_blocks (
id TEXT PRIMARY KEY, -- Präfix "blk_..."
doc_id TEXT NOT NULL,
sort_order REAL NOT NULL DEFAULT 0, -- Fließkommazahl für konfliktfreies Einfügen
type TEXT NOT NULL DEFAULT 'bullet', -- 'bullet', 'todo', 'h1', 'h2', 'h3', 'code', 'quote', 'callout', 'divider'
CREATE TABLE IF NOT EXISTS blocks (
id TEXT PRIMARY KEY, -- Block-ID (z.B. "blk_..." oder "docId-b0")
document_id TEXT NOT NULL, -- Zugehöriges Dokument (doc_...)
content TEXT NOT NULL DEFAULT '', -- Textinhalt des Blocks
level INTEGER NOT NULL DEFAULT 0, -- Hierarchieebene (0 = Root, 1 = Kind...)
level INT NOT NULL DEFAULT 0, -- Hierarchieebene (0 = Root, 1 = Kind...)
position REAL NOT NULL DEFAULT 0, -- Fließkommazahl für konfliktfreies Einfügen/Sortieren
type TEXT NOT NULL DEFAULT 'bullet', -- 'bullet', 'todo', 'h1', 'h2', 'h3', 'code', 'quote', 'callout', 'divider'
checked INTEGER NOT NULL DEFAULT 0, -- Für Checklisten (0 = offen, 1 = abgehakt)
collapsed INTEGER NOT NULL DEFAULT 0, -- Für eingeklappte Unterbäume
collapsed INTEGER NOT NULL DEFAULT 0, -- Für eingeklappte Unterbäume (1 = zugeklappt)
lang TEXT NOT NULL DEFAULT '', -- Programmiersprache für Codeblöcke
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL DEFAULT '',
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_document_blocks_doc_order ON document_blocks(doc_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_blocks_doc_pos ON blocks(document_id, position ASC);
CREATE TABLE IF NOT EXISTS block_tags (
tag TEXT NOT NULL, -- Hashtag (z.B. "meeting", ohne führendes '#')
document_id TEXT NOT NULL, -- Zugehöriges Dokument
block_id TEXT NOT NULL, -- Zugehöriger Block
PRIMARY KEY(tag, block_id),
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE,
FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);
CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);
```
#### Entkoppelte Ordner-Freigabe (`workspace_shared_folders` & `workspace_folder_mounts`)
+14 -35
View File
@@ -8,31 +8,30 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle
| ID | Bereich | Problem / Schwachstelle | Schweregrad | Risiko im Produktivbetrieb |
|---|---|---|---|---|
| **FLAW-01** | Workspace Sync | Kein echtes CRDT/OT – Lock-Heartbeat-Modell mit Last-Write-Wins | **Gelöst (v2.1)** | Behoben durch Yjs CRDT-Engine & blockbasierte kollaborative Bearbeitung |
| **FLAW-01** | Workspace Sync | Blockierende Locks / Synchronisations-Konflikte | **Gelöst (v2.2)** | Behoben durch In-Memory DocRooms, Echo-Schutz per `clientId` und 1.5s Debounce-Flush |
| **FLAW-02** | Storage | Dual-Storage Drift (SQLite vs. Dateisystem) | **Obsolet / Gelöst** | Kein Risiko: MindSift nutzt Pure-DB (keine `.md`-Dateien auf Platte) |
| **FLAW-03** | Netzwerk / WS | Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback | **Mittel** | Verzögerte Synchronisierung (1–3s Polling) in Firmennetzwerken |
| **FLAW-03** | Netzwerk / WS | Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback | **Gelöst (v2.2)** | Behoben durch Dual-Transport SSE-Fallback & globale Singleton-Verbindung |
| **FLAW-04** | Datenbank / Suche | Volltextsuche via `LIKE '%query%'` statt SQLite FTS5 | **Mittel** | Performance-Einbrüche bei wachsenden Notizbeständen (>10.000 Blöcke) |
| **FLAW-05** | Frontend DOM | Fehlende Virtualisierung im Outliner-Baum | **Mittel** | Frame-Drops und Lags bei Dokumenten mit >500 Blöcken auf Mobilgeräten |
| **FLAW-06** | SQLite Concurrency | Single-Writer-Bottleneck bei hohem parallelem Schreibaufkommen | **Mittel** | `SQLITE_BUSY` Fehler bei vielen gleichzeitigen Team-Nutzern |
| **FLAW-07** | Benachrichtigungen | Fehlende Dead-Letter-Queue für Webhook- & E-Mail-Alarme | **Niedrig** | Verlorene Benachrichtigungen bei temporärem Ausfall von Drittsystemen |
| **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne rotierende Refresh-Tokens & Blacklisting | **Niedrig** | Keine sofortige Sitzungsinvalidierung bei Rechteentzug |
| **FLAW-09** | Mobile UX | Viewport-Verschiebung durch Virtual-Keyboard auf iOS/Android | **Niedrig** | Verdeckte Eingabefelder und springende Bottom-Navigationsleisten |
| **FLAW-10** | Outliner UX | Kein hierarchischer Undo/Redo-Stack für Strukturmutationen | **Mittel** | Versehentliches Löschen/Einrücken von Blöcken nicht per `Ctrl+Z` rückgängig machbar |
---
## 🔍 Detaillierte Analyse & Maßnahmen
### FLAW-01: [GELÖST / IMPLEMENTIERT] Yjs Outliner CRDT & Multi-User Kollaboration
- **Status:** **Gelöst via Yjs CRDT Engine (Lösung i)**
- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/hub.go`, `internal/storage/sqlite.go`, `frontend/src/lib/crdt.ts`, `frontend/src/features/workspace/WorkspaceView.tsx`
- **Ursache (Historisch):** MindSift nutzte ein kooperatives Dokumenten-Lease-Locking mit Last-Write-Wins. Bei parallelem Zugriff oder Netztrennung konnten Änderungen überschrieben werden.
- **Implementierte Lösung (Yjs CRDT):**
1. **Block-Level Yjs CRDT:** Implementierung eines vollwertigen CRDT-Modells mit `Y.Doc`. Die Blockreihenfolge wird über ein `Y.Array<string>` (Block-IDs) konfliktfrei mit dem YATA-Algorithmus aufgelöst. Inhalte und Metadaten (Text, Level, Checked, Collapsed) sind in `Y.Map` gekapselt.
2. **Wegfall von Ganzdokument-Sperren:** Keine blockierenden Sperren mehr (`isLockedByOther` entfernt). Mehrere Nutzer können zeitgleich am selben Dokument schreiben.
3. **Dual-Persistence:** CRDT-Zustände und Delta-Updates werden binär/Base64 in SQLite (`document_crdt_state`, `document_crdt_updates`) persistiert. Zur Aufrechterhaltung relationaler Indizes (Global To-Dos, Tag-Indizierung, Suche) werden Blöcke parallel in `document_blocks` materialisiert.
4. **Live-Awareness & Presence:** Block-Fokus und Cursor-Zustand werden via `CRDT_AWARENESS` übertragen. Nutzer sehen ein Live-Badge im Header (`Live (N)`) sowie farbige Kollaborator-Pillen an aktiven Blöcken.
5. **REST- & Proxy-Sicherheit:** Ergänzend zu WebSockets stehen Endpunkte `GET /api/v1/documents/:id/crdt` und `PUT /api/v1/documents/:id/crdt` bereit.
### FLAW-01: [GELÖST / IMPLEMENTIERT] Granulare Outliner-Synchronisation & Multi-User Kollaboration
- **Status:** **Gelöst via In-Memory DocRooms, Echo-Schutz & Debounced Persistence**
- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/docroom.go`, `internal/storage/sqlite.go`, `frontend/src/lib/useDocumentSync.ts`, `frontend/src/features/workspace/OutlinerBlock.tsx`
- **Ursache (Historisch):** MindSift nutzte früher pessimistisches Dokumentensperren (Lease-Locks mit Heartbeats). Bei parallelem Bearbeiten blockierten sich Nutzer gegenseitig; unausgereifte Sync-Versuche führten zu Selbstechos und Cursor-Jumps.
- **Implementierte Lösung:**
1. **In-Memory DocRooms:** Granularer WebSocket-Raum pro Dokument (`/api/v1/ws/docs/:id`) mit Mutex-geschütztem Block-State im Go-Backend.
2. **Strikter Echo-Schutz per `clientId`:** Jede Verbindung besitzt eine eindeutige `clientId`. Der Hub filtert den Sender heraus und leitet Events nur an Peers weiter.
3. **Live Presence & Ephemere Cursors:** Cursorposition und Textselektionen (`cursor_moved`) werden flüchtig an Peers gestreamt, ohne SQLite zu belasten.
4. **Fokus- & Eingabeschutz (`OutlinerBlock.tsx`):** Remote-Updates werden nur blockiert, wenn `document.hasFocus()` vorliegt. Bei `window.onblur` und `input.onblur` wird sofort (0ms) geflusht; Callbacks werden über `useRef` geführt (keine Stale Closures).
5. **1.5s Inaktivitäts-Debounce-Flush:** Nach 1,5s Inaktivität sichert der Raum persistent in SQLite (`blocks`, Regex `#tags` in `block_tags` und kompiliertes Markdown in `documents.markdown_cache`).
6. **Vollständige Eliminierung von Locks:** Alle blockierenden Sperren wurden restlos entfernt. Mehrere Nutzer können zeitgleich schreiben.
---
@@ -84,7 +83,7 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle
---
### FLAW-05: Fehlende Virtualisierung im Outliner-Baum
- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/lib/components/Outliner.svelte`
- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/features/workspace/WorkspaceView.tsx`
- **Ursache:** Jeder Block im Notizbaum wird als eigenes DOM-Element gerendert. Enthält ein Dokument 500 bis 2000 Blöcke (z. B. lange Projektjournale oder Besprechungsprotokolle), befinden sich tausende verschachtelte `div`- und `contenteditable`-Elemente gleichzeitig im DOM.
- **Folgen:** Auf älteren Smartphones oder bei schnellem Tippen kommt es zu spürbaren Input-Latenzen und Ruckeln beim Scrollen.
- **Empfohlene Lösung:**
@@ -123,26 +122,6 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle
---
### FLAW-09: Mobile Viewport-Verschiebung durch Virtual-Keyboard
- **Betroffene Komponenten:** `frontend/src/routes/+layout.svelte`, `frontend/src/lib/components/BottomNav.svelte`
- **Ursache:** Öffnet sich auf iOS Safari oder Android Chrome die Bildschirmtastatur, ändert der Browser die Höhe des sichtbaren Viewports dynamisch. Feste CSS-Attribute wie `height: 100vh` oder `fixed bottom-0` führen dazu, dass Eingabefelder unter die Tastatur rutschen oder die Navigationsleiste den Bildschirm verdeckt.
- **Folgen:** In der mobilen Triage- und Aufgabenansicht muss der Nutzer manuell scrollen, um die Speichern-Buttons zu erreichen.
- **Empfohlene Lösung:**
1. Nutzung der modernen `window.visualViewport`-API in Svelte.
2. Dynamisches Setzen einer CSS-Variable `--vvh` (`visualViewport.height`) zur präzisen Anpassung von Modals und Bottom-Sheets.
---
### FLAW-10: Kein hierarchischer Undo/Redo-Stack im Outliner
- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/lib/components/Outliner.svelte`
- **Ursache:** Standardmäßiges Browser-`Ctrl+Z` funktioniert nur innerhalb eines einzelnen aktiven Textfelds (`contenteditable`). Strukturelle Aktionen – wie das Verschieben von Blöcken via Drag & Drop, Einrücken (`Tab`), Ausrücken (`Shift+Tab`) oder das Löschen ganzer Unterbäume – sind im Standard-Browser-Undo-Stack nicht enthalten.
- **Folgen:** Verschiebt ein Nutzer versehentlich einen Block samt 10 Unterpunkten, kann dies nicht mit `Ctrl+Z` rückgängig gemacht werden.
- **Empfohlene Lösung:**
1. Implementierung eines expliziten In-Memory Command-Stacks im Frontend (Command-Pattern: `execute()` / `undo()`).
2. Aufzeichnung aller Block-Mutationen (Insert, Indent, Move, Delete) mit Revert-Payloads.
---
## 🧭 Verwandte Dokumente
- 🏗️ [Gesamtarchitektur](architecture.md)
- 💾 [Datenbank-Design & Tabellenschemata](database.md)
+44 -32
View File
@@ -1,12 +1,12 @@
# 📝 Kernmodul 3: Workspace Outliner-Engine, Wiki-Links & Pure-DB Storage
Der MindSift Workspace ([`WorkspaceView.tsx`](../../frontend/src/features/workspace/WorkspaceView.tsx)) kombiniert ein **hierarchisches Bullet-Point Notizsystem (inspiriert von Logseq)** mit einem **persönlichen Wiki** und einem **globalen To-Do Rollup Dashboard**.
Der MindSift Workspace ([`WorkspaceView.tsx`](../../frontend/src/features/workspace/WorkspaceView.tsx)) kombiniert ein **hierarchisches Bullet-Point Notizsystem (inspiriert von Logseq)** mit einem **persönlichen Wiki**, **Live-Kollaboration** und einem **globalen To-Do Rollup Dashboard**.
---
## 🌳 1. Die Logseq-Outliner-Engine
Im Gegensatz zu klassischen Fließtext-Editoren basiert der MindSift Workspace auf **Blöcken** (`document_blocks` in SQLite). Jeder Block ist ein gedanklicher Knotenpunkt mit einer flexiblen Einrückungsebene (`level: 0, 1, 2, ...`).
Im Gegensatz zu klassischen Fließtext-Editoren basiert der MindSift Workspace auf einer **flachen, relationalen Liste von Blöcken** (`blocks`-Tabelle in SQLite). Jeder Block ist ein gedanklicher Knotenpunkt mit einer flexiblen Einrückungsebene (`level: 0, 1, 2, ...`) und einer fortlaufenden Fließkommaposition (`position`):
```
• Projekt MindSift v2.0 (level 0)
@@ -22,12 +22,20 @@ Im Gegensatz zu klassischen Fließtext-Editoren basiert der MindSift Workspace a
- `Tab`: Rückt den aktuellen Block ein (wird Kindknoten des vorherigen Blocks).
- `Shift + Tab`: Rückt den Block um eine Ebene aus (verringert die Tiefe).
- `Shift + Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks (z.B. für mehrzeilige Notizen oder Code).
- `Backspace` (auf leerem Block): Rückt den Block aus oder löscht ihn, wenn er auf Level 0 ist.
- `/` (Slash-Commands): Öffnet das Umwandlungsmenü (`/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/quote`, `/divider`).
- `Backspace` (auf leerem Block): Löscht den Block sofort. Das Ausrücken erfolgt strikt und kontrolliert über `Shift + Tab`.
- `/` (Slash-Commands): Öffnet das Umwandlungsmenü (`/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/quote`, `/divider`).
- **Markdown-Tastatur-Kürzel:** Tippen von `[] ` oder `[ ] ` wandelt den Block sofort in ein Todo um; `# `, `## `, `### ` erzeugt Überschriften.
### Eingabestabilität & Zombie-Fokus-Schutz (`OutlinerBlock.tsx`)
1. **Echter Fokus-Check:** Remote-Updates (`block_updated`) werden nur dann abgewehrt, wenn der Block tatsächlich aktiv im Betriebssystem editiert wird:
`isFocusedRef.current === true && document.hasFocus()`.
2. **Sofort-Flush bei Fensterwechsel:** Sobald das Browser-Fenster den Fokus verliert (`window.addEventListener('blur', ...)`), wird ein offener 150ms-Debounce sofort gecancelt und der Text augenblicklich per WebSocket übertragen.
3. **Stale Closures eliminiert:** Alle Callbacks werden in `useRef` gehalten, sodass asynchrone Debounce-Timer garantiert die aktuellste Komponenten-Referenz verwenden.
### Zoom-In Hoisting (Fokus-Modus)
Klickt der Benutzer auf den Bullet-Dot (`•`) eines Blocks, blendet der Editor alle übergeordneten und benachbarten Blöcke aus und zoomt **ausschließlich in diesen Ast** hinein.
- Eine dezente Breadcrumb-Leiste oben zeigt den Pfad an (z.B. `Workspace / Projekte / MindSift / Architektur`).
Klickt der Benutzer auf den Bullet-Dot (`•`) eines Blocks, blendet der Editor alle übergeordneten und benachbarten Blöcke aus und zoomt **ausschließlich in diesen Ast** hinein:
- Der gewählte Punkt wird zur prominenten Überschrift im Fokus-Modus.
- Eine klickbare Breadcrumb-Leiste oben zeigt den Pfad an (z.B. `Workspace / Projekte / MindSift / Architektur`).
- Ein Klick auf ein beliebiges Breadcrumb-Element zoomt sofort auf die jeweilige Ebene zurück.
### Drag & Drop von Blöcken
@@ -35,51 +43,55 @@ Der Bullet-Dot dient gleichzeitig als Drag-Anfasser. Wird ein Block verschoben,
---
## 🔗 2. Wiki-Links (`[[Link]]`) & Hashtags
## 🔗 2. Wiki-Links (`[[Link]]`), Web-Links (`/link`) & Hashtags
1. **Interne Wiki-Links:**
- Text in doppelten eckigen Klammern (z.B. `[[Reiseplanung 2026]]`) wird automatisch als klickbarer Link gerendert.
- Ein Klick darauf durchsucht die Notizen des Benutzers und öffnet das Ziel-Dokument direkt im Editor.
2. **Hashtags (`#tag`):**
- Hashtags im Fließtext werden automatisch indiziert (`doc_block_tags`-Tabelle).
- Text in doppelten eckigen Klammern (z.B. `[[Reiseplanung 2026]]`) wird automatisch als klickbare Notiz-Pille gerendert.
- Klick darauf öffnet die verknüpfte Notiz direkt im Editor.
2. **Slash-Befehl `/link` & Link-Modal:**
- `/link` öffnet ein Modal mit zwei Modi:
- **Notiz verknüpfen:** Durchsucht bestehende Notizen und fügt `[[Titel]]` ein.
- **Web-Link:** Eingabe von Titel und URL, fügt Standard-Markdown `[Titel](url)` ein.
3. **Hashtags (`#tag`):**
- Hashtags im Fließtext werden automatisch indiziert (`block_tags`-Tabelle).
- In der linken Leiste listet der Tag-Explorer alle verwendeten Schlagworte auf. Klick auf ein Tag filtert alle Dokumente und Blöcke, die dieses Tag enthalten.
---
---
## ⚡ 3. Echtzeit-Kollaboration & Automatische Markdown-Kompilierung
## ⚡ 3. Yjs CRDT Real-Time Engine & Dual-Persistence
Ab MindSift v2.1 setzt der Workspace auf ein **konfliktfreies Replicated Data Type (CRDT) Modell** auf Basis von [Yjs](https://yjs.dev/):
MindSift setzt auf eine latenzfreie In-Memory Kollaborations-Architektur ([`internal/ws/docroom.go`](../../internal/ws/docroom.go)):
### Funktionsweise
1. **CRDT-Struktur im Client:**
- Ein `Y.Doc` pro geöffnetem Dokument.
- `Y.Array<string>` (`blocks`): Führt die Block-IDs in korrekter Reihenfolge. Der YATA-Algorithmus garantiert, dass simultanes Einfügen, Verschieben oder Löschen von Blöcken durch mehrere Nutzer deterministisch und konfliktfrei konvergiert.
- `Y.Map` (`block:<id>`): Speichert Inhalt (`content`), Einrückung (`level`), Erledigungsstatus (`checked`) und Faltungsstatus (`collapsed`).
2. **Kollaborative Bearbeitung ohne Sperren:**
- Nutzer können simultan dieselbe Notiz bearbeiten.
- Fokus- und Cursor-Positionen werden via `CRDT_AWARENESS` übertragen und als farbige Benutzer-Pillen direkt am jeweiligen Block visualisiert.
1. **In-Memory DocRoom pro geöffnetem Dokument:**
- Hält den synchronen Zustand von `title` und `blocks[]` im RAM.
- Granulare Mutations-Events (`block_updated`, `block_created`, `block_deleted`, `block_moved`, `title_updated`).
- Ephemeres Presence-Tracking (`cursor_moved`, `client_left`) zeigt farbige Benutzer-Avatare im Header und Cursors am aktiven Block an.
2. **Echo-Schutz:**
- Jeder Client identifiziert sich mit einer eindeutigen `clientId`.
- Der Server sendet Mutations-Events ausschließlich an andere Clients im selben Raum (`clientId != senderClientId`), um Cursor-Springen und Selbstechos zu verhindern.
### Dual-Persistence in SQLite
### Automatische Markdown-Kompilierung & SQLite Debounce-Flush
```
Client (Y.Doc Mutation)
Eingabe im OutlinerBlock
│
├──▶ WebSocket (CRDT_UPDATE Base64 Delta)
├──▶ WebSocket (block_updated Event)
│ │
│ ▼
│ SQLite: document_crdt_updates / document_crdt_state
│ (Mathematisch exakte CRDT-Konvergenz)
│ Go DocRoom: In-Memory Mutation & Broadcast an Peers
│
└──▶ Periodischer Snapshot / Flush
└──▶ 1.5s Inaktivitäts-Debounce (oder Raum-Exit)
│
▼
SQLite: document_blocks
(Relationale Indizes für Global To-Dos, Tag-Explorer & FTS)
SQLite Transaktion:
1. Persistiert Blöcke in `blocks`
2. Regex-Extraktion von #tags in `block_tags`
3. Kompiliert Block-Hierarchie zu Standard-Markdown
und speichert Cache in `documents.markdown_cache`
```
1. **Mathematische CRDT-Garantie:** Binäre Yjs-Updates werden in `document_crdt_updates` und periodisch als Snapshot in `document_crdt_state` gesichert. Auch nach einem Server-Neustart oder Offline-Phasen konvergieren alle Clients verlustfrei.
2. **Relationale Query-Fähigkeit:** Um schnelle SQL-Abfragen für das globale To-Do Rollup Dashboard und Hashtags zu ermöglichen, werden die Blöcke parallel in `document_blocks` materialisiert. Es existieren **keine Markdown-Dateien auf der Festplatte**.
1. **Kompiliertes Markdown:** Jeder Block wird gemäß seiner `level`-Tiefe mit 2 Leerzeichen pro Einrückungsebene als sauberes Markdown formatiert (`documents.markdown_cache`).
2. **Pure Database Architecture:** Alle Daten liegen transaktional und sicher in SQLite. Es werden keine unkontrollierten Dateien auf der Festplatte abgelegt; Notizen können jederzeit per Klick als `.md`-Datei exportiert werden.
---
+2 -2
View File
@@ -71,8 +71,8 @@ server {
proxy_send_timeout 3600s;
}
# 2. Server-Sent Events Fallback (/api/v1/events)
location /api/v1/events {
# 2. Server-Sent Events Fallback (/api/v1/events & /api/v1/sync/)
location ~ ^/api/v1/(events|sync/) {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
+7 -6
View File
@@ -74,13 +74,14 @@ Der MindSift-Workspace vereint die Übersicht eines klassischen Dokumenten-Explo
* Verschiebe Absätze und ganze Gedankenbäume mühelos mit der Maus.
* **Unterpunkte per Drop erstellen:** Ziehst du einen Bullet-Point direkt auf einen anderen Aufzählungspunkt, wird er automatisch als Unter-Bullet eingerückt. Eingeklappte Eltern-Punkte klappen beim Hineinlegen automatisch auf.
* Untergeordnete Unterpunkte werden beim Ziehen automatisch als zusammenhängender Ast mitbewegt.
* **Echtzeit-Schreib-Lock (Kollisionsschutz):**
* Sobald du einen Bullet-Point per Drag & Drop greifst oder anfängst zu tippen, wird sofort ein Schreib-Lock für das Dokument aktiviert.
* Andere Nutzer sehen live, wer das Dokument gerade bearbeitet, und Kollisionen bei gleichzeitigen Änderungen werden zuverlässig verhindert.
* **Slash-Befehle (`/`):** Tippe einfach `/` für `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/callout` oder `/divider`.
* **Live-Kollaboration & Presence-Tracking:**
* Gleichzeitiges, latenzfreies Bearbeiten von Dokumenten im Team ohne blockierende Sperren.
* Farbige Avatare im Header und Live-Cursors am aktiven Block visualisieren genau, wer gerade wo tippt.
* Strikter Echo-Schutz verhindert Cursor-Springen und Flackern beim Tippen.
* **Slash-Befehle (`/`):** Tippe einfach `/` für `/todo`, `/link` (Notizen & Web-Links verknüpfen), `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/quote` oder `/divider`.
* **Vernetzung & Hashtags:** Verlinke Notizen untereinander mit Wiki-Links (`[[Notiztitel]]`) und tagge Gedanken mit `#Hashtags`.
* **Explorer mit Ordnern & Favoriten:** Organisiere Dokumente hierarchisch in Ordnern, verschiebe Notizen per Drag & Drop und hefte deine wichtigsten Dokumente als Favoriten ganz oben an.
* **100 % Standard-Markdown:** Jedes Dokument wird lokal unter `./data/workspace/...` als saubere `.md`-Datei mit 2-Leerzeichen-Einrückung abgelegt – jederzeit lesbar und kompatibel mit Obsidian, Logseq oder VS Code.
* **100 % Pure-DB & Markdown-Export:** Alle Notizen und Blöcke werden transaktional und sicher in SQLite gespeichert. Beim Speichern wird automatisch ein sauberer Markdown-Cache generiert, und Dokumente können jederzeit mit einem Klick als `.md`-Datei exportiert werden.
---
@@ -226,7 +227,7 @@ MindSift erkennt den Erststart automatisch und führt dich durch den Einrichtung
## 🔒 Deine Daten gehören dir
* **100 % Self-Hosted:** Keine fremden Cloud-Dienste, keine Telemetrie, kein Tracking.
* **Kein Vendor-Lock-in:** Alle Notizen liegen als lesbare `.md`-Dateien im Ordner `./data/workspace`. Backups können jederzeit exportiert werden.
* **Kein Vendor-Lock-in:** Alle Notizen liegen als relationale Daten in SQLite und werden automatisch als Standard-Markdown kompiliert. Dokumente lassen sich jederzeit mit einem Klick als `.md`-Datei exportieren oder als SQLite-Dump sichern.
---
+17 -10
View File
@@ -4,7 +4,7 @@ import (
"context"
"embed"
"io/fs"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
@@ -23,11 +23,16 @@ var embeddedDist embed.FS
func main() {
cfg := config.Load()
log.Printf("🧠 Starting MindSift Server on port %s (DB: %s)", cfg.Port, cfg.DatabasePath)
level := cfg.SlogLevel()
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
slog.SetDefault(logger)
slog.Info("MindSift Server starting", "port", cfg.Port, "log_level", level.String())
store, err := storage.NewStore(cfg.DatabasePath)
if err != nil {
log.Fatalf("Failed to initialize storage: %v", err)
slog.Error("Failed to initialize storage", "error", err)
os.Exit(1)
}
defer store.Close()
@@ -35,11 +40,11 @@ func main() {
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS)
if cfg.DemoMode {
log.Printf("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
if err := store.EnsureDemoAccounts(); err != nil {
log.Printf("⚠️ Fehler beim Anlegen der Demo-Accounts: %v", err)
slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err)
} else {
log.Printf("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
}
}
@@ -47,7 +52,8 @@ func main() {
staticFS, err := fs.Sub(embeddedDist, "dist")
if err != nil {
log.Fatalf("Failed to mount embedded dist filesystem: %v", err)
slog.Error("Failed to mount embedded dist filesystem", "error", err)
os.Exit(1)
}
srv := api.NewServer(cfg, store, wsHub, staticFS)
@@ -61,9 +67,10 @@ func main() {
}
go func() {
log.Printf("✨ MindSift ready at http://0.0.0.0:%s", cfg.Port)
slog.Info("✨ MindSift ready", "url", "http://0.0.0.0:"+cfg.Port)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("HTTP server error: %v", err)
slog.Error("HTTP server error", "error", err)
os.Exit(1)
}
}()
@@ -71,7 +78,7 @@ func main() {
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Println("Shutting down MindSift server gracefully...")
slog.Info("Shutting down MindSift server gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = httpServer.Shutdown(ctx)
+3
View File
@@ -14,6 +14,9 @@ services:
- BASE_URL=${BASE_URL:-http://localhost:8080}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- LOG_LEVEL=${LOG_LEVEL:-info}
# Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging
# - LOG_LEVEL=debug
# - DEBUG=true
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
- MINDA_DEMO=${MINDA_DEMO:-false}
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
+3
View File
@@ -14,6 +14,9 @@ services:
- BASE_URL=${BASE_URL:-http://localhost:8080}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- LOG_LEVEL=${LOG_LEVEL:-info}
# Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging
# - LOG_LEVEL=debug
# - DEBUG=true
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
- MINDA_DEMO=${MINDA_DEMO:-false}
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
+21
View File
@@ -14,6 +14,7 @@
"prismjs": "^1.30.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"y-indexeddb": "^9.0.12",
"yjs": "^13.6.33"
},
"devDependencies": {
@@ -2782,6 +2783,26 @@
}
}
},
"node_modules/y-indexeddb": {
"version": "9.0.12",
"resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz",
"integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.74"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"yjs": "^13.0.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+1 -2
View File
@@ -16,8 +16,7 @@
"lucide-react": "^0.468.0",
"prismjs": "^1.30.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"yjs": "^13.6.33"
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
+3 -3
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Layers,
CheckSquare,
@@ -414,7 +414,7 @@ export const App: React.FC = () => {
setActiveThemeState(themeId);
};
const refreshAll = async () => {
const refreshAll = useCallback(async () => {
try {
const [meRes, deckRes, listsRes] = await Promise.all([
api.getMe(),
@@ -428,7 +428,7 @@ export const App: React.FC = () => {
} catch {
// If token expired or invalid, api.ts dispatches mindsift:unauthorized
}
};
}, []);
const checkBootAndAuth = async () => {
setBootLoading(true);
+45 -46
View File
@@ -105,7 +105,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null);
const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const realtimeConnRef = useRef<any>(null);
const loadLists = async () => {
@@ -795,52 +794,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
return (
<div className="w-full space-y-3">
{/* Mobile Compact Header Bar (List switching & actions live in MindSift Logo Menu) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate">
{activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'}
</span>
{openItems.length > 0 && (
<span className="badge-neutral text-[11px] flex-shrink-0">
{openItems.length} offen
</span>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{activeList && (
<button
type="button"
onClick={openShareModal}
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
title="Liste teilen"
>
<Users className="w-4 h-4 text-accent" />
</button>
)}
<div
className={`h-9 w-9 flex items-center justify-center rounded-btn border transition-colors ${
wsConnected
? realtimeTransport === 'sse'
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-canvas border-border text-muted'
}`}
title={
wsConnected
? realtimeTransport === 'sse'
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
: 'Live synchronisiert (WebSocket online)'
: 'Offline'
}
>
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
</div>
</div>
</div>
{/* Top Header: List Tabs & Actions (Desktop) */}
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
{/* List Tabs */}
<div className="flex flex-wrap items-center gap-1">
{lists.map((lst) => {
@@ -953,6 +908,50 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div>
</div>
{/* Mobile Compact Header Bar (List switching & actions live in MindSift Logo Menu) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border !mt-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate">
{activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'}
</span>
{openItems.length > 0 && (
<span className="badge-neutral text-[11px] flex-shrink-0">
{openItems.length} offen
</span>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{activeList && (
<button
type="button"
onClick={openShareModal}
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
title="Liste teilen"
>
<Users className="w-4 h-4 text-accent" />
</button>
)}
<div
className={`h-9 w-9 flex items-center justify-center rounded-btn border transition-colors ${
wsConnected
? realtimeTransport === 'sse'
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-canvas border-border text-muted'
}`}
title={
wsConnected
? realtimeTransport === 'sse'
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
: 'Live synchronisiert (WebSocket online)'
: 'Offline'
}
>
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
</div>
</div>
</div>
{showNewListModal && (
<form
onSubmit={handleCreateList}
+29 -29
View File
@@ -1264,36 +1264,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
return (
<div className="w-full space-y-3">
{/* Mobile Compact Context Bar (replaces cluttered button rows; options live in MindSift Logo Menu) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate">
{currentSelectedList ? `${currentSelectedList.icon} ${currentSelectedList.title}` : '📋 Alle Aufgaben'}
</span>
<span className="badge-neutral text-[11px] flex-shrink-0">
{viewMode === 'kanban'
? 'Kanban'
: filter === 'open'
? `Offen (${openTasks.length})`
: filter === 'done'
? `Erledigt (${doneTasks.length})`
: `Papierkorb (${safeDiscarded.length})`}
</span>
</div>
{currentSelectedList && (
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center flex-shrink-0"
title="Aufgabenliste teilen"
>
<Users className="w-4 h-4 text-accent" />
</button>
)}
</div>
{/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */}
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
{/* Task Lists selector tabs */}
<div className="flex flex-wrap items-center gap-1">
<button
@@ -1448,6 +1420,34 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div>
</div>
{/* Mobile Compact Context Bar (replaces cluttered button rows; options live in MindSift Logo Menu) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border !mt-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate">
{currentSelectedList ? `${currentSelectedList.icon} ${currentSelectedList.title}` : '📋 Alle Aufgaben'}
</span>
<span className="badge-neutral text-[11px] flex-shrink-0">
{viewMode === 'kanban'
? 'Kanban'
: filter === 'open'
? `Offen (${openTasks.length})`
: filter === 'done'
? `Erledigt (${doneTasks.length})`
: `Papierkorb (${safeDiscarded.length})`}
</span>
</div>
{currentSelectedList && (
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center flex-shrink-0"
title="Aufgabenliste teilen"
>
<Users className="w-4 h-4 text-accent" />
</button>
)}
</div>
{/* Quick Add Task Input (Desktop only - on mobile, tapping the bottom '+' FAB opens mobile create sheet) */}
<form
onSubmit={handleQuickAddTask}
@@ -0,0 +1,960 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import {
Pin,
Users,
Trash2,
Download,
FileText,
Loader2,
ArrowLeft,
Hash,
Lock,
Home,
ChevronRight,
Minimize2,
Sparkles,
Plus,
} from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api';
import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus } from '../../lib/useDocumentSync';
import { OutlinerTree } from './OutlinerTree';
export interface DocumentEditorProps {
docId: string;
currentUser?: User | null;
initialDoc?: Document | null;
availableFolders: string[];
autoFocusTitle?: boolean;
initialFocusedBlockId?: string | null;
onDocMetadataChange: (updated: Document) => void;
onDeleteDoc: (docId: string) => void;
onOpenShareModal: (doc: Document) => void;
onSelectTag: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
onBackMobile?: () => void;
onSyncStatusChange?: (status: DocSyncStatus) => void;
}
const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒'];
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
if (!blocks || blocks.length === 0) {
return [
{
id: `${docId}-b0`,
documentId: docId,
type: 'text',
content: '',
level: 0,
position: 1000,
},
];
}
return blocks.map((b, idx) => ({
...b,
id: b.id || `${docId}-b${idx}`,
documentId: docId,
type: b.type || 'text',
level: Math.max(0, b.level || 0),
position: b.position || (idx + 1) * 1000,
}));
}
export interface BreadcrumbNode {
id: string | null;
title: string;
}
export function computeBreadcrumbs(
docTitle: string,
blocks: DocumentBlock[],
zoomedBlockId: string | null
): BreadcrumbNode[] {
if (!zoomedBlockId) return [];
const zoomedIdx = blocks.findIndex((b) => b.id === zoomedBlockId);
if (zoomedIdx === -1) return [];
const trail: BreadcrumbNode[] = [];
const zoomedBlock = blocks[zoomedIdx];
let currentLevel = zoomedBlock.level;
for (let i = zoomedIdx - 1; i >= 0; i--) {
if (blocks[i].level < currentLevel) {
trail.unshift({
id: blocks[i].id,
title: blocks[i].content?.trim() || 'Punkt',
});
currentLevel = blocks[i].level;
if (currentLevel === 0) break;
}
}
trail.unshift({
id: null,
title: docTitle || 'Dokument',
});
trail.push({
id: zoomedBlock.id,
title: zoomedBlock.content?.trim() || 'Punkt',
});
return trail;
}
export function getZoomedSubtree(
blocks: DocumentBlock[],
zoomedBlockId: string | null
): {
zoomedBlock: DocumentBlock | null;
childBlocks: DocumentBlock[];
levelOffset: number;
} {
if (!zoomedBlockId) {
return { zoomedBlock: null, childBlocks: blocks, levelOffset: 0 };
}
const idx = blocks.findIndex((b) => b.id === zoomedBlockId);
if (idx === -1) {
return { zoomedBlock: null, childBlocks: blocks, levelOffset: 0 };
}
const zoomedBlock = blocks[idx];
const zLevel = zoomedBlock.level;
const childBlocks: DocumentBlock[] = [];
for (let i = idx + 1; i < blocks.length; i++) {
if (blocks[i].level > zLevel) {
childBlocks.push(blocks[i]);
} else {
break;
}
}
return {
zoomedBlock,
childBlocks,
levelOffset: zLevel + 1,
};
}
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
docId,
currentUser,
initialDoc,
availableFolders,
autoFocusTitle,
initialFocusedBlockId,
onDocMetadataChange,
onDeleteDoc,
onOpenShareModal,
onSelectTag,
onWikiLinkClick,
onBackMobile,
onSyncStatusChange,
}) => {
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || '');
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
ensureNonEmptyBlocks(docId, initialDoc?.blocks)
);
const [loading, setLoading] = useState<boolean>(!initialDoc);
const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
const titleInputRef = useRef<HTMLInputElement | null>(null);
const titleDebounceRef = useRef<number | null>(null);
const docRef = useRef<Document | null>(doc);
docRef.current = doc;
useEffect(() => {
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
const focusTitle = () => {
if (titleInputRef.current) {
titleInputRef.current.focus();
titleInputRef.current.select();
}
};
focusTitle();
const t1 = setTimeout(focusTitle, 50);
const t2 = setTimeout(focusTitle, 150);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}
}, [loading, autoFocusTitle, doc?.id]);
useEffect(() => {
if (zoomedBlockId && !blocks.some((b) => b.id === zoomedBlockId)) {
setZoomedBlockId(null);
}
}, [blocks, zoomedBlockId]);
const { zoomedBlock, childBlocks, levelOffset } = useMemo(() => {
return getZoomedSubtree(blocks, zoomedBlockId);
}, [blocks, zoomedBlockId]);
const breadcrumbs = useMemo(() => {
return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId);
}, [title, doc?.title, blocks, zoomedBlockId]);
// Crucial fix: Collaborator with write permission can edit!
const canEdit = useMemo(() => {
if (!doc) return true;
if (doc.isOwner) return true;
return doc.permission === 'owner' || doc.permission === 'write';
}, [doc]);
// Handle incoming WebSocket events for THIS document room
const handleRemoteEvent = useCallback(
(evt: DocSyncEvent) => {
if (evt.docId && evt.docId !== docId) return;
switch (evt.type) {
case 'doc_state': {
if (evt.title !== undefined) {
const isTitleActivelyEditing =
titleInputRef.current !== null &&
document.activeElement === titleInputRef.current &&
typeof document !== 'undefined' &&
document.hasFocus();
if (!isTitleActivelyEditing) {
setTitle(evt.title);
}
}
if (Array.isArray(evt.blocks) && evt.blocks.length > 0) {
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
}
break;
}
case 'title_updated': {
if (evt.title !== undefined) {
const isTitleActivelyEditing =
titleInputRef.current !== null &&
document.activeElement === titleInputRef.current &&
typeof document !== 'undefined' &&
document.hasFocus();
if (!isTitleActivelyEditing) {
setTitle(evt.title);
}
if (docRef.current) {
const updated = { ...docRef.current, title: evt.title };
setDoc(updated);
onDocMetadataChange(updated);
}
}
break;
}
case 'block_created': {
if (!evt.block) return;
const incoming = evt.block;
setBlocks((prev) => {
if (prev.some((b) => b.id === incoming.id)) {
return prev.map((b) => (b.id === incoming.id ? incoming : b));
}
const next = [...prev];
if (evt.afterBlockId) {
const idx = next.findIndex((b) => b.id === evt.afterBlockId);
if (idx >= 0) {
next.splice(idx + 1, 0, incoming);
return ensureNonEmptyBlocks(docId, next);
}
}
next.push(incoming);
return ensureNonEmptyBlocks(docId, next);
});
break;
}
case 'block_updated': {
const targetId = evt.blockId || evt.block?.id;
if (!targetId) return;
setBlocks((prev) =>
prev.map((b) => {
if (b.id !== targetId) return b;
if (evt.block) {
return { ...b, ...evt.block };
}
return {
...b,
content: evt.content !== undefined ? evt.content : b.content,
level: evt.level !== undefined ? evt.level : b.level,
checked: evt.checked !== undefined ? evt.checked : b.checked,
collapsed: evt.collapsed !== undefined ? evt.collapsed : b.collapsed,
};
})
);
break;
}
case 'block_deleted': {
if (!evt.blockId) return;
setBlocks((prev) =>
ensureNonEmptyBlocks(
docId,
prev.filter((b) => b.id !== evt.blockId)
)
);
break;
}
case 'block_moved': {
if (!evt.blockId || evt.newIndex === undefined) return;
const targetIndex = evt.newIndex;
setBlocks((prev) => {
const fromIdx = prev.findIndex((b) => b.id === evt.blockId);
if (fromIdx === -1) return prev;
const copy = [...prev];
const [moving] = copy.splice(fromIdx, 1);
const updatedMoving: DocumentBlock = {
...moving,
level: evt.level !== undefined ? evt.level : moving.level,
};
const boundedIdx = Math.max(0, Math.min(targetIndex, copy.length));
copy.splice(boundedIdx, 0, updatedMoving);
return ensureNonEmptyBlocks(docId, copy);
});
break;
}
case 'blocks_replaced': {
if (Array.isArray(evt.blocks)) {
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
}
break;
}
}
},
[docId, onDocMetadataChange]
);
const {
status: syncStatus,
remoteCursors,
sendCursorMoved,
sendBlockCreated,
sendBlockUpdated,
sendBlockDeleted,
sendBlockMoved,
sendTitleUpdated,
} = useDocumentSync(docId, handleRemoteEvent, canEdit, currentUser);
useEffect(() => {
onSyncStatusChange?.(syncStatus);
}, [syncStatus, onSyncStatusChange]);
// Fetch isolated document state on mount
useEffect(() => {
let isCancelled = false;
if (!initialDoc) {
setLoading(true);
}
setError(null);
api
.getDocument(docId)
.then((fetched) => {
if (isCancelled) return;
setDoc(fetched);
setTitle(fetched.title || 'Unbenannte Notiz');
setBlocks(ensureNonEmptyBlocks(docId, fetched.blocks));
setLoading(false);
onDocMetadataChange(fetched);
})
.catch((err: any) => {
if (isCancelled) return;
setError(err?.message || 'Dokument konnte nicht geladen werden');
setLoading(false);
});
return () => {
isCancelled = true;
if (titleDebounceRef.current !== null) {
window.clearTimeout(titleDebounceRef.current);
titleDebounceRef.current = null;
}
};
}, [docId]);
// Extract all #tags present across the current blocks
const extractedTags = useMemo(() => {
const tagSet = new Set<string>();
for (const b of blocks) {
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
if (matches) {
for (const m of matches) {
tagSet.add(m.slice(1).toLowerCase());
}
}
}
return Array.from(tagSet);
}, [blocks]);
// Keep parent sidebar metadata in sync when blocks/tags change
useEffect(() => {
if (!docRef.current) return;
const updatedDoc: Document = {
...docRef.current,
title,
blocks,
tags: extractedTags,
updatedAt: new Date().toISOString(),
};
docRef.current = updatedDoc;
onDocMetadataChange(updatedDoc);
}, [blocks, extractedTags, title]);
// Active remote users in this room (excluding self)
const activeUsers = useMemo(() => {
const list: RemoteCursor[] = [];
const seen = new Set<string>();
for (const c of Object.values(remoteCursors)) {
const key = c.userId || c.clientId;
if (!seen.has(key)) {
seen.add(key);
list.push(c);
}
}
return list;
}, [remoteCursors]);
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!canEdit) return;
const nextTitle = e.target.value;
setTitle(nextTitle);
if (titleDebounceRef.current !== null) {
window.clearTimeout(titleDebounceRef.current);
}
titleDebounceRef.current = window.setTimeout(() => {
titleDebounceRef.current = null;
sendTitleUpdated(nextTitle);
}, 250);
};
const handleTitleBlur = () => {
if (titleDebounceRef.current !== null) {
window.clearTimeout(titleDebounceRef.current);
titleDebounceRef.current = null;
sendTitleUpdated(title);
}
};
// Flush title debouncing on window blur so background window never holds stale pending title
useEffect(() => {
const handleWindowBlur = () => {
if (titleDebounceRef.current !== null) {
window.clearTimeout(titleDebounceRef.current);
titleDebounceRef.current = null;
sendTitleUpdated(title);
}
};
window.addEventListener('blur', handleWindowBlur);
return () => window.removeEventListener('blur', handleWindowBlur);
}, [title, sendTitleUpdated]);
const handleUpdateBlock = useCallback(
(updatedBlock: DocumentBlock) => {
setBlocks((prev) =>
prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))
);
sendBlockUpdated(updatedBlock);
},
[sendBlockUpdated]
);
const handleCreateBlock = useCallback(
(newBlock: DocumentBlock, afterBlockId?: string) => {
setBlocks((prev) => {
const next = [...prev];
if (afterBlockId) {
const idx = next.findIndex((b) => b.id === afterBlockId);
if (idx >= 0) {
next.splice(idx + 1, 0, newBlock);
const normalized = ensureNonEmptyBlocks(docId, next);
sendBlockCreated(newBlock, afterBlockId, normalized);
return normalized;
}
}
next.push(newBlock);
const normalized = ensureNonEmptyBlocks(docId, next);
sendBlockCreated(newBlock, afterBlockId, normalized);
return normalized;
});
},
[docId, sendBlockCreated]
);
const handleDeleteBlock = useCallback(
(blockId: string) => {
setBlocks((prev) => {
const filtered = prev.filter((b) => b.id !== blockId);
const normalized = ensureNonEmptyBlocks(docId, filtered);
sendBlockDeleted(blockId, normalized);
return normalized;
});
},
[docId, sendBlockDeleted]
);
const handleMoveBlock = useCallback(
(blockId: string, newIndex: number, level?: number) => {
setBlocks((prev) => {
const fromIdx = prev.findIndex((b) => b.id === blockId);
if (fromIdx === -1) return prev;
const copy = [...prev];
const [moving] = copy.splice(fromIdx, 1);
const updatedMoving: DocumentBlock = {
...moving,
level: level !== undefined ? level : moving.level,
};
const boundedIdx = Math.max(0, Math.min(newIndex, copy.length));
copy.splice(boundedIdx, 0, updatedMoving);
const normalized = ensureNonEmptyBlocks(docId, copy);
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
return normalized;
});
},
[docId, sendBlockMoved]
);
const handleTogglePin = async () => {
if (!doc) return;
try {
const updated = await api.updateDocument(doc.id, { isPinned: !doc.isPinned });
setDoc(updated);
onDocMetadataChange(updated);
} catch {
// ignore
}
};
const handleSelectIcon = async (icon: string) => {
setShowEmojiPicker(false);
if (!doc || !canEdit) return;
try {
const updated = await api.updateDocument(doc.id, { icon });
setDoc(updated);
onDocMetadataChange(updated);
} catch {
// ignore
}
};
const handleExportMarkdown = () => {
const lines: string[] = [`# ${title || 'Unbenannte Notiz'}`, ''];
for (const b of blocks) {
const indent = ' '.repeat(Math.max(0, b.level || 0));
if (b.type === 'todo') {
lines.push(`${indent}- [${b.checked ? 'x' : ' '}] ${b.content}`);
} else if (b.type === 'h1') {
lines.push(`${indent}- # ${b.content}`);
} else if (b.type === 'h2') {
lines.push(`${indent}- ## ${b.content}`);
} else if (b.type === 'h3') {
lines.push(`${indent}- ### ${b.content}`);
} else if (b.type === 'code') {
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
} else if (b.type === 'divider') {
lines.push(`${indent}- ---`);
} else {
lines.push(`${indent}- ${b.content}`);
}
}
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${(title || 'notiz').toLowerCase().replace(/[^a-z0-9äöüß_-]+/gi, '-')}.md`;
a.click();
URL.revokeObjectURL(url);
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center p-12 text-muted">
<Loader2 className="w-5 h-5 animate-spin mr-2 text-accent" />
<span className="text-xs">Notiz wird geladen…</span>
</div>
);
}
if (error) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center">
<p className="text-xs text-status-err font-medium mb-2">{error}</p>
{onBackMobile && (
<button
type="button"
onClick={onBackMobile}
className="btn-action px-3 py-1.5 text-xs text-main"
>
Zurück zur Übersicht
</button>
)}
</div>
);
}
return (
<div className="flex-1 flex flex-col h-full min-h-0 bg-canvas overflow-y-auto">
{/* Top Document Action Bar */}
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-canvas border-b border-border">
<div className="flex items-center gap-2 min-w-0">
{onBackMobile && (
<button
type="button"
onClick={onBackMobile}
className="lg:hidden btn-icon"
title="Zurück zur Seitenleiste"
>
<ArrowLeft className="w-3.5 h-3.5" />
</button>
)}
{/* Breadcrumbs Navigation (Fokus-Modus) or Doc Indicator */}
{zoomedBlock && breadcrumbs.length > 0 ? (
<nav
aria-label="Breadcrumb Navigation"
className="flex items-center flex-wrap gap-1 text-xs text-muted"
>
<button
type="button"
onClick={() => setZoomedBlockId(null)}
className="hover:text-accent inline-flex items-center gap-1 font-medium transition-colors cursor-pointer"
title="Zurück zum Hauptdokument"
>
<Home className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="truncate max-w-[130px]">
{title || doc?.title || 'Dokument'}
</span>
</button>
{breadcrumbs.slice(1).map((crumb, idx) => {
const isCurrent = idx === breadcrumbs.length - 2;
return (
<React.Fragment key={crumb.id || idx}>
<ChevronRight className="w-3 h-3 text-faint shrink-0" />
{isCurrent ? (
<span
className="font-bold text-accent truncate max-w-[160px]"
title={crumb.title}
>
{crumb.title || 'Punkt'}
</span>
) : (
<button
type="button"
onClick={() => setZoomedBlockId(crumb.id)}
className="hover:text-main truncate max-w-[120px] transition-colors cursor-pointer"
title={`Zoom zu "${crumb.title}"`}
>
{crumb.title || 'Punkt'}
</button>
)}
</React.Fragment>
);
})}
</nav>
) : (
<div className="flex items-center gap-1.5 text-xs text-muted">
<FileText className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="truncate max-w-[180px] font-medium text-main">
{title || doc?.title || 'Dokument'}
</span>
</div>
)}
{/* Read-only indicator */}
{!canEdit && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] font-medium bg-status-warn/10 text-status-warn border border-status-warn/30">
<Lock className="w-3 h-3" />
Nur Lesen
</span>
)}
</div>
{/* Right Status & Action Buttons */}
<div className="flex items-center gap-1.5">
{/* Active Presence Avatars (Immer rund: rounded-full mit Überlappung) */}
{activeUsers.length > 0 && (
<div className="flex items-center -space-x-1.5 mr-1" title="Aktive Bearbeiter im Dokument">
{activeUsers.map((u) => {
const initials = (u.userName || 'A')
.split(' ')
.map((n) => n[0])
.slice(0, 2)
.join('')
.toUpperCase();
return (
<div key={u.clientId} className="relative group/avatar cursor-default">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white shadow-sm ring-2 ring-canvas"
style={{ backgroundColor: u.userColor }}
>
{initials}
</div>
{/* Tooltip */}
<div className="pointer-events-none absolute bottom-full mb-1 left-1/2 -translate-x-1/2 opacity-0 group-hover/avatar:opacity-100 transition-opacity whitespace-nowrap bg-canvas text-main text-[10px] px-1.5 py-0.5 rounded-btn border border-border shadow-md z-30">
{u.userName}
</div>
</div>
);
})}
</div>
)}
{/* Sharing Section (1:1 styling from SharedListsView) */}
{doc && (() => {
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
const sharesCount = doc.sharedWith?.length || 0;
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
const ownerDisplayName = doc.ownerName || 'Eigentümer';
return (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
</button>
) : (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
)
)}
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="btn-icon"
title="Notiz teilen und Berechtigungen"
>
<Users className="w-3.5 h-3.5" />
</button>
</div>
);
})()}
<button
type="button"
onClick={handleTogglePin}
className={`btn-icon ${
doc?.isPinned
? 'text-status-warn bg-status-warn/10 border-status-warn/30'
: ''
}`}
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
>
<Pin className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={handleExportMarkdown}
className="btn-icon"
title="Als Markdown (.md) exportieren"
>
<Download className="w-3.5 h-3.5" />
</button>
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
<button
type="button"
onClick={() => onDeleteDoc(docId)}
className="btn-icon-danger"
title="Notiz löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Main Content Area */}
<div className="max-w-4xl w-full mx-auto px-4 sm:px-8 py-5 flex-1">
{zoomedBlock ? (
/* =========================================================================
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
========================================================================= */
<div className="space-y-3">
{/* Prominent Zoomed Ast-Heading */}
<div className="flex items-start justify-between gap-3 pb-4 mb-2 border-b border-border">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-accent bg-accent-dim px-2 py-0.5 rounded-btn border border-accent/30">
<Sparkles className="w-2.5 h-2.5 text-accent animate-pulse" />
Fokus-Modus
</span>
<span className="text-[11px] text-faint hidden sm:inline">
Isolierter Ast • Zoom aktiv
</span>
</div>
<input
type="text"
value={zoomedBlock.content}
onChange={(e) => {
handleUpdateBlock({ ...zoomedBlock, content: e.target.value });
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const firstChild: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
documentId: docId,
type: 'text',
content: '',
level: levelOffset,
position: (zoomedBlock.position || 1000) + 10,
};
handleCreateBlock(firstChild, zoomedBlock.id);
}
}}
placeholder="Ast-Titel…"
readOnly={!canEdit}
className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight"
/>
</div>
<button
type="button"
onClick={() => setZoomedBlockId(null)}
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main shrink-0 mt-1"
title="Fokus-Modus beenden"
>
<Minimize2 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Fokus beenden</span>
</button>
</div>
{/* Subtree Children or Empty State */}
{childBlocks.length === 0 ? (
<div className="py-8 text-center text-xs text-faint border border-dashed border-border rounded-card my-2">
<p className="mb-2">Dieser Ast hat noch keine Unterpunkte.</p>
{canEdit && (
<button
type="button"
onClick={() => {
const firstChild: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
documentId: docId,
type: 'text',
content: '',
level: levelOffset,
position: (zoomedBlock.position || 1000) + 10,
};
handleCreateBlock(firstChild, zoomedBlock.id);
}}
className="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 text-xs text-accent font-medium hover:bg-accent/10"
>
<Plus className="w-3.5 h-3.5" />
<span>Ersten Unterpunkt anlegen (oder Enter drücken)</span>
</button>
)}
</div>
) : (
<OutlinerTree
docId={docId}
blocks={childBlocks}
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={setZoomedBlockId}
remoteCursors={remoteCursors}
sendCursorMoved={sendCursorMoved}
onUpdateBlock={handleUpdateBlock}
onCreateBlock={handleCreateBlock}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
/>
)}
</div>
) : (
/* =========================================================================
STANDARD GESAMTDOKUMENT-ANSICHT
========================================================================= */
<>
{/* Document Icon + Title Header */}
<div className="flex items-center gap-3 mb-4 relative">
<div className="relative">
<button
type="button"
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
className="text-2xl sm:text-3xl hover:opacity-80 transition-opacity cursor-pointer select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none"
title="Icon ändern"
>
{doc?.icon || '📝'}
</button>
{showEmojiPicker && canEdit && (
<div className="absolute left-0 mt-1 p-2 panel-surface shadow-panel border border-border rounded-card grid grid-cols-6 gap-1 z-30 w-52">
{EMOJI_PALETTE.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => handleSelectIcon(emoji)}
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center cursor-pointer"
>
{emoji}
</button>
))}
</div>
)}
</div>
<input
ref={titleInputRef}
type="text"
value={title}
readOnly={!canEdit}
autoFocus={autoFocusTitle}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
placeholder="Titel der Notiz…"
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight"
/>
</div>
{/* Logseq Outliner Tree */}
<OutlinerTree
docId={docId}
blocks={blocks}
canEdit={canEdit}
levelOffset={0}
onZoomBlock={setZoomedBlockId}
remoteCursors={remoteCursors}
sendCursorMoved={sendCursorMoved}
onUpdateBlock={handleUpdateBlock}
onCreateBlock={handleCreateBlock}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
/>
</>
)}
</div>
</div>
);
};
@@ -0,0 +1,308 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import {
Link as LinkIcon,
FileText,
Globe,
X,
Search,
ExternalLink,
Loader2,
Plus,
} from 'lucide-react';
import { api } from '../../lib/api';
import { Document } from '../../lib/types';
export interface LinkModalProps {
isOpen: boolean;
onClose: () => void;
onInsertLink: (markdown: string) => void;
availableDocs?: { id: string; title: string; icon?: string; folderPath?: string }[];
}
export const LinkModal: React.FC<LinkModalProps> = ({
isOpen,
onClose,
onInsertLink,
availableDocs,
}) => {
const [tab, setTab] = useState<'note' | 'web'>('note');
const [search, setSearch] = useState('');
const [docs, setDocs] = useState<Document[]>([]);
const [loading, setLoading] = useState(false);
const [webTitle, setWebTitle] = useState('');
const [webUrl, setWebUrl] = useState('');
const searchInputRef = useRef<HTMLInputElement | null>(null);
const webTitleInputRef = useRef<HTMLInputElement | null>(null);
// Load documents when modal opens
useEffect(() => {
if (!isOpen) return;
if (availableDocs && availableDocs.length > 0) {
setDocs(availableDocs as Document[]);
return;
}
let isCancelled = false;
setLoading(true);
api
.getDocuments()
.then((fetched) => {
if (!isCancelled) {
setDocs(fetched);
setLoading(false);
}
})
.catch(() => {
if (!isCancelled) {
setLoading(false);
}
});
return () => {
isCancelled = true;
};
}, [isOpen, availableDocs]);
// Focus input on mount or tab change
useEffect(() => {
if (!isOpen) return;
const timer = setTimeout(() => {
if (tab === 'note') {
searchInputRef.current?.focus();
} else {
webTitleInputRef.current?.focus();
}
}, 50);
return () => clearTimeout(timer);
}, [isOpen, tab]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
const filteredDocs = useMemo(() => {
if (!search.trim()) return docs;
const q = search.toLowerCase();
return docs.filter(
(d) =>
d.title.toLowerCase().includes(q) ||
(d.folderPath && d.folderPath.toLowerCase().includes(q))
);
}, [docs, search]);
const handleSelectNote = (doc: { title: string }) => {
const noteTitle = doc.title.trim() || 'Notiz';
onInsertLink(`[[${noteTitle}]]`);
};
const handleWebLinkSubmit = (e: React.FormEvent) => {
e.preventDefault();
let url = webUrl.trim();
if (!url) return;
if (!/^https?:\/\//i.test(url) && !url.startsWith('#') && !url.startsWith('/')) {
url = `https://${url}`;
}
const label = webTitle.trim() || url;
onInsertLink(`[${label}](${url})`);
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4 animate-in fade-in zoom-in-95 duration-150">
{/* Header - identical to UnifiedShareModal */}
<div className="flex items-center justify-between pb-3 border-b border-border">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center shrink-0">
<LinkIcon className="w-4 h-4" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-bold text-main truncate">Link einfügen</h3>
<p className="text-[11px] text-muted truncate">
Notiz intern verknüpfen oder externen Web-Link einbetten
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 text-muted hover:text-main rounded-btn hover:bg-panel transition-colors cursor-pointer"
title="Schließen"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Control Box - matching UnifiedShareModal form header */}
<div className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-main">Link-Typ:</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setTab('note')}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
tab === 'note'
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<FileText className="w-3.5 h-3.5" />
<span>Notiz</span>
</button>
<button
type="button"
onClick={() => setTab('web')}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
tab === 'web'
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Globe className="w-3.5 h-3.5" />
<span>Web-Link</span>
</button>
</div>
</div>
{tab === 'note' ? (
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none" />
<input
ref={searchInputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Notiz suchen (Titel oder Ordner)…"
className="input-field bg-panel h-8 pl-8 pr-2.5 text-xs w-full text-main placeholder:text-muted"
/>
</div>
) : (
<form onSubmit={handleWebLinkSubmit} className="space-y-2.5">
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-faint mb-1 block">
Link-Text / Label
</label>
<input
ref={webTitleInputRef}
type="text"
value={webTitle}
onChange={(e) => setWebTitle(e.target.value)}
placeholder="z. B. MindSift Dokumentation"
className="input-field bg-panel h-8 px-2.5 text-xs w-full text-main placeholder:text-muted"
/>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-faint mb-1 block">
Web-Adresse (URL) *
</label>
<input
type="text"
required
value={webUrl}
onChange={(e) => setWebUrl(e.target.value)}
placeholder="https://example.com"
className="input-field bg-panel h-8 px-2.5 text-xs w-full text-main placeholder:text-muted"
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="btn-action text-xs h-8 px-3.5 cursor-pointer"
>
Abbrechen
</button>
<button
type="submit"
disabled={!webUrl.trim()}
className="btn-primary text-xs h-8 px-3.5 flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
>
<Plus className="w-3.5 h-3.5" />
<span>Link einfügen</span>
</button>
</div>
</form>
)}
</div>
{/* Note List Section (Matching Active Shares list in UnifiedShareModal) */}
{tab === 'note' && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
<span>Verfügbare Notizen</span>
<span className="font-mono text-[11px] font-normal lowercase">
{filteredDocs.length} {filteredDocs.length === 1 ? 'Notiz' : 'Notizen'}
</span>
</div>
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-0.5">
{loading ? (
<div className="p-6 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2 justify-center text-center">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span>Notizen werden geladen…</span>
</div>
) : filteredDocs.length === 0 ? (
<div className="p-4 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2.5 justify-center text-center">
<Search className="w-4 h-4 text-faint shrink-0" />
<span>
{search
? `Keine Notiz für "${search}" gefunden.`
: 'Keine Notizen im Workspace vorhanden.'}
</span>
</div>
) : (
filteredDocs.map((d) => (
<div
key={d.id}
onClick={() => handleSelectNote(d)}
className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer"
>
<div className="flex items-center gap-2.5 min-w-0">
<span className="text-base shrink-0">{d.icon || '📝'}</span>
<div className="min-w-0">
<div className="text-xs font-semibold text-main truncate group-hover:text-accent">
{d.title || 'Unbenannte Notiz'}
</div>
{d.folderPath && (
<div className="text-[10px] text-muted truncate">
📁 {d.folderPath}
</div>
)}
</div>
</div>
<button
type="button"
className="btn-action text-xs h-7 px-2.5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 text-accent cursor-pointer shrink-0"
>
<span>Einfügen</span>
<ExternalLink className="w-3 h-3" />
</button>
</div>
))
)}
</div>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,829 @@
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from 'react';
import {
ChevronRight,
ChevronDown,
Check,
X,
GripVertical,
Code,
Heading1,
Heading2,
Heading3,
ListTodo,
Minus,
MessageSquare,
Hash,
ExternalLink,
Maximize2,
Link as LinkIcon,
FileText,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { RemoteCursor } from '../../lib/useDocumentSync';
import { LinkModal } from './LinkModal';
export interface SlashCommand {
type: BlockType | 'link';
label: string;
desc: string;
icon: React.ReactNode;
}
const SLASH_COMMANDS: SlashCommand[] = [
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
{ type: 'link', label: '/link', desc: 'Notiz verknüpfen oder Web-Link einfügen', icon: <LinkIcon className="w-3.5 h-3.5" /> },
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> },
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> },
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
{ type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: <Code className="w-3.5 h-3.5" /> },
{ type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: <MessageSquare className="w-3.5 h-3.5" /> },
{ type: 'divider', label: '/divider', desc: 'Trennlinie', icon: <Minus className="w-3.5 h-3.5" /> },
];
export interface OutlinerBlockProps {
block: DocumentBlock;
index: number;
maxAllowedLevel: number;
hasChildren: boolean;
isFocused: boolean;
canEdit: boolean;
activeRemoteCursors?: RemoteCursor[];
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
onFocusBlock: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
onDeleteBlock: (blockId: string) => void;
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
onDragStartBlock?: (index: number) => void;
onDragOverBlock?: (e: React.DragEvent, index: number) => void;
onDropBlock?: (index: number) => void;
isDragTarget?: boolean;
isTargeted?: boolean;
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
}
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
block,
index,
hasChildren,
isFocused,
canEdit,
activeRemoteCursors = [],
sendCursorMoved,
levelOffset = 0,
onZoomBlock,
onFocusBlock,
onUpdateBlock,
onCreateBlockBelow,
onDeleteBlock,
onIndentBlock,
onNavigateFocus,
onTagClick,
onWikiLinkClick,
onDragStartBlock,
onDragOverBlock,
onDropBlock,
isDragTarget,
isTargeted,
registerTextareaRef,
}) => {
const [localContent, setLocalContent] = useState<string>(block.content || '');
const [slashSearch, setSlashSearch] = useState<string | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const debounceTimerRef = useRef<number | null>(null);
const latestBlockRef = useRef<DocumentBlock>(block);
latestBlockRef.current = block;
const localContentRef = useRef<string>(localContent);
localContentRef.current = localContent;
// Callback refs to avoid stale closures in setTimeout, debounce flush, and event listeners
const onUpdateBlockRef = useRef(onUpdateBlock);
onUpdateBlockRef.current = onUpdateBlock;
const onFocusBlockRef = useRef(onFocusBlock);
onFocusBlockRef.current = onFocusBlock;
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
onCreateBlockBelowRef.current = onCreateBlockBelow;
const onDeleteBlockRef = useRef(onDeleteBlock);
onDeleteBlockRef.current = onDeleteBlock;
const onIndentBlockRef = useRef(onIndentBlock);
onIndentBlockRef.current = onIndentBlock;
const onNavigateFocusRef = useRef(onNavigateFocus);
onNavigateFocusRef.current = onNavigateFocus;
const sendCursorMovedRef = useRef(sendCursorMoved);
sendCursorMovedRef.current = sendCursorMoved;
// Track if this input currently has actual focus in an active browser window
const isFocusedRef = useRef<boolean>(false);
const flushDebounce = useCallback(
(contentToFlush: string, overrides?: Partial<DocumentBlock>) => {
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
const nextBlock: DocumentBlock = {
...latestBlockRef.current,
content: contentToFlush,
...overrides,
};
onUpdateBlockRef.current(nextBlock, true);
},
[]
);
// Sync incoming remote changes ONLY when this textarea is NOT actively being edited
// Condition: isFocusedRef.current === true && document.hasFocus()
useEffect(() => {
const isActivelyEditing =
isFocusedRef.current === true &&
typeof document !== 'undefined' &&
document.hasFocus() &&
textareaRef.current !== null &&
document.activeElement === textareaRef.current;
if (!isActivelyEditing && block.content !== localContentRef.current) {
setLocalContent(block.content || '');
localContentRef.current = block.content || '';
}
}, [block.content]);
// Handle window blur / focus to instantly flush debounced input and clear zombie focus
useEffect(() => {
const handleWindowBlur = () => {
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
flushDebounce(localContentRef.current);
} else if (localContentRef.current !== latestBlockRef.current.content) {
flushDebounce(localContentRef.current);
}
isFocusedRef.current = false;
sendCursorMovedRef.current?.(null);
};
const handleWindowFocus = () => {
const isThisActive =
textareaRef.current !== null &&
document.activeElement === textareaRef.current &&
document.hasFocus();
isFocusedRef.current = isThisActive;
};
window.addEventListener('blur', handleWindowBlur);
window.addEventListener('focus', handleWindowFocus);
return () => {
window.removeEventListener('blur', handleWindowBlur);
window.removeEventListener('focus', handleWindowFocus);
};
}, [flushDebounce]);
useEffect(() => {
return () => {
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
};
}, []);
const adjustHeight = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.max(22, el.scrollHeight)}px`;
}, []);
useLayoutEffect(() => {
adjustHeight();
}, [localContent, block.type, block.level, adjustHeight]);
useEffect(() => {
window.addEventListener('resize', adjustHeight);
return () => window.removeEventListener('resize', adjustHeight);
}, [adjustHeight]);
const filteredSlashCommands = useMemo(() => {
if (slashSearch === null) return [];
const q = slashSearch.toLowerCase();
return SLASH_COMMANDS.filter(
(c) => c.label.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)
);
}, [slashSearch]);
const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null);
const clean = localContent.replace(/\/([a-z0-9]*)$/i, '').trim();
if (cmd.type === 'link') {
setLocalContent(clean);
setShowLinkModal(true);
return;
}
setLocalContent(clean);
flushDebounce(clean, {
type: cmd.type,
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
});
};
const handleInsertLink = (linkMarkdown: string) => {
setShowLinkModal(false);
const cursor = textareaRef.current?.selectionStart ?? localContent.length;
const before = localContent.slice(0, cursor);
const after = localContent.slice(cursor);
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
setLocalContent(newContent);
flushDebounce(newContent);
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!canEdit) return;
let val = e.target.value;
let typeOverride: BlockType | undefined;
let checkedOverride: boolean | undefined;
// Quick markdown shortcuts
if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
val = val.replace(/^\[\s?\]\s/, '');
typeOverride = 'todo';
checkedOverride = false;
} else if (val.startsWith('[x] ') || val.startsWith('[X] ')) {
val = val.replace(/^\[[xX]\]\s/, '');
typeOverride = 'todo';
checkedOverride = true;
} else if (val.startsWith('# ') && block.type !== 'h1') {
val = val.slice(2);
typeOverride = 'h1';
} else if (val.startsWith('## ') && block.type !== 'h2') {
val = val.slice(3);
typeOverride = 'h2';
} else if (val.startsWith('### ') && block.type !== 'h3') {
val = val.slice(4);
typeOverride = 'h3';
}
// Check for slash command invocation
const slashMatch = val.match(/\/([a-z0-9]*)$/i);
if (slashMatch) {
setSlashSearch(slashMatch[1]);
setSlashIndex(0);
} else {
setSlashSearch(null);
}
setLocalContent(val);
sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd);
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
}
if (typeOverride) {
flushDebounce(val, {
type: typeOverride,
checked: checkedOverride ?? latestBlockRef.current.checked,
});
return;
}
debounceTimerRef.current = window.setTimeout(() => {
debounceTimerRef.current = null;
onUpdateBlockRef.current({
...latestBlockRef.current,
content: val,
});
}, 150);
};
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
isFocusedRef.current = true;
onFocusBlockRef.current(block.id);
sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd);
};
const handleBlur = () => {
isFocusedRef.current = false;
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
flushDebounce(localContentRef.current);
} else if (localContentRef.current !== latestBlockRef.current.content) {
flushDebounce(localContentRef.current);
}
sendCursorMovedRef.current?.(null);
};
const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
};
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
};
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const target = e.currentTarget;
sendCursorMovedRef.current?.(block.id, target.selectionStart, target.selectionEnd);
};
const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null;
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!canEdit) return;
// Slash command navigation
if (slashSearch !== null && filteredSlashCommands.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const selected = filteredSlashCommands[slashIndex];
if (selected) {
applySlashCommand(selected);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setSlashSearch(null);
return;
}
}
// 1. Tab / Shift+Tab -> Strict Indent / Outdent
if (e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
flushDebounce(localContent);
onIndentBlockRef.current(block.id, e.shiftKey ? -1 : 1);
return;
}
// 2. Enter (without Shift) -> Create new block on same level
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
e.preventDefault();
e.stopPropagation();
setSlashSearch(null);
flushDebounce(localContent);
onCreateBlockBelowRef.current(block.id, localContent);
return;
}
// 3. Backspace on empty block -> IMMEDIATELY DELETE THE BLOCK (regardless of level)!
// Outdenting is strictly done via Shift+Tab.
if (e.key === 'Backspace' && localContent === '') {
e.preventDefault();
e.stopPropagation();
setSlashSearch(null);
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
onDeleteBlockRef.current(block.id);
return;
}
// 4. Arrow navigation between blocks
if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
const el = textareaRef.current;
if (el && el.selectionStart === 0 && el.selectionEnd === 0) {
e.preventDefault();
flushDebounce(localContent);
onNavigateFocusRef.current(block.id, 'up');
}
return;
}
if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) {
const el = textareaRef.current;
if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) {
e.preventDefault();
flushDebounce(localContent);
onNavigateFocusRef.current(block.id, 'down');
}
return;
}
};
const level = Math.max(0, block.level || 0);
const visualLevel = Math.max(0, level - (levelOffset || 0));
// Parse rich tokens (WikiLinks [[Page]], Markdown Links [Title](url), raw URLs, and #Tags)
const tokens = useMemo(() => {
const parts = (localContent || '').split(
/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/.*?\)|\bhttps?:\/\/[^\s]+|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g
);
return parts.filter(Boolean);
}, [localContent]);
const hasSpecialTokens = tokens.some(
(t) =>
t.startsWith('#') ||
(t.startsWith('[[') && t.endsWith(']]')) ||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
/^https?:\/\//.test(t)
);
return (
<div
className={`group relative flex items-start gap-1 py-1 rounded-card transition-all duration-300 ${
isDragTarget
? 'border-t-2 border-accent bg-accent/10'
: isTargeted
? 'bg-accent/15 ring-2 ring-accent'
: isFocused
? 'bg-panel-elevated'
: 'hover:bg-panel'
}`}
style={{
paddingLeft: `${visualLevel * 22 + 4}px`,
...(primaryRemoteCursor
? { borderLeft: `2px solid ${primaryRemoteCursor.userColor}` }
: {}),
}}
onDragOver={(e) => onDragOverBlock?.(e, index)}
onDrop={() => onDropBlock?.(index)}
>
{/* Remote Cursors Floating Name Badges on Block */}
{activeRemoteCursors.length > 0 && (
<div className="absolute -top-2.5 right-2 z-20 flex items-center gap-1 select-none pointer-events-none">
{activeRemoteCursors.map((c) => (
<span
key={c.clientId}
className="text-[9px] font-bold px-1.5 py-0.5 rounded-btn text-white shadow-sm flex items-center gap-0.5"
style={{ backgroundColor: c.userColor }}
>
{c.userName}
</span>
))}
</div>
)}
{/* Indentation Guide Line */}
{visualLevel > 0 &&
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
<div
key={lvlIdx}
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-border"
style={{ left: `${lvlIdx * 22 + 12}px` }}
/>
))}
{/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */}
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
{canEdit && (
<button
type="button"
draggable
onDragStart={() => onDragStartBlock?.(index)}
title="Block ziehen oder umwandeln"
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-main cursor-grab active:cursor-grabbing transition-opacity"
>
<GripVertical className="w-3.5 h-3.5" />
</button>
)}
{hasChildren ? (
<button
type="button"
onClick={() =>
flushDebounce(localContent, { collapsed: !block.collapsed })
}
className="p-0.5 text-muted hover:text-accent transition-colors"
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
>
{block.collapsed ? (
<ChevronRight className="w-3.5 h-3.5" />
) : (
<ChevronDown className="w-3.5 h-3.5 text-accent" />
)}
</button>
) : (
<span className="w-3.5" />
)}
{block.type === 'todo' ? (
<button
type="button"
disabled={!canEdit}
onClick={() =>
flushDebounce(localContent, { checked: !block.checked })
}
className={`w-4 h-4 checkbox-square flex items-center justify-center transition-colors border ${
block.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
title={block.checked ? 'Als offen markieren' : 'Als erledigt markieren'}
>
<Check className="w-3 h-3 stroke-[3]" />
</button>
) : (
<button
type="button"
onClick={() => onZoomBlock?.(block.id)}
className="w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover group/bullet transition-transform"
title="Klick: In diesen Ast hineinzoomen (Fokus-Modus)"
>
<span
className={`w-1.5 h-1.5 rounded-full transition-all ${
block.collapsed && hasChildren
? 'bg-accent ring-2 ring-accent/30'
: isFocused
? 'bg-accent scale-125'
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
}`}
/>
</button>
)}
</div>
{/* Main Block Content */}
<div className="flex-1 min-w-0 pr-1 relative">
{block.type === 'divider' ? (
<div className="py-2">
<hr className="border-t border-border" />
</div>
) : block.type === 'code' ? (
<div className="bg-code border border-border rounded-btn p-2 my-0.5">
<div className="text-[10px] text-faint font-mono uppercase pb-1 flex justify-between">
<span>Code</span>
<button
type="button"
onClick={() => flushDebounce(localContent, { type: 'bullet' })}
className="hover:text-accent"
>
Als Text
</button>
</div>
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onClick={handleClick}
onKeyUp={handleKeyUp}
onSelect={handleSelect}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
/>
</div>
) : block.type === 'callout' ? (
<div className="bg-panel-elevated border-l-2 border-accent p-2 rounded-r-btn my-0.5">
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onClick={handleClick}
onKeyUp={handleKeyUp}
onSelect={handleSelect}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
/>
</div>
) : (
<div className="relative w-full">
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onClick={handleClick}
onKeyUp={handleKeyUp}
onSelect={handleSelect}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
placeholder={
index === 0 && level === 0
? 'Schreibe einen Punkt… (Enter = Neu, Tab = Einrücken, Shift+Tab = Ausrücken, / für Befehle)'
: ''
}
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${
block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight'
: block.type === 'h2'
? 'text-base font-semibold text-main'
: block.type === 'h3'
? 'text-sm font-semibold text-main'
: block.type === 'todo' && block.checked
? 'text-sm text-faint line-through'
: 'text-sm text-main'
}`}
/>
{/* Remote Caret & Floating Name Tag Overlay */}
{primaryRemoteCursor &&
primaryRemoteCursor.selectionStart !== undefined &&
primaryRemoteCursor.selectionStart !== null && (
<div
aria-hidden="true"
className={`pointer-events-none absolute inset-0 overflow-hidden leading-relaxed whitespace-pre-wrap break-words select-none z-10 ${
block.type === 'h1'
? 'text-lg font-bold tracking-tight'
: block.type === 'h2'
? 'text-base font-semibold'
: block.type === 'h3'
? 'text-sm font-semibold'
: block.type === 'todo' && block.checked
? 'text-sm text-faint line-through'
: 'text-sm'
}`}
>
<span className="opacity-0">
{localContent.slice(
0,
Math.min(primaryRemoteCursor.selectionStart, localContent.length)
)}
</span>
<span className="relative inline-block align-baseline">
<span
className="inline-block w-[2px] h-[1.15em] -mb-0.5 animate-pulse"
style={{ backgroundColor: primaryRemoteCursor.userColor }}
/>
<span
className="absolute -top-3.5 left-0 text-[8px] font-bold px-1 py-0 rounded-btn text-white whitespace-nowrap shadow-sm"
style={{ backgroundColor: primaryRemoteCursor.userColor }}
>
{primaryRemoteCursor.userName}
</span>
</span>
</div>
)}
</div>
)}
{/* Rich Tokens Bar (Wiki-Links & #Tags) if present */}
{hasSpecialTokens && !isFocused && (
<div className="flex flex-wrap items-center gap-1 mt-0.5">
{tokens.map((part, idx) => {
if (part.startsWith('#') && part.length > 1) {
const tag = part.slice(1);
return (
<button
key={idx}
type="button"
onClick={() => onTagClick?.(tag)}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-accent bg-accent-dim hover:bg-accent/20 rounded-btn transition-colors"
>
<Hash className="w-2.5 h-2.5 opacity-80" />
<span>{tag}</span>
</button>
);
}
if (part.startsWith('[[') && part.endsWith(']]')) {
const page = part.slice(2, -2).trim();
return (
<button
key={idx}
type="button"
onClick={() => onWikiLinkClick?.(page)}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-info bg-status-info/10 hover:bg-status-info/20 rounded-btn transition-colors"
title={`Notiz öffnen: ${page}`}
>
<FileText className="w-2.5 h-2.5 opacity-80" />
<span>{page}</span>
</button>
);
}
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
if (mdMatch) {
const label = mdMatch[1];
const url = mdMatch[2];
return (
<a
key={idx}
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
title={url}
>
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
<span>{label || url}</span>
</a>
);
}
if (/^https?:\/\//.test(part)) {
return (
<a
key={idx}
href={part}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
title={part}
>
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
<span className="truncate max-w-[160px]">{part}</span>
</a>
);
}
return null;
})}
</div>
)}
{/* Floating Slash Commands Menu */}
{slashSearch !== null && canEdit && (
<div className="absolute left-0 top-8 z-40 w-56 panel-surface shadow-panel border border-border rounded-card p-1">
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
Befehl auswählen
</div>
<div className="max-h-48 overflow-y-auto space-y-0.5">
{filteredSlashCommands.map((cmd, i) => (
<button
key={cmd.type}
type="button"
onMouseDown={(e) => {
e.preventDefault();
applySlashCommand(cmd);
}}
className={`w-full text-left px-2 py-1 rounded-btn text-xs flex items-center justify-between transition-colors ${
i === slashIndex
? 'bg-accent text-on-accent'
: 'text-main hover:bg-panel-elevated'
}`}
>
<div className="flex items-center gap-1.5">
{cmd.icon}
<span className="font-semibold">{cmd.label}</span>
</div>
<span className="text-[10px] opacity-75">{cmd.desc}</span>
</button>
))}
{filteredSlashCommands.length === 0 && (
<div className="p-2 text-center text-xs text-faint">
Kein Befehl gefunden
</div>
)}
</div>
</div>
)}
</div>
{/* Zoom / Focus button on hover */}
<button
type="button"
onClick={() => onZoomBlock?.(block.id)}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In diesen Ast hineinzoomen (Fokus-Modus)"
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
{/* Delete button on hover */}
{canEdit && (
<button
type="button"
onClick={() => onDeleteBlockRef.current(block.id)}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0"
title="Block löschen"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{/* Link Insertion Modal */}
{showLinkModal && (
<LinkModal
isOpen={showLinkModal}
onClose={() => setShowLinkModal(false)}
onInsertLink={handleInsertLink}
/>
)}
</div>
);
};
@@ -0,0 +1,382 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Plus } from 'lucide-react';
import { DocumentBlock } from '../../lib/types';
import { RemoteCursor } from '../../lib/useDocumentSync';
import { OutlinerBlock } from './OutlinerBlock';
export interface OutlinerTreeProps {
docId: string;
blocks: DocumentBlock[];
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
remoteCursors?: Record<string, RemoteCursor>;
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
onDeleteBlock: (blockId: string) => void;
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
initialFocusedBlockId?: string | null;
}
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
docId,
blocks,
canEdit,
levelOffset = 0,
onZoomBlock,
remoteCursors,
sendCursorMoved,
onUpdateBlock,
onCreateBlock,
onDeleteBlock,
onMoveBlock,
onTagClick,
onWikiLinkClick,
initialFocusedBlockId,
}) => {
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
initialFocusedBlockId || null
);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' } | null>(null);
const focusBlockInput = useCallback(
(blockId: string, cursor: 'start' | 'end' | 'select' = 'end') => {
setFocusedBlockId(blockId);
const el = textareaMapRef.current.get(blockId);
if (el) {
requestAnimationFrame(() => {
el.focus();
try {
if (cursor === 'select') {
el.select();
} else {
const pos = cursor === 'end' ? el.value.length : 0;
el.setSelectionRange(pos, pos);
}
} catch {
// ignore
}
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
} else {
pendingFocusRef.current = { blockId, cursor };
}
},
[]
);
const registerTextareaRef = useCallback((blockId: string, el: HTMLTextAreaElement | null) => {
if (el) {
textareaMapRef.current.set(blockId, el);
if (pendingFocusRef.current && pendingFocusRef.current.blockId === blockId) {
const target = pendingFocusRef.current;
pendingFocusRef.current = null;
requestAnimationFrame(() => {
setFocusedBlockId(blockId);
el.focus();
try {
if (target.cursor === 'select') {
el.select();
} else {
const pos = target.cursor === 'end' ? el.value.length : 0;
el.setSelectionRange(pos, pos);
}
} catch {
// ignore
}
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
} else {
textareaMapRef.current.delete(blockId);
}
}, []);
useEffect(() => {
if (initialFocusedBlockId) {
setHighlightedBlockId(initialFocusedBlockId);
focusBlockInput(initialFocusedBlockId, 'select');
const timer = setTimeout(() => {
focusBlockInput(initialFocusedBlockId, 'select');
}, 70);
const timer2 = setTimeout(() => {
focusBlockInput(initialFocusedBlockId, 'select');
}, 200);
const fadeTimer = setTimeout(() => {
setHighlightedBlockId(null);
}, 2800);
return () => {
clearTimeout(timer);
clearTimeout(timer2);
clearTimeout(fadeTimer);
};
}
}, [initialFocusedBlockId, blocks.length, focusBlockInput]);
// Compute visibility based on collapsed ancestor blocks
const visibleEntries = useMemo(() => {
const result: {
block: DocumentBlock;
index: number;
hasChildren: boolean;
maxAllowedLevel: number;
}[] = [];
let collapsedAboveLevel: number | null = null;
for (let i = 0; i < blocks.length; i++) {
const b = blocks[i];
const lvl = Math.max(0, b.level || 0);
if (collapsedAboveLevel !== null) {
if (lvl > collapsedAboveLevel) {
continue;
} else {
collapsedAboveLevel = null;
}
}
const nextBlock = blocks[i + 1];
const nextLvl = nextBlock ? Math.max(0, nextBlock.level || 0) : 0;
const hasChildren = Boolean(nextBlock && nextLvl > lvl);
const prevBlock = i > 0 ? blocks[i - 1] : null;
const maxAllowedLevel = prevBlock ? Math.max(0, prevBlock.level || 0) + 1 : (levelOffset || 0);
result.push({
block: b,
index: i,
hasChildren,
maxAllowedLevel,
});
if (b.collapsed && hasChildren) {
collapsedAboveLevel = lvl;
}
}
return result;
}, [blocks]);
const handleCreateBlockBelow = useCallback(
(afterBlockId: string, _currentContent: string) => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === afterBlockId);
const currentBlock = idx >= 0 ? blocks[idx] : null;
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
const inheritedType =
currentBlock && currentBlock.type === 'todo' ? 'todo' : 'text';
const newBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
documentId: docId,
type: inheritedType,
content: '',
level,
checked: false,
collapsed: false,
position: ((idx >= 0 ? idx + 2 : blocks.length + 1) * 1000),
};
pendingFocusRef.current = { blockId: newBlock.id, cursor: 'start' };
setFocusedBlockId(newBlock.id);
onCreateBlock(newBlock, afterBlockId);
},
[blocks, canEdit, docId, onCreateBlock]
);
const handleDeleteBlock = useCallback(
(blockId: string) => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
// If this is the only block in the document (and not in an isolated zoom subtree), clear instead of deleting
if (blocks.length <= 1 && !levelOffset) {
const only = blocks[0];
onUpdateBlock(
{
...only,
content: '',
type: 'text',
level: 0,
checked: false,
},
true
);
return;
}
const targetFocusBlock = idx > 0 ? blocks[idx - 1] : blocks[idx + 1];
if (targetFocusBlock) {
focusBlockInput(targetFocusBlock.id, 'end');
}
onDeleteBlock(blockId);
},
[blocks, canEdit, focusBlockInput, onDeleteBlock, onUpdateBlock]
);
const handleIndentBlock = useCallback(
(blockId: string, delta: 1 | -1) => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
const current = blocks[idx];
const currentLevel = Math.max(0, current.level || 0);
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 0;
const maxLevel = idx === 0 ? 0 : prevLevel + 1;
const minLevel = levelOffset || 0;
const nextLevel =
delta === 1
? Math.min(maxLevel, currentLevel + 1)
: Math.max(minLevel, currentLevel - 1);
if (nextLevel === currentLevel) return;
onUpdateBlock(
{
...current,
level: nextLevel,
},
true
);
},
[blocks, canEdit, onUpdateBlock]
);
const handleNavigateFocus = useCallback(
(blockId: string, direction: 'up' | 'down') => {
const visIdx = visibleEntries.findIndex((e) => e.block.id === blockId);
if (visIdx === -1) return;
const targetEntry =
direction === 'up' ? visibleEntries[visIdx - 1] : visibleEntries[visIdx + 1];
if (targetEntry) {
focusBlockInput(
targetEntry.block.id,
direction === 'up' ? 'end' : 'start'
);
}
},
[focusBlockInput, visibleEntries]
);
const handleDragStartBlock = useCallback((index: number) => {
setDraggedIndex(index);
}, []);
const handleDragOverBlock = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverIndex(index);
}, []);
const handleDropBlock = useCallback(
(targetIndex: number) => {
if (draggedIndex === null || draggedIndex === targetIndex) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
}
const movingBlock = blocks[draggedIndex];
if (movingBlock) {
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
}
setDraggedIndex(null);
setDragOverIndex(null);
},
[blocks, draggedIndex, onMoveBlock]
);
useEffect(() => {
const clearDrag = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
window.addEventListener('dragend', clearDrag);
return () => window.removeEventListener('dragend', clearDrag);
}, []);
const cursorsByBlock = useMemo(() => {
const map: Record<string, RemoteCursor[]> = {};
if (!remoteCursors) return map;
for (const c of Object.values(remoteCursors)) {
if (c.blockId) {
if (!map[c.blockId]) map[c.blockId] = [];
map[c.blockId].push(c);
}
}
return map;
}, [remoteCursors]);
return (
<div className="space-y-0.5">
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => (
<OutlinerBlock
key={block.id}
block={block}
index={index}
maxAllowedLevel={maxAllowedLevel}
hasChildren={hasChildren}
isFocused={focusedBlockId === block.id}
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={onZoomBlock}
activeRemoteCursors={cursorsByBlock[block.id] || []}
sendCursorMoved={sendCursorMoved}
onFocusBlock={setFocusedBlockId}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onDeleteBlock={handleDeleteBlock}
onIndentBlock={handleIndentBlock}
onNavigateFocus={handleNavigateFocus}
onTagClick={onTagClick}
onWikiLinkClick={onWikiLinkClick}
onDragStartBlock={handleDragStartBlock}
onDragOverBlock={handleDragOverBlock}
onDropBlock={handleDropBlock}
isDragTarget={dragOverIndex === index && draggedIndex !== index}
isTargeted={highlightedBlockId === block.id}
registerTextareaRef={registerTextareaRef}
/>
))}
{canEdit && (
<div className="pt-2 pl-2">
<button
type="button"
onClick={() => {
const lastBlock = blocks[blocks.length - 1];
if (lastBlock) {
handleCreateBlockBelow(lastBlock.id, lastBlock.content);
} else {
const firstBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_0`,
documentId: docId,
type: 'text',
content: '',
level: levelOffset || 0,
position: 1000,
};
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
onCreateBlock(firstBlock);
}
}}
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main"
>
<Plus className="w-3.5 h-3.5" />
<span>Neuer Punkt</span>
</button>
</div>
)}
</div>
);
};
File diff suppressed because it is too large. Load diff
+131 -265
View File
@@ -602,31 +602,6 @@ export const api = {
};
},
async getDocumentCRDT(id: string): Promise<{ docId: string; state?: string; updates?: string[] }> {
return request<{ docId: string; state?: string; updates?: string[] }>(`/api/v1/documents/${id}/crdt`);
},
async saveDocumentCRDT(id: string, payload: string | { state?: string; update?: string }): Promise<{ success: boolean }> {
const body = typeof payload === 'string' ? { state: payload } : payload;
return request<{ success: boolean }>(`/api/v1/documents/${id}/crdt`, {
method: 'PUT',
body: JSON.stringify(body),
});
},
async sendDocumentAwareness(id: string, payload: any): Promise<{ success: boolean }> {
return request<{ success: boolean }>(`/api/v1/documents/${id}/awareness`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
async clearDocumentAwareness(id: string): Promise<{ success: boolean }> {
return request<{ success: boolean }>(`/api/v1/documents/${id}/awareness`, {
method: 'DELETE',
});
},
async joinSSERoom(sseId: string, room: string, action: 'join' | 'leave' = 'join'): Promise<{ success: boolean }> {
return request<{ success: boolean }>('/api/v1/events/rooms', {
method: 'POST',
@@ -675,44 +650,6 @@ export const api = {
return ensureArray(res);
},
// --- DOCUMENT LOCKING ---
async getDocLock(docId: string): Promise<{ locked: boolean; lock?: DocLockInfo }> {
return request<{ locked: boolean; lock?: DocLockInfo }>(`/api/v1/documents/${docId}/lock`);
},
async lockDocument(
docId: string
): Promise<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }> {
try {
return await request<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }>(
`/api/v1/documents/${docId}/lock`,
{ method: 'POST' }
);
} catch (e: any) {
return { locked: true, acquired: false, message: e.message };
}
},
async heartbeatDocLock(docId: string): Promise<{ extended: boolean }> {
try {
return await request<{ extended: boolean }>(`/api/v1/documents/${docId}/lock/heartbeat`, {
method: 'POST',
});
} catch {
return { extended: false };
}
},
async unlockDocument(docId: string): Promise<{ unlocked: boolean }> {
try {
return await request<{ unlocked: boolean }>(`/api/v1/documents/${docId}/lock`, {
method: 'DELETE',
});
} catch {
return { unlocked: false };
}
},
// --- SPACES & REALTIME LISTS ---
async getSpaces(): Promise<Space[]> {
const res = await request<Space[]>('/api/v1/spaces');
@@ -1140,6 +1077,11 @@ export function onRealtimeStatus(callback: (connected: boolean, transport: Realt
};
}
let isWsBlockedByProxy = false;
let sseReconnectTimer: any = null;
let sseReconnectDelay = 2000;
const globalSubscribedRooms = new Set<string>();
function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTransport) {
isRealtimeConnectedState = connected;
activeTransport = transport;
@@ -1154,20 +1096,32 @@ function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTrans
}
export function connectSSE(rooms?: string[]): void {
if (rooms) {
rooms.forEach((r) => globalSubscribedRooms.add(r));
}
clearTimeout(sseReconnectTimer);
clearTimeout(wsReconnectTimer);
clearTimeout(wsConnectionWatchdog);
if (activeGlobalSSE) {
try { activeGlobalSSE.close(); } catch {}
activeGlobalSSE = null;
}
try {
const url = buildSSEURL(undefined, undefined, rooms);
const url = buildSSEURL(undefined, undefined, Array.from(globalSubscribedRooms));
const es = new EventSource(url);
activeGlobalSSE = es;
es.onopen = () => {
console.info('[MindSift Realtime] Server-Sent Events (SSE) verbunden (Proxy-Fallback aktiv).');
wsReconnectDelay = 2000;
sseReconnectDelay = 2000;
notifyRealtimeStatusChange(true, 'sse');
if (activeSSEId && globalSubscribedRooms.size > 0) {
globalSubscribedRooms.forEach((room) => {
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
});
}
};
es.onmessage = (event) => {
@@ -1175,6 +1129,11 @@ export function connectSSE(rooms?: string[]): void {
const msg = JSON.parse(event.data) as WSMessage;
if (msg.event === 'SSE_CONNECTED') {
activeSSEId = msg.payload?.sseId || null;
if (activeSSEId && globalSubscribedRooms.size > 0) {
globalSubscribedRooms.forEach((room) => {
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
});
}
}
wsListeners.forEach((fn) => {
try {
@@ -1190,17 +1149,24 @@ export function connectSSE(rooms?: string[]): void {
};
es.onerror = (e) => {
console.warn('[MindSift Realtime] SSE getrennt oder Fehler aufgetreten:', e);
if (activeTransport === 'sse') {
notifyRealtimeStatusChange(false, 'none');
}
try { es.close(); } catch {}
activeGlobalSSE = null;
if (!manualDisconnect && getToken()) {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = setTimeout(() => {
connectWebSocket();
}, 5000);
clearTimeout(sseReconnectTimer);
const delay = sseReconnectDelay;
sseReconnectDelay = Math.min(30000, Math.round(sseReconnectDelay * 1.5));
sseReconnectTimer = setTimeout(() => {
if (!manualDisconnect && getToken()) {
if (isWsBlockedByProxy) {
connectSSE();
} else {
connectWebSocket();
}
}
}, delay);
}
};
} catch (err) {
@@ -1214,11 +1180,19 @@ export function connectWebSocket(): void {
if (!token) return;
manualDisconnect = false;
// If proxy blocked WebSocket previously, do not attempt failing WebSocket; stay on SSE
if (isWsBlockedByProxy) {
connectSSE();
return;
}
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
return;
}
clearTimeout(wsConnectionWatchdog);
clearTimeout(wsReconnectTimer);
clearTimeout(sseReconnectTimer);
try {
const url = buildWebSocketURL();
@@ -1229,6 +1203,7 @@ export function connectWebSocket(): void {
wsConnectionWatchdog = setTimeout(() => {
if (ws && ws.readyState !== WebSocket.OPEN) {
console.warn('[MindSift Realtime] WebSocket connection timeout (Proxy Upgrade Block). Wechsle automatisch zu SSE...');
isWsBlockedByProxy = true;
try { ws.close(); } catch {}
activeGlobalWS = null;
connectSSE();
@@ -1243,6 +1218,13 @@ export function connectWebSocket(): void {
}
wsReconnectDelay = 2000;
notifyRealtimeStatusChange(true, 'ws');
if (globalSubscribedRooms.size > 0) {
globalSubscribedRooms.forEach((room) => {
try {
ws.send(JSON.stringify({ event: 'JOIN_ROOM', room }));
} catch {}
});
}
};
ws.onmessage = (event) => {
@@ -1264,11 +1246,11 @@ export function connectWebSocket(): void {
ws.onclose = (evt: CloseEvent) => {
clearTimeout(wsConnectionWatchdog);
activeGlobalWS = null;
console.warn('[MindSift Global WS] Closed with code:', evt?.code, 'reason:', evt?.reason);
const isProxyIssue = evt?.code === 1006 || evt?.code === 1002 || evt?.code === 1008;
if (isProxyIssue && !manualDisconnect && getToken()) {
console.warn('[MindSift Realtime] Proxy/Firewall Inkompatibilität erkannt (Code ' + evt.code + '). Wechsle zu SSE...');
console.warn('[MindSift Realtime] Proxy/Firewall Inkompatibilität erkannt (Code ' + evt.code + '). Wechsle dauerhaft zu SSE...');
isWsBlockedByProxy = true;
connectSSE();
return;
}
@@ -1282,21 +1264,22 @@ export function connectWebSocket(): void {
const delay = wsReconnectDelay;
wsReconnectDelay = Math.min(30000, Math.round(wsReconnectDelay * 1.5));
wsReconnectTimer = setTimeout(() => {
connectWebSocket();
if (!manualDisconnect && getToken()) {
connectWebSocket();
}
}, delay);
}
};
ws.onerror = (e) => {
clearTimeout(wsConnectionWatchdog);
console.warn('[MindSift Global WS] Error:', e);
try {
ws.close();
} catch {}
};
} catch (e) {
clearTimeout(wsConnectionWatchdog);
console.warn('Failed to connect WebSocket, switching to SSE:', e);
isWsBlockedByProxy = true;
connectSSE();
}
}
@@ -1305,12 +1288,20 @@ export function connectWebSocket(): void {
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
if (getToken() && !isRealtimeConnected()) {
connectWebSocket();
if (isWsBlockedByProxy) {
connectSSE();
} else {
connectWebSocket();
}
}
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && getToken() && !isRealtimeConnected()) {
connectWebSocket();
if (isWsBlockedByProxy) {
connectSSE();
} else {
connectWebSocket();
}
}
});
}
@@ -1319,6 +1310,7 @@ export function disconnectWebSocket(): void {
manualDisconnect = true;
clearTimeout(wsReconnectTimer);
clearTimeout(wsConnectionWatchdog);
clearTimeout(sseReconnectTimer);
if (activeGlobalWS) {
try {
activeGlobalWS.close();
@@ -1343,19 +1335,10 @@ export function sendWebSocketMessage(msg: any): void {
// SSE Fallback: dispatch via REST endpoints
try {
const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg;
if (parsed.event === 'CRDT_UPDATE' && parsed.docId) {
api.saveDocumentCRDT(parsed.docId, { update: parsed.payload?.update });
} else if (parsed.event === 'CRDT_AWARENESS' && parsed.docId) {
api.sendDocumentAwareness(parsed.docId, parsed.payload);
} else if (parsed.event === 'CRDT_LEAVE' && parsed.docId) {
api.clearDocumentAwareness(parsed.docId);
if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'leave');
} else if (parsed.event === 'CRDT_JOIN' && parsed.docId) {
if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'join');
} else if (parsed.event === 'LOCK_DOC' && parsed.docId) {
api.lockDocument(parsed.docId);
} else if (parsed.event === 'UNLOCK_DOC' && parsed.docId) {
api.unlockDocument(parsed.docId);
if (parsed.event === 'JOIN_ROOM' && parsed.room) {
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'join').catch(() => {});
} else if (parsed.event === 'LEAVE_ROOM' && parsed.room) {
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'leave').catch(() => {});
}
} catch (err) {
console.error('Error dispatching fallback REST message for SSE:', err);
@@ -1377,196 +1360,79 @@ export function createRealtimeConnection(options: {
onStatusChange?: (status: { connected: boolean; transport: RealtimeTransport; reason?: string }) => void;
}) {
let isClosed = false;
let currentTransport: RealtimeTransport = 'none';
let ws: WebSocket | null = null;
let es: EventSource | null = null;
let watchdogTimer: any = null;
let reconnectTimer: any = null;
let reconnectDelay = 2000;
let sseId: string | null = null;
const notify = (connected: boolean, transport: RealtimeTransport, reason?: string) => {
currentTransport = transport;
options.onStatusChange?.({ connected, transport, reason });
};
const joinedRooms: string[] = [];
if (options.docId) joinedRooms.push(`doc:${options.docId}`);
if (options.listId) joinedRooms.push(options.listId);
if (options.rooms) {
options.rooms.forEach((r) => {
if (!joinedRooms.includes(r)) joinedRooms.push(r);
});
}
const connectSSE = () => {
joinedRooms.forEach((r) => globalSubscribedRooms.add(r));
// Subscribe to status changes
const unsubStatus = onRealtimeStatus((connected, transport) => {
if (isClosed) return;
if (es) {
try { es.close(); } catch {}
es = null;
}
options.onStatusChange?.({
connected,
transport,
reason: connected ? undefined : 'Verbindung getrennt',
});
});
try {
const allRooms = [...(options.rooms || [])];
if (options.docId) allRooms.push(`doc:${options.docId}`);
if (options.listId) allRooms.push(options.listId);
const url = buildSSEURL(options.listId, options.docId, allRooms);
es = new EventSource(url);
// Subscribe to messages
let unsubMsg: (() => void) | null = null;
if (options.onMessage) {
unsubMsg = onWebSocketMessage((msg) => {
if (isClosed) return;
options.onMessage?.(msg);
});
}
es.onopen = () => {
if (isClosed) return;
reconnectDelay = 2000;
notify(true, 'sse');
};
es.onmessage = (evt) => {
if (isClosed) return;
try {
const msg = JSON.parse(evt.data) as WSMessage;
if (msg.event === 'SSE_CONNECTED') {
sseId = msg.payload?.sseId || null;
}
options.onMessage?.(msg);
} catch (e) {
console.error('SSE JSON error', e);
}
};
es.onerror = (e) => {
if (isClosed) return;
notify(false, 'none', 'SSE-Verbindungsabbruch');
try { es?.close(); } catch {}
es = null;
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
if (!isClosed) connectWS();
}, 5000);
};
} catch (e: any) {
notify(false, 'none', e?.message || 'SSE Initialisierungsfehler');
}
// Join rooms on the active connection
const joinAll = () => {
joinedRooms.forEach((r) => {
if (r.startsWith('doc:')) {
sendWebSocketMessage({ event: 'CRDT_JOIN', docId: r.slice(4) });
} else {
sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
}
});
};
const connectWS = () => {
if (isRealtimeConnected()) {
joinAll();
}
const unsubRejoin = onRealtimeStatus((connected) => {
if (isClosed) return;
const token = getToken();
if (!token) {
notify(false, 'none', 'Kein Authentifizierungs-Token vorhanden');
return;
if (connected) {
joinAll();
}
if (ws) {
try { ws.close(); } catch {}
ws = null;
}
clearTimeout(watchdogTimer);
try {
const url = buildWebSocketURL(options.listId);
ws = new WebSocket(url);
// 3.5s watchdog for corporate proxies silently discarding Upgrade header
watchdogTimer = setTimeout(() => {
if (ws && ws.readyState !== WebSocket.OPEN && !isClosed) {
console.warn('[MindSift Realtime] WebSocket connection timeout (Proxy Upgrade block). Falling back to SSE...');
try { ws.close(); } catch {}
ws = null;
connectSSE();
}
}, 3500);
ws.onopen = () => {
if (isClosed) return;
clearTimeout(watchdogTimer);
reconnectDelay = 2000;
notify(true, 'ws');
if (options.docId) {
try {
ws?.send(JSON.stringify({ event: 'CRDT_JOIN', docId: options.docId }));
} catch {}
}
};
ws.onmessage = (evt) => {
if (isClosed) return;
try {
const msg = JSON.parse(evt.data) as WSMessage;
options.onMessage?.(msg);
} catch (e) {
console.error('WS JSON error', e);
}
};
ws.onclose = (evt: CloseEvent) => {
if (isClosed) return;
clearTimeout(watchdogTimer);
ws = null;
const isProxyError = evt?.code === 1006 || evt?.code === 1002 || evt?.code === 1008;
if (isProxyError) {
console.warn(`[MindSift Realtime] Proxy WebSocket rejection detected (Code ${evt.code}). Falling back to SSE...`);
connectSSE();
return;
}
const reason = evt.reason || (evt.code === 1000 ? 'Normal geschlossen' : `Code ${evt.code}`);
notify(false, 'none', reason);
clearTimeout(reconnectTimer);
const delay = reconnectDelay;
reconnectDelay = Math.min(30000, Math.round(reconnectDelay * 1.5));
reconnectTimer = setTimeout(connectWS, delay);
};
ws.onerror = (e) => {
if (isClosed) return;
clearTimeout(watchdogTimer);
try { ws?.close(); } catch {}
};
} catch (e: any) {
clearTimeout(watchdogTimer);
console.warn('[MindSift Realtime] WebSocket constructor failed, trying SSE fallback:', e);
connectSSE();
}
};
connectWS();
});
return {
getTransport: () => currentTransport,
isConnected: () => currentTransport !== 'none',
getTransport: () => activeTransport,
isConnected: () => isRealtimeConnected(),
send: (msg: any) => {
const str = typeof msg === 'string' ? msg : JSON.stringify(msg);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(str);
return;
}
// SSE Fallback: dispatch via REST endpoints
try {
const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg;
if (parsed.event === 'CRDT_UPDATE' && parsed.docId) {
api.saveDocumentCRDT(parsed.docId, { update: parsed.payload?.update });
} else if (parsed.event === 'CRDT_AWARENESS' && parsed.docId) {
api.sendDocumentAwareness(parsed.docId, parsed.payload);
} else if (parsed.event === 'CRDT_LEAVE' && parsed.docId) {
api.clearDocumentAwareness(parsed.docId);
if (sseId) api.joinSSERoom(sseId, `doc:${parsed.docId}`, 'leave');
} else if (parsed.event === 'CRDT_JOIN' && parsed.docId) {
if (sseId) api.joinSSERoom(sseId, `doc:${parsed.docId}`, 'join');
} else if (parsed.event === 'LOCK_DOC' && parsed.docId) {
api.lockDocument(parsed.docId);
} else if (parsed.event === 'UNLOCK_DOC' && parsed.docId) {
api.unlockDocument(parsed.docId);
}
} catch (err) {
console.error('Error dispatching fallback REST message for connection:', err);
}
if (isClosed) return;
sendWebSocketMessage(msg);
},
close: () => {
isClosed = true;
clearTimeout(watchdogTimer);
clearTimeout(reconnectTimer);
if (ws) {
try { ws.close(); } catch {}
ws = null;
}
if (es) {
try { es.close(); } catch {}
es = null;
}
notify(false, 'none');
unsubStatus();
unsubRejoin();
if (unsubMsg) unsubMsg();
joinedRooms.forEach((r) => {
globalSubscribedRooms.delete(r);
if (r.startsWith('doc:')) {
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: r.slice(4) });
} else {
sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
}
});
},
};
}
-271
View File
@@ -1,271 +0,0 @@
import * as Y from 'yjs';
import { BlockType, DocumentBlock } from './types';
// Convert Uint8Array to Base64
export function uint8ArrayToBase64(bytes: Uint8Array): string {
let binary = '';
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// Convert Base64 to Uint8Array
export function base64ToUint8Array(base64: string): Uint8Array {
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
const COLLAB_COLORS = [
'#3B82F6', // Blue
'#10B981', // Emerald
'#F59E0B', // Amber
'#8B5CF6', // Purple
'#EC4899', // Pink
'#06B6D4', // Cyan
'#F97316', // Orange
'#14B8A6', // Teal
];
export function getCollaboratorColor(userId: string): string {
if (!userId) return COLLAB_COLORS[0];
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = (hash << 5) - hash + userId.charCodeAt(i);
hash |= 0;
}
const idx = Math.abs(hash) % COLLAB_COLORS.length;
return COLLAB_COLORS[idx];
}
export interface CollaboratorAwareness {
userId: string;
userName: string;
activeBlockId: string | null;
color: string;
lastActive: number;
}
export class DocumentCRDTSession {
public docId: string;
public doc: Y.Doc;
private order: Y.Array<string>;
private blocks: Y.Map<Y.Map<any>>;
private destroyed: boolean = false;
public onLocalUpdate?: (base64Update: string) => void;
public onRemoteChange?: (blocks: DocumentBlock[]) => void;
constructor(docId: string) {
this.docId = docId;
this.doc = new Y.Doc({ guid: docId });
this.order = this.doc.getArray<string>('blockOrder');
this.blocks = this.doc.getMap<Y.Map<any>>('blocks');
this.doc.on('update', (update: Uint8Array, origin: any) => {
if (this.destroyed) return;
if (origin !== 'remote') {
const b64 = uint8ArrayToBase64(update);
if (this.onLocalUpdate) {
this.onLocalUpdate(b64);
}
} else {
if (this.onRemoteChange) {
this.onRemoteChange(this.getBlocks());
}
}
});
}
public destroy(): void {
this.destroyed = true;
this.doc.destroy();
}
public getBlocks(): DocumentBlock[] {
const orderList = this.order.toArray();
const result: DocumentBlock[] = [];
for (const id of orderList) {
const bMap = this.blocks.get(id);
if (bMap) {
result.push({
id,
docId: this.docId,
type: (bMap.get('type') || 'bullet') as BlockType,
content: bMap.get('content') || '',
level: typeof bMap.get('level') === 'number' ? bMap.get('level') : 0,
checked: !!bMap.get('checked'),
collapsed: !!bMap.get('collapsed'),
lang: bMap.get('lang') || '',
sortOrder: typeof bMap.get('sortOrder') === 'number' ? bMap.get('sortOrder') : 0,
updatedAt: bMap.get('updatedAt') || undefined,
});
}
}
return result;
}
public initFromBlocks(initialBlocks: DocumentBlock[]): void {
this.doc.transact(() => {
if (this.order.length === 0 && initialBlocks.length > 0) {
const ids: string[] = [];
for (const b of initialBlocks) {
ids.push(b.id);
const bMap = new Y.Map<any>();
bMap.set('id', b.id);
bMap.set('type', b.type || 'bullet');
bMap.set('content', b.content || '');
bMap.set('level', b.level || 0);
bMap.set('checked', !!b.checked);
bMap.set('collapsed', !!b.collapsed);
bMap.set('lang', b.lang || '');
bMap.set('sortOrder', b.sortOrder || 0);
bMap.set('updatedAt', b.updatedAt || new Date().toISOString());
this.blocks.set(b.id, bMap);
}
this.order.push(ids);
}
}, 'init');
}
public updateBlock(id: string, updates: Partial<DocumentBlock>): void {
this.doc.transact(() => {
const bMap = this.blocks.get(id);
if (bMap) {
if (updates.type !== undefined) bMap.set('type', updates.type);
if (updates.content !== undefined) bMap.set('content', updates.content);
if (updates.level !== undefined) bMap.set('level', updates.level);
if (updates.checked !== undefined) bMap.set('checked', updates.checked);
if (updates.collapsed !== undefined) bMap.set('collapsed', updates.collapsed);
if (updates.lang !== undefined) bMap.set('lang', updates.lang);
if (updates.sortOrder !== undefined) bMap.set('sortOrder', updates.sortOrder);
bMap.set('updatedAt', new Date().toISOString());
}
}, 'local');
}
public insertBlock(index: number, block: DocumentBlock): void {
this.doc.transact(() => {
const bMap = new Y.Map<any>();
bMap.set('id', block.id);
bMap.set('type', block.type || 'bullet');
bMap.set('content', block.content || '');
bMap.set('level', block.level || 0);
bMap.set('checked', !!block.checked);
bMap.set('collapsed', !!block.collapsed);
bMap.set('lang', block.lang || '');
bMap.set('sortOrder', block.sortOrder || 0);
bMap.set('updatedAt', new Date().toISOString());
this.blocks.set(block.id, bMap);
if (index >= 0 && index <= this.order.length) {
this.order.insert(index, [block.id]);
} else {
this.order.push([block.id]);
}
}, 'local');
}
public deleteBlock(id: string): void {
this.doc.transact(() => {
const currentOrder = this.order.toArray();
const idx = currentOrder.indexOf(id);
if (idx !== -1) {
this.order.delete(idx, 1);
}
this.blocks.delete(id);
}, 'local');
}
public deleteMultipleBlocks(ids: string[]): void {
if (ids.length === 0) return;
const toDeleteSet = new Set(ids);
this.doc.transact(() => {
const currentOrder = this.order.toArray();
// Delete from order backwards to preserve indices
for (let i = currentOrder.length - 1; i >= 0; i--) {
if (toDeleteSet.has(currentOrder[i])) {
this.order.delete(i, 1);
}
}
for (const id of ids) {
this.blocks.delete(id);
}
}, 'local');
}
public reorderBlocks(newOrder: string[]): void {
this.doc.transact(() => {
this.order.delete(0, this.order.length);
this.order.push(newOrder);
}, 'local');
}
public syncFullBlocks(blocks: DocumentBlock[]): void {
this.doc.transact(() => {
const nextIds = blocks.map((b) => b.id);
const currentIds = new Set(this.order.toArray());
// Upsert all blocks
for (const b of blocks) {
let bMap = this.blocks.get(b.id);
if (!bMap) {
bMap = new Y.Map<any>();
this.blocks.set(b.id, bMap);
}
bMap.set('id', b.id);
bMap.set('type', b.type || 'bullet');
bMap.set('content', b.content || '');
bMap.set('level', b.level || 0);
bMap.set('checked', !!b.checked);
bMap.set('collapsed', !!b.collapsed);
bMap.set('lang', b.lang || '');
bMap.set('sortOrder', b.sortOrder || 0);
bMap.set('updatedAt', b.updatedAt || new Date().toISOString());
}
// Delete blocks no longer present
const nextIdSet = new Set(nextIds);
currentIds.forEach((id) => {
if (!nextIdSet.has(id)) {
this.blocks.delete(id);
}
});
// Update order if different
const currentOrder = this.order.toArray();
const orderChanged =
currentOrder.length !== nextIds.length || currentOrder.some((id, idx) => id !== nextIds[idx]);
if (orderChanged) {
this.order.delete(0, this.order.length);
this.order.push(nextIds);
}
}, 'local');
}
public applyRemoteUpdate(base64Update: string): void {
try {
const bytes = base64ToUint8Array(base64Update);
Y.applyUpdate(this.doc, bytes, 'remote');
} catch (err) {
console.error('[CRDT] Failed to apply remote update:', err);
}
}
public applyRemoteUpdates(base64Updates: string[]): void {
for (const b64 of base64Updates) {
this.applyRemoteUpdate(b64);
}
}
public encodeState(): string {
const bytes = Y.encodeStateAsUpdate(this.doc);
return uint8ArrayToBase64(bytes);
}
}
+5 -1
View File
@@ -153,16 +153,18 @@ export interface SystemSettings {
smtpConfiguredViaEnv?: boolean;
}
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
export interface DocumentBlock {
id: string;
docId?: string;
documentId?: string;
type: BlockType;
content: string;
checked?: boolean;
lang?: string;
level?: number;
position?: number;
collapsed?: boolean;
sortOrder?: number;
updatedAt?: string;
@@ -211,6 +213,8 @@ export interface Document {
spaceId?: string;
title: string;
icon: string;
folderId?: string;
markdownCache?: string;
content: string;
blocks: DocumentBlock[];
tags: string[];
+553
View File
@@ -0,0 +1,553 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { DocumentBlock } from './types';
import { getToken, api } from './api';
export type DocSyncStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline';
export interface RemoteCursor {
clientId: string;
userId: string;
userName: string;
userColor: string;
blockId: string | null;
selectionStart?: number;
selectionEnd?: number;
updatedAt: number;
}
export const CURSOR_COLORS = [
'#3b82f6', // blue
'#10b981', // emerald
'#f59e0b', // amber
'#ec4899', // pink
'#8b5cf6', // purple
'#06b6d4', // cyan
'#f97316', // orange
'#14b8a6', // teal
'#6366f1', // indigo
];
export function getUserColor(idOrName: string): string {
if (!idOrName) return CURSOR_COLORS[0];
let hash = 0;
for (let i = 0; i < idOrName.length; i++) {
hash = (hash << 5) - hash + idOrName.charCodeAt(i);
hash |= 0;
}
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
}
export interface DocSyncEvent {
type:
| 'doc_state'
| 'block_created'
| 'block_updated'
| 'block_deleted'
| 'block_moved'
| 'title_updated'
| 'blocks_replaced'
| 'cursor_moved'
| 'client_left';
docId?: string;
clientId?: string;
userId?: string;
userName?: string;
userColor?: string;
selectionStart?: number;
selectionEnd?: number;
activeCursors?: RemoteCursor[];
title?: string;
block?: DocumentBlock;
blockId?: string;
afterBlockId?: string;
newIndex?: number;
content?: string;
level?: number;
checked?: boolean;
collapsed?: boolean;
blockType?: string;
blocks?: DocumentBlock[];
}
export function useDocumentSync(
docId: string | null,
onRemoteEvent: (evt: DocSyncEvent) => void,
canEdit: boolean = true,
currentUser?: { id: string; email?: string; displayName?: string } | null
) {
const [status, setStatus] = useState<DocSyncStatus>('connecting');
const [remoteCursors, setRemoteCursors] = useState<Record<string, RemoteCursor>>({});
const wsRef = useRef<WebSocket | null>(null);
const clientIdRef = useRef<string>(
`cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
);
const onRemoteEventRef = useRef(onRemoteEvent);
onRemoteEventRef.current = onRemoteEvent;
const docIdRef = useRef(docId);
docIdRef.current = docId;
const canEditRef = useRef(canEdit);
canEditRef.current = canEdit;
const currentUserRef = useRef(currentUser);
currentUserRef.current = currentUser;
const reconnectTimerRef = useRef<number | null>(null);
const fallbackTimerRef = useRef<number | null>(null);
const localUserName =
currentUser?.displayName || currentUser?.email?.split('@')[0] || 'Anonym';
const localUserColor = useMemo(
() => getUserColor(currentUser?.id || clientIdRef.current),
[currentUser?.id]
);
const localUserNameRef = useRef(localUserName);
localUserNameRef.current = localUserName;
const localUserColorRef = useRef(localUserColor);
localUserColorRef.current = localUserColor;
const pendingMessagesQueueRef = useRef<string[]>([]);
const lastCursorSendRef = useRef<number>(0);
const pendingCursorTimerRef = useRef<number | null>(null);
const pendingCursorPayloadRef = useRef<{
blockId: string | null;
selectionStart?: number;
selectionEnd?: number;
} | null>(null);
// Clean up stale remote cursors (> 30s)
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
setRemoteCursors((prev) => {
let hasStale = false;
const next: Record<string, RemoteCursor> = {};
for (const [key, cursor] of Object.entries(prev)) {
if (now - cursor.updatedAt < 30000) {
next[key] = cursor;
} else {
hasStale = true;
}
}
return hasStale ? next : prev;
});
}, 5000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (!docId) {
setStatus('offline');
setRemoteCursors({});
pendingMessagesQueueRef.current = [];
return;
}
pendingMessagesQueueRef.current = [];
let isDisposed = false;
let retryCount = 0;
const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
clientIdRef.current = currentClientId;
const connect = () => {
if (isDisposed) return;
const token = getToken();
if (!token) {
setStatus('offline');
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(
docId
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(
currentClientId
)}&userName=${encodeURIComponent(localUserName)}&userColor=${encodeURIComponent(
localUserColor
)}`;
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
let ws: WebSocket;
try {
ws = new WebSocket(wsUrl);
} catch {
setStatus('offline');
return;
}
wsRef.current = ws;
ws.onopen = () => {
if (isDisposed) {
ws.close();
return;
}
retryCount = 0;
setStatus('connected');
// Flush any queued messages buffered while connecting or reconnecting
if (pendingMessagesQueueRef.current.length > 0) {
const queue = [...pendingMessagesQueueRef.current];
pendingMessagesQueueRef.current = [];
for (const msg of queue) {
try {
ws.send(msg);
} catch (err) {
console.warn('[DocSync] Failed to flush queued message:', err);
}
}
}
};
ws.onmessage = (event) => {
if (isDisposed) return;
try {
const parsed: DocSyncEvent = JSON.parse(event.data);
if (!parsed || !parsed.type) return;
// Echo protection: ignore events originating from our own clientId (except initial doc_state)
if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) {
return;
}
if (parsed.type === 'doc_state') {
if (Array.isArray(parsed.activeCursors)) {
const initialCursors: Record<string, RemoteCursor> = {};
for (const c of parsed.activeCursors) {
if (c.clientId && c.clientId !== currentClientId) {
initialCursors[c.clientId] = {
...c,
updatedAt: c.updatedAt || Date.now(),
};
}
}
setRemoteCursors(initialCursors);
}
} else if (parsed.type === 'cursor_moved') {
if (parsed.clientId && parsed.clientId !== currentClientId) {
setRemoteCursors((prev) => ({
...prev,
[parsed.clientId!]: {
clientId: parsed.clientId!,
userId: parsed.userId || '',
userName: parsed.userName || 'Anonym',
userColor: parsed.userColor || '#3b82f6',
blockId:
parsed.blockId !== undefined
? parsed.blockId
: prev[parsed.clientId!]?.blockId ?? null,
selectionStart: parsed.selectionStart,
selectionEnd: parsed.selectionEnd,
updatedAt: Date.now(),
},
}));
}
} else if (parsed.type === 'client_left') {
if (parsed.clientId) {
setRemoteCursors((prev) => {
if (!prev[parsed.clientId!]) return prev;
const next = { ...prev };
delete next[parsed.clientId!];
return next;
});
}
}
onRemoteEventRef.current(parsed);
} catch {
// Ignore malformed frames
}
};
ws.onerror = () => {
// Handled in onclose
};
ws.onclose = () => {
if (isDisposed) return;
wsRef.current = null;
retryCount += 1;
setStatus(retryCount > 2 ? 'offline' : 'reconnecting');
const delay = Math.min(1000 * Math.pow(1.6, retryCount), 8000);
reconnectTimerRef.current = window.setTimeout(connect, delay);
};
};
connect();
return () => {
isDisposed = true;
if (reconnectTimerRef.current !== null) {
window.clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (fallbackTimerRef.current !== null) {
window.clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}
if (pendingCursorTimerRef.current !== null) {
window.clearTimeout(pendingCursorTimerRef.current);
pendingCursorTimerRef.current = null;
}
if (wsRef.current) {
try {
wsRef.current.onopen = null;
wsRef.current.onmessage = null;
wsRef.current.onerror = null;
wsRef.current.onclose = null;
wsRef.current.close();
} catch {
// ignore
}
wsRef.current = null;
}
};
}, [docId, localUserName, localUserColor]);
const executeSendCursor = useCallback(
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
const ws = wsRef.current;
const activeDocId = docIdRef.current;
if (!activeDocId || !ws || ws.readyState !== WebSocket.OPEN) return;
const payload: DocSyncEvent = {
type: 'cursor_moved',
docId: activeDocId,
clientId: clientIdRef.current,
userId: currentUserRef.current?.id,
userName: localUserNameRef.current,
userColor: localUserColorRef.current,
blockId: blockId || undefined,
selectionStart,
selectionEnd,
};
try {
ws.send(JSON.stringify(payload));
} catch {
// Ephemeral cursor, ignore send failure
}
lastCursorSendRef.current = Date.now();
},
[]
);
const sendCursorMoved = useCallback(
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
if (!docIdRef.current) return;
const now = Date.now();
const elapsed = now - lastCursorSendRef.current;
// When leaving focus / blur (blockId === null), send immediately
if (blockId === null) {
if (pendingCursorTimerRef.current !== null) {
window.clearTimeout(pendingCursorTimerRef.current);
pendingCursorTimerRef.current = null;
}
pendingCursorPayloadRef.current = null;
executeSendCursor(null);
return;
}
// Max once per 100ms
if (elapsed >= 100) {
if (pendingCursorTimerRef.current !== null) {
window.clearTimeout(pendingCursorTimerRef.current);
pendingCursorTimerRef.current = null;
}
executeSendCursor(blockId, selectionStart, selectionEnd);
} else {
pendingCursorPayloadRef.current = { blockId, selectionStart, selectionEnd };
if (pendingCursorTimerRef.current === null) {
pendingCursorTimerRef.current = window.setTimeout(() => {
pendingCursorTimerRef.current = null;
if (pendingCursorPayloadRef.current) {
executeSendCursor(
pendingCursorPayloadRef.current.blockId,
pendingCursorPayloadRef.current.selectionStart,
pendingCursorPayloadRef.current.selectionEnd
);
pendingCursorPayloadRef.current = null;
}
}, 100 - elapsed);
}
}
},
[executeSendCursor]
);
const sendRawEvent = useCallback(
(evt: DocSyncEvent, fallbackFn?: () => void) => {
const activeDocId = docIdRef.current;
const activeCanEdit = canEditRef.current;
if (!activeDocId || !activeCanEdit) return;
const ws = wsRef.current;
const payload: DocSyncEvent = {
...evt,
docId: activeDocId,
clientId: clientIdRef.current,
};
const jsonStr = JSON.stringify(payload);
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(jsonStr);
} catch (err) {
console.warn('[DocSync] Failed to send WebSocket message, buffering:', err);
pendingMessagesQueueRef.current.push(jsonStr);
}
} else {
console.warn(
`[DocSync] WebSocket not OPEN (state=${ws ? ws.readyState : 'null'}). Buffering event:`,
evt.type
);
pendingMessagesQueueRef.current.push(jsonStr);
if (fallbackFn) {
if (fallbackTimerRef.current !== null) {
window.clearTimeout(fallbackTimerRef.current);
}
fallbackTimerRef.current = window.setTimeout(() => {
fallbackFn();
}, 600);
}
}
},
[]
);
const sendBlockCreated = useCallback(
(block: DocumentBlock, afterBlockId?: string, allBlocks?: DocumentBlock[]) => {
sendRawEvent(
{
type: 'block_created',
block,
afterBlockId,
},
() => {
if (docId && allBlocks) {
api
.patchDocumentBlocks(docId, {
upserted: [block],
blockOrder: allBlocks.map((b) => b.id),
})
.catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendBlockUpdated = useCallback(
(block: DocumentBlock) => {
sendRawEvent(
{
type: 'block_updated',
blockId: block.id,
block,
},
() => {
if (docId) {
api.patchDocumentBlocks(docId, { upserted: [block] }).catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendBlockDeleted = useCallback(
(blockId: string, allBlocks?: DocumentBlock[]) => {
sendRawEvent(
{
type: 'block_deleted',
blockId,
},
() => {
if (docId) {
api
.patchDocumentBlocks(docId, {
deletedIds: [blockId],
blockOrder: allBlocks ? allBlocks.map((b) => b.id) : undefined,
})
.catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendBlockMoved = useCallback(
(blockId: string, newIndex: number, level?: number, allBlocks?: DocumentBlock[]) => {
sendRawEvent(
{
type: 'block_moved',
blockId,
newIndex,
level,
},
() => {
if (docId && allBlocks) {
api
.patchDocumentBlocks(docId, {
upserted: allBlocks,
blockOrder: allBlocks.map((b) => b.id),
})
.catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendTitleUpdated = useCallback(
(title: string) => {
sendRawEvent(
{
type: 'title_updated',
title,
},
() => {
if (docId) {
api.updateDocument(docId, { title }).catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
const sendBlocksReplaced = useCallback(
(blocks: DocumentBlock[]) => {
sendRawEvent(
{
type: 'blocks_replaced',
blocks,
},
() => {
if (docId) {
api.updateDocument(docId, { blocks }).catch(() => {});
}
}
);
},
[docId, sendRawEvent]
);
return {
status,
clientId: clientIdRef.current,
remoteCursors,
sendCursorMoved,
sendBlockCreated,
sendBlockUpdated,
sendBlockDeleted,
sendBlockMoved,
sendTitleUpdated,
sendBlocksReplaced,
};
}
+129 -192
View File
@@ -3,11 +3,11 @@ package api
import (
"bytes"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"net/smtp"
"strings"
@@ -32,7 +32,7 @@ type Server struct {
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
if wsHub != nil && store != nil {
wsHub.SetCRDTStore(store)
wsHub.SetDocStore(store)
}
srv := &Server{
cfg: cfg,
@@ -49,6 +49,7 @@ func (s *Server) Router() http.Handler {
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(s.apiLoggingMiddleware)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
@@ -69,6 +70,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/ws", s.handleWebSocket)
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
r.Get("/api/v1/ws/docs/{id}", s.handleDocWebSocket)
r.Get("/api/v1/events", s.handleSSE)
r.Post("/api/v1/events/rooms", s.handleSSERooms)
@@ -141,18 +143,6 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare)
r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare)
// Document Locking
r.Get("/api/v1/documents/{id}/lock", s.handleGetDocLock)
r.Post("/api/v1/documents/{id}/lock", s.handleLockDoc)
r.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock)
r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc)
// Document CRDT Realtime Sync
r.Get("/api/v1/documents/{id}/crdt", s.handleGetDocCRDT)
r.Put("/api/v1/documents/{id}/crdt", s.handleSaveDocCRDT)
r.Post("/api/v1/documents/{id}/awareness", s.handlePostDocAwareness)
r.Delete("/api/v1/documents/{id}/awareness", s.handleDeleteDocAwareness)
// Shared Lists & Realtime Collaboration
r.Get("/api/v1/lists", s.handleGetAllLists)
r.Post("/api/v1/lists", s.handleCreateList)
@@ -251,6 +241,54 @@ func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func (s *Server) apiLoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only log API requests (/api/...)
if !strings.HasPrefix(r.URL.Path, "/api") {
next.ServeHTTP(w, r)
return
}
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
duration := time.Since(start)
durationMs := float64(duration.Microseconds()) / 1000.0
status := ww.Status()
if status == 0 {
status = http.StatusOK
}
method := r.Method
path := r.URL.Path
if status >= 500 {
slog.Error("HTTP request failed",
"method", method,
"path", path,
"status", status,
"duration_ms", durationMs,
)
} else if status >= 400 {
slog.Warn("HTTP request warning",
"method", method,
"path", path,
"status", status,
"duration_ms", durationMs,
)
} else {
slog.Debug("HTTP request completed",
"method", method,
"path", path,
"status", status,
"duration_ms", durationMs,
)
}
})
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Check Authorization Bearer Token
@@ -848,7 +886,6 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent(doc.ID, "DOC_CREATED", doc)
s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED")
writeJSON(w, http.StatusCreated, doc)
}
@@ -861,6 +898,18 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
if s.wsHub != nil {
if liveTitle, liveBlocks, ok := s.wsHub.GetActiveDocState(docID); ok {
if liveTitle != "" {
doc.Title = liveTitle
}
if len(liveBlocks) > 0 {
doc.Blocks = liveBlocks
doc.MarkdownCache = domain.CompileBlocksToMarkdown(liveBlocks)
doc.Content = doc.MarkdownCache
}
}
}
writeJSON(w, http.StatusOK, doc)
}
@@ -868,16 +917,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
// Check document lock
if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID {
w.WriteHeader(http.StatusLocked)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Dokument wird aktuell bearbeitet von " + lock.UserName,
"currentLock": lock,
})
return
}
var req struct {
Title *string `json:"title"`
Icon *string `json:"icon"`
@@ -896,8 +935,10 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if s.wsHub != nil && (req.Title != nil || req.Blocks != nil) {
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
}
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
}
writeJSON(w, http.StatusOK, doc)
@@ -907,16 +948,6 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
// Check document lock
if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID {
w.WriteHeader(http.StatusLocked)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Dokument wird aktuell bearbeitet von " + lock.UserName,
"currentLock": lock,
})
return
}
var req domain.DocBlocksPatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
@@ -928,6 +959,9 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusBadRequest, err.Error())
return
}
if s.wsHub != nil {
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
}
patchEvent := domain.DocBlocksPatchEvent{
DocID: docID,
@@ -950,11 +984,18 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID)
if s.wsHub != nil {
s.wsHub.CloseDocRoom(docID)
}
if err := s.store.DeleteDocument(userID, docID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
for _, uid := range authorizedUsers {
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID})
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
@@ -1163,6 +1204,11 @@ func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request)
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", share)
if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil {
for _, docID := range docIDs {
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
}
}
writeJSON(w, http.StatusCreated, share)
}
@@ -1170,11 +1216,16 @@ func (s *Server) handleDeleteFolderShare(w http.ResponseWriter, r *http.Request)
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
docIDs, _ := s.store.GetSharedFolderDocIDs(folderID)
if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "shareId": shareID})
for _, docID := range docIDs {
s.wsHub.BroadcastToUser(shareID, "DOC_DELETED", map[string]string{"id": docID})
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
@@ -1324,175 +1375,61 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, users)
}
// --- DOCUMENT LOCKING ---
// --- DOCUMENT REALTIME WEBSOCKET ROOM ---
func (s *Server) handleGetDocLock(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
lock := s.wsHub.GetDocLock(docID)
if lock == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": false})
return
func (s *Server) authenticateUserFromReq(r *http.Request) string {
token := r.URL.Query().Get("token")
if token == "" {
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
token = strings.TrimPrefix(authHdr, "Bearer ")
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": true, "lock": lock})
if token != "" {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
return claims.UserID
}
}
return ""
}
func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
// GET /api/v1/ws/docs/{id}
func (s *Server) handleDocWebSocket(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Sec-Websocket-Key") != "" {
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
r.Header.Set("Connection", "Upgrade")
}
if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") {
r.Header.Set("Upgrade", "websocket")
}
}
docID := chi.URLParam(r, "id")
user, err := s.store.GetUserByID(userID)
userName := "Ein Benutzer"
if err == nil && user != nil && user.DisplayName != "" {
userName = user.DisplayName
}
info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 20*time.Second)
if !acquired {
w.WriteHeader(http.StatusLocked)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"locked": true,
"acquired": false,
"currentLock": currentLock,
"message": "Wird aktuell bearbeitet von " + currentLock.UserName,
})
if docID == "" {
writeError(w, http.StatusBadRequest, "docId erforderlich")
return
}
s.wsHub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
writeJSON(w, http.StatusOK, map[string]interface{}{
"locked": true,
"acquired": true,
"lock": info,
})
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
userID = auth.GetUserID(r.Context())
}
if userID == "" {
userID = "anonymous"
}
func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
ok := s.wsHub.HeartbeatDocLock(docID, userID, 20*time.Second)
writeJSON(w, http.StatusOK, map[string]bool{"extended": ok})
}
func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
user, _ := s.store.GetUserByID(userID)
isAdmin := user != nil && user.Role == "admin"
released := s.wsHub.ReleaseDocLock(docID, userID, isAdmin)
if released {
s.wsHub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
}
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released})
}
// --- DOCUMENT CRDT REALTIME SYNC ---
func (s *Server) handleGetDocCRDT(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
if _, err := s.store.GetDocumentByID(userID, docID); err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
stateBlob, updates, err := s.store.GetDocumentCRDTState(docID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
b64State := ""
if len(stateBlob) > 0 {
b64State = base64.StdEncoding.EncodeToString(stateBlob)
}
var b64Updates []string
for _, u := range updates {
b64Updates = append(b64Updates, base64.StdEncoding.EncodeToString(u))
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"docId": docID,
"state": b64State,
"updates": b64Updates,
})
}
func (s *Server) handleSaveDocCRDT(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
doc, err := s.store.GetDocumentByID(userID, docID)
if err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
if !doc.IsOwner && doc.Permission != "write" {
writeError(w, http.StatusForbidden, "Keine Schreibberechtigung")
return
}
var req struct {
State string `json:"state"`
Update string `json:"update"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
return
}
if req.Update != "" {
raw, err := base64.StdEncoding.DecodeString(req.Update)
canWrite := false
if userID != "anonymous" {
doc, err := s.store.GetDocumentByID(userID, docID)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges Base64 Format für Update")
writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument")
return
}
if err := s.store.AppendDocumentCRDTUpdate(docID, raw, userID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastRoom("doc:"+docID, "CRDT_UPDATE", map[string]interface{}{
"docId": docID,
"update": req.Update,
"fromUserId": userID,
}, nil)
}
if req.State != "" {
raw, err := base64.StdEncoding.DecodeString(req.State)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges Base64 Format")
return
}
if err := s.store.SaveDocumentCRDTState(docID, raw, userID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handlePostDocAwareness(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
doc, err := s.store.GetDocumentByID(userID, docID)
if err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
_ = doc
var payload json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
canWrite = doc.IsOwner || doc.Permission == "owner" || doc.Permission == "write"
}
s.wsHub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS", payload, nil)
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleDeleteDocAwareness(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
s.wsHub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
"docId": docID,
"userId": userID,
}, nil)
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
clientID := r.URL.Query().Get("clientId")
room := s.wsHub.GetOrCreateDocRoom(docID)
room.ServeDocWS(w, r, userID, clientID, canWrite)
}
// --- REALTIME LISTS & SHARING ---
+214 -76
View File
@@ -841,9 +841,9 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
}
}
func TestDocumentCRDTEndpoints(t *testing.T) {
func TestDocRoomLiveStateAndDebouncedFlush(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "crdt_api_test.db")
dbPath := filepath.Join(tmpDir, "docroom_test.db")
store, err := storage.NewStore(dbPath)
if err != nil {
@@ -859,59 +859,76 @@ func TestDocumentCRDTEndpoints(t *testing.T) {
srv := NewServer(cfg, store, hub, nil)
router := srv.Router()
user, err := store.CreateUser("crdt_api@example.com", "hash", "CRDT API User", "member")
user, err := store.CreateUser("outliner_ws@example.com", "hash", "WS Outliner User", "member")
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role)
doc, err := store.CreateDocument(user.ID, "", "API CRDT Doc", "📄", "", nil, nil, false)
doc, err := store.CreateDocument(user.ID, "", "Initial Title", "📄", "", nil, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
// 1. Initial GET /api/v1/documents/{id}/crdt
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID+"/crdt", nil)
// 1. Mutate in-memory DocRoom before 1.5s flush fires
room := hub.GetOrCreateDocRoom(doc.ID)
newContent := "Live RAM Block #ramtag"
newLevel := 1
room.HandleEvent(ws.DocSyncEvent{
Type: "title_updated",
ClientID: "cli_1",
Title: "RAM Updated Title",
}, nil)
room.HandleEvent(ws.DocSyncEvent{
Type: "block_created",
ClientID: "cli_1",
AfterBlockID: doc.Blocks[0].ID,
Block: &domain.DocumentBlock{
ID: "blk_ram_2",
Type: "text",
Content: newContent,
Level: newLevel,
},
}, nil)
// 2. Verify GET /api/v1/documents/{id} immediately returns the un-flushed in-memory state
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from GET crdt, got %d: %s", rec.Code, rec.Body.String())
t.Fatalf("expected 200 OK from GET document, got %d: %s", rec.Code, rec.Body.String())
}
var liveDoc domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &liveDoc)
if liveDoc.Title != "RAM Updated Title" {
t.Fatalf("expected live RAM title 'RAM Updated Title', got '%s'", liveDoc.Title)
}
if len(liveDoc.Blocks) != 2 || liveDoc.Blocks[1].Content != newContent {
t.Fatalf("expected 2 live blocks with RAM content, got %+v", liveDoc.Blocks)
}
// 2. PUT /api/v1/documents/{id}/crdt with base64 state
savePayload := []byte(`{"state":"AQIDBA=="}`)
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID+"/crdt", bytes.NewReader(savePayload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
// 3. Flush room to SQLite and verify persistence + tag extraction + markdown_cache
room.Flush()
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from PUT crdt, got %d: %s", rec.Code, rec.Body.String())
persistedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
if err != nil {
t.Fatalf("GetDocumentByID failed: %v", err)
}
// 3. Verify state persisted
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID+"/crdt", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from GET crdt, got %d", rec.Code)
if persistedDoc.Title != "RAM Updated Title" {
t.Fatalf("expected persisted title 'RAM Updated Title', got '%s'", persistedDoc.Title)
}
var crdtResp struct {
DocID string `json:"docId"`
State string `json:"state"`
if !strings.Contains(persistedDoc.MarkdownCache, " - Live RAM Block #ramtag") {
t.Fatalf("expected compiled markdown_cache to contain indented bullet, got:\n%s", persistedDoc.MarkdownCache)
}
_ = json.Unmarshal(rec.Body.Bytes(), &crdtResp)
if crdtResp.State != "AQIDBA==" {
t.Fatalf("expected state 'AQIDBA==', got '%s'", crdtResp.State)
tags, _ := store.GetWorkspaceTags(user.ID)
if len(tags) != 1 || tags[0].Tag != "ramtag" {
t.Fatalf("expected extracted #ramtag in block_tags, got %+v", tags)
}
}
func TestSSEAndAwarenessEndpoints(t *testing.T) {
func TestSSEGlobalEndpoint(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test_sse.db")
store, err := storage.NewStore(dbPath)
if err != nil {
@@ -920,7 +937,6 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
defer store.Close()
hub := ws.NewHub()
hub.SetCRDTStore(store)
cfg := &config.Config{
JWTSecret: "test-jwt-secret-key",
AllowRegistration: false,
@@ -928,7 +944,6 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
srv := NewServer(cfg, store, hub, nil)
router := srv.Router()
// 1. Setup user
setupBody := []byte(`{
"email": "sse_user@example.com",
"password": "secretpassword",
@@ -951,51 +966,13 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
_ = json.Unmarshal(rec.Body.Bytes(), &authResp)
token := authResp.Token
// 2. Create Document
doc, err := store.CreateDocument(authResp.User.ID, "", "SSE Collaborative Note", "📄", "", nil, nil, false)
if err != nil {
t.Fatalf("failed to create document: %v", err)
}
// 3. Test Awareness POST and DELETE
awarenessBody := []byte(`{"userId":"` + authResp.User.ID + `","activeBlockId":"blk_123"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/awareness", bytes.NewReader(awarenessBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from POST awareness, got %d: %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodDelete, "/api/v1/documents/"+doc.ID+"/awareness", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from DELETE awareness, got %d", rec.Code)
}
// 4. Test Delta CRDT Update via PUT /api/v1/documents/{id}/crdt
updateBody := []byte(`{"update":"AQIDBA=="}`)
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID+"/crdt", bytes.NewReader(updateBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from PUT crdt update, got %d: %s", rec.Code, rec.Body.String())
}
// 5. Test SSE Connection GET /api/v1/events
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req = httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+token+"&docId="+doc.ID, nil).WithContext(ctx)
req = httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+token, nil).WithContext(ctx)
rec = httptest.NewRecorder()
go func() {
// Cancel context after short moment to terminate stream cleanly
time.Sleep(50 * time.Millisecond)
cancel()
}()
@@ -1006,12 +983,173 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
if !strings.Contains(contentType, "text/event-stream") {
t.Fatalf("expected Content-Type text/event-stream, got %s", contentType)
}
if rec.Header().Get("X-Accel-Buffering") != "no" {
t.Fatalf("expected X-Accel-Buffering: no, got %s", rec.Header().Get("X-Accel-Buffering"))
}
bodyStr := rec.Body.String()
if !strings.Contains(bodyStr, "SSE_CONNECTED") {
t.Fatalf("expected SSE_CONNECTED event in body, got: %s", bodyStr)
}
}
func TestDecoupledFolderSharingAPI(t *testing.T) {
srv, store, tokenA := setupTestServer(t)
router := srv.Router()
userB, err := store.CreateUser("user_b@example.com", "hash", "User B", "member")
if err != nil {
t.Fatalf("failed to create user B: %v", err)
}
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName)
if err != nil {
t.Fatalf("failed to generate token B: %v", err)
}
// 1. User A creates a shared folder "TeamProjekt"
shPayload := map[string]string{"name": "TeamProjekt", "mountPath": "TeamProjekt"}
b, _ := json.Marshal(shPayload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/folders/shared", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create shared folder failed: %s", rec.Body.String())
}
var shFolder domain.SharedFolder
_ = json.Unmarshal(rec.Body.Bytes(), &shFolder)
// 2. User A creates a document inside "TeamProjekt"
docPayload := map[string]interface{}{
"title": "Spezifikation",
"folderPath": "TeamProjekt",
"blocks": []domain.DocumentBlock{
{ID: "blk_1", Type: "text", Content: "Projekt Details", Level: 0, Position: 1000},
},
}
b, _ = json.Marshal(docPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create doc failed: %s", rec.Body.String())
}
var createdDoc domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &createdDoc)
// 3. User A shares the folder with User B (write permission)
sharePayload := map[string]interface{}{
"userId": userB.ID,
"targetType": "user",
"permission": "write",
}
b, _ = json.Marshal(sharePayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/workspace/folders/shared/"+shFolder.ID+"/shares", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("share folder failed: %s", rec.Body.String())
}
// 4. User B gets their documents list -> must see the document in "TeamProjekt"
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents", nil)
req.Header.Set("Authorization", "Bearer "+tokenB)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("get docs userB failed: %s", rec.Body.String())
}
var docsB []domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docsB)
var foundDocB *domain.Document
for i := range docsB {
if docsB[i].ID == createdDoc.ID {
foundDocB = &docsB[i]
break
}
}
if foundDocB == nil {
t.Fatalf("User B did not see shared document!")
}
if foundDocB.FolderPath != "TeamProjekt" {
t.Fatalf("expected User B initial folderPath 'TeamProjekt', got %q", foundDocB.FolderPath)
}
// 5. User B moves their mount point to "MeineOrdner/TeamProjekt"
movePayload := map[string]string{
"sourcePath": "TeamProjekt",
"targetParentPath": "MeineOrdner",
}
b, _ = json.Marshal(movePayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/folders/move", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenB)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User B move folder failed: %s", rec.Body.String())
}
// 6. DECOUPLED CHECK:
// User B's document path must now be "MeineOrdner/TeamProjekt"
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
req.Header.Set("Authorization", "Bearer "+tokenB)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User B get doc failed: %s", rec.Body.String())
}
var docBAfterMove domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docBAfterMove)
if docBAfterMove.FolderPath != "MeineOrdner/TeamProjekt" {
t.Fatalf("expected User B folderPath 'MeineOrdner/TeamProjekt', got %q", docBAfterMove.FolderPath)
}
// User A's document path must STILL be "TeamProjekt" (completely decoupled!)
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
req.Header.Set("Authorization", "Bearer "+tokenA)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User A get doc failed: %s", rec.Body.String())
}
var docAAfterMove domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docAAfterMove)
if docAAfterMove.FolderPath != "TeamProjekt" {
t.Fatalf("User A's folder hierarchy was altered! Expected 'TeamProjekt', got %q", docAAfterMove.FolderPath)
}
// 7. Synchronous contents check:
// User B moves the document into a subfolder: "MeineOrdner/TeamProjekt/Archiv"
moveDocPayload := map[string]string{
"folderPath": "MeineOrdner/TeamProjekt/Archiv",
}
b, _ = json.Marshal(moveDocPayload)
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+createdDoc.ID+"/move", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenB)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User B move doc into subfolder failed: %s", rec.Body.String())
}
// User A must now see the document synchronized at "TeamProjekt/Archiv"!
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
req.Header.Set("Authorization", "Bearer "+tokenA)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User A get doc failed: %s", rec.Body.String())
}
var docASynced domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docASynced)
if docASynced.FolderPath != "TeamProjekt/Archiv" {
t.Fatalf("expected User A to see subfolder sync at 'TeamProjekt/Archiv', got %q", docASynced.FolderPath)
}
}
+25 -1
View File
@@ -1,6 +1,7 @@
package config
import (
"log/slog"
"os"
"path/filepath"
"strings"
@@ -14,6 +15,7 @@ type Config struct {
BaseURL string
AllowRegistration bool
LogLevel string
Debug bool
DemoMode bool
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
@@ -40,7 +42,14 @@ func Load() *Config {
jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")
baseURL := getEnv("BASE_URL", "http://localhost:"+port)
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
logLevel := getEnv("LOG_LEVEL", "info")
debugVal := strings.ToLower(getEnv("DEBUG", "false"))
debug := debugVal == "true" || debugVal == "1" || debugVal == "yes" || debugVal == "on"
logLevel := strings.ToLower(getEnv("LOG_LEVEL", "info"))
if debug {
logLevel = "debug"
}
demoVal := strings.ToLower(getEnv("MINDA_DEMO", getEnv("MINDSIFT_DEMO", getEnv("DEMO_MODE", "false"))))
demoMode := demoVal == "true" || demoVal == "1" || demoVal == "yes" || demoVal == "on"
@@ -60,6 +69,7 @@ func Load() *Config {
BaseURL: baseURL,
AllowRegistration: allowReg,
LogLevel: logLevel,
Debug: debug,
DemoMode: demoMode,
SMTPHost: smtpHost,
SMTPPort: smtpPort,
@@ -70,6 +80,20 @@ func Load() *Config {
}
}
// SlogLevel parses Config.LogLevel into a standard slog.Level.
func (c *Config) SlogLevel() slog.Level {
switch strings.ToLower(strings.TrimSpace(c.LogLevel)) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func getEnv(key, fallback string) string {
if val, ok := os.LookupEnv(key); ok && strings.TrimSpace(val) != "" {
return strings.TrimSpace(val)
+58
View File
@@ -0,0 +1,58 @@
package config
import (
"log/slog"
"os"
"testing"
)
func TestConfigLogLevelAndDebug(t *testing.T) {
os.Setenv("PORT", "9090")
defer os.Unsetenv("PORT")
// 1. Default info
os.Unsetenv("LOG_LEVEL")
os.Unsetenv("DEBUG")
cfg := Load()
if cfg.SlogLevel() != slog.LevelInfo {
t.Fatalf("expected LevelInfo, got %v", cfg.SlogLevel())
}
// 2. LOG_LEVEL=debug
os.Setenv("LOG_LEVEL", "debug")
cfg = Load()
if cfg.SlogLevel() != slog.LevelDebug {
t.Fatalf("expected LevelDebug, got %v", cfg.SlogLevel())
}
// 3. LOG_LEVEL=warn
os.Setenv("LOG_LEVEL", "warn")
cfg = Load()
if cfg.SlogLevel() != slog.LevelWarn {
t.Fatalf("expected LevelWarn, got %v", cfg.SlogLevel())
}
// 4. LOG_LEVEL=error
os.Setenv("LOG_LEVEL", "error")
cfg = Load()
if cfg.SlogLevel() != slog.LevelError {
t.Fatalf("expected LevelError, got %v", cfg.SlogLevel())
}
// 5. DEBUG=true overrides LOG_LEVEL
os.Setenv("LOG_LEVEL", "error")
os.Setenv("DEBUG", "true")
cfg = Load()
if cfg.SlogLevel() != slog.LevelDebug {
t.Fatalf("expected LevelDebug when DEBUG=true, got %v", cfg.SlogLevel())
}
// 6. DEBUG=1 overrides LOG_LEVEL
os.Setenv("DEBUG", "1")
cfg = Load()
if cfg.SlogLevel() != slog.LevelDebug {
t.Fatalf("expected LevelDebug when DEBUG=1, got %v", cfg.SlogLevel())
}
os.Unsetenv("DEBUG")
os.Unsetenv("LOG_LEVEL")
}
+56 -20
View File
@@ -260,13 +260,47 @@ type SystemSettings struct {
}
type DocumentBlock struct {
ID string `json:"id"`
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
Content string `json:"content"`
Checked bool `json:"checked,omitempty"`
Lang string `json:"lang,omitempty"`
Level int `json:"level,omitempty"`
Collapsed bool `json:"collapsed,omitempty"`
ID string `json:"id"`
DocumentID string `json:"documentId,omitempty"`
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
Content string `json:"content"`
Checked bool `json:"checked,omitempty"`
Lang string `json:"lang,omitempty"`
Level int `json:"level"`
Position float64 `json:"position"`
Collapsed bool `json:"collapsed,omitempty"`
}
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
var sb strings.Builder
for _, b := range blocks {
lvl := b.Level
if lvl < 0 {
lvl = 0
}
indent := strings.Repeat(" ", lvl)
switch b.Type {
case "h1":
sb.WriteString(indent + "- # " + b.Content + "\n")
case "h2":
sb.WriteString(indent + "- ## " + b.Content + "\n")
case "h3":
sb.WriteString(indent + "- ### " + b.Content + "\n")
case "todo":
mark := "[ ]"
if b.Checked {
mark = "[x]"
}
sb.WriteString(indent + "- " + mark + " " + b.Content + "\n")
case "code":
sb.WriteString(indent + "- ```" + b.Lang + "\n" + b.Content + "\n" + indent + " ```\n")
case "divider":
sb.WriteString(indent + "- ---\n")
default:
sb.WriteString(indent + "- " + b.Content + "\n")
}
}
return sb.String()
}
type DocBlocksPatchRequest struct {
@@ -310,19 +344,21 @@ type DocLockInfo struct {
}
type Document struct {
ID string `json:"id"`
UserID string `json:"userId"`
SpaceID string `json:"spaceId,omitempty"`
Title string `json:"title"`
Icon string `json:"icon"`
Content string `json:"content"`
Blocks []DocumentBlock `json:"blocks"`
Tags []string `json:"tags"`
FolderPath string `json:"folderPath,omitempty"`
IsPinned bool `json:"isPinned"`
IsArchived bool `json:"isArchived"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ID string `json:"id"`
UserID string `json:"userId"`
SpaceID string `json:"spaceId,omitempty"`
Title string `json:"title"`
Icon string `json:"icon"`
Content string `json:"content"`
MarkdownCache string `json:"markdownCache,omitempty"`
Blocks []DocumentBlock `json:"blocks"`
Tags []string `json:"tags"`
FolderID string `json:"folderId,omitempty"`
FolderPath string `json:"folderPath,omitempty"`
IsPinned bool `json:"isPinned"`
IsArchived bool `json:"isArchived"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
// Collaboration & Permissions
Permission string `json:"permission"` // owner | write | read
+222 -180
View File
@@ -162,9 +162,11 @@ func (s *Store) migrate() error {
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
user_id TEXT NOT NULL DEFAULT '',
space_id TEXT DEFAULT '',
title TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
folder_id TEXT NOT NULL DEFAULT '',
markdown_cache TEXT NOT NULL DEFAULT '',
icon TEXT NOT NULL DEFAULT '📄',
content TEXT NOT NULL DEFAULT '',
blocks_json TEXT NOT NULL DEFAULT '[]',
@@ -172,13 +174,40 @@ func (s *Store) migrate() error {
folder_path TEXT NOT NULL DEFAULT '',
is_pinned INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC);
CREATE TABLE IF NOT EXISTS blocks (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
level INT NOT NULL DEFAULT 0,
position REAL NOT NULL DEFAULT 0,
type TEXT NOT NULL DEFAULT 'bullet',
checked INTEGER NOT NULL DEFAULT 0,
collapsed INTEGER NOT NULL DEFAULT 0,
lang TEXT NOT NULL DEFAULT '',
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_blocks_doc_pos ON blocks(document_id, position ASC);
CREATE TABLE IF NOT EXISTS block_tags (
tag TEXT NOT NULL,
document_id TEXT NOT NULL,
block_id TEXT NOT NULL,
PRIMARY KEY(tag, block_id),
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE,
FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);
CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);
CREATE TABLE IF NOT EXISTS doc_shares (
id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL,
@@ -208,22 +237,6 @@ func (s *Store) migrate() error {
INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at)
SELECT user_id, id, updated_at FROM documents WHERE is_pinned = 1;
CREATE TABLE IF NOT EXISTS doc_block_tags (
id TEXT PRIMARY KEY,
tag TEXT NOT NULL,
user_id TEXT NOT NULL,
doc_id TEXT NOT NULL,
block_id TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(doc_id, block_id, tag),
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_tag ON doc_block_tags(tag);
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_user_tag ON doc_block_tags(user_id, tag);
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_doc ON doc_block_tags(doc_id);
CREATE TABLE IF NOT EXISTS tag_pages (
user_id TEXT NOT NULL,
tag TEXT NOT NULL,
@@ -232,25 +245,6 @@ func (s *Store) migrate() error {
PRIMARY KEY(user_id, tag)
);
CREATE TABLE IF NOT EXISTS document_crdt_state (
doc_id TEXT PRIMARY KEY,
state_blob BLOB NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL DEFAULT '',
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS document_crdt_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
doc_id TEXT NOT NULL,
update_blob BLOB NOT NULL,
created_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_doc_crdt_updates_doc ON document_crdt_updates(doc_id);
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -382,17 +376,28 @@ func (s *Store) migrate() error {
"CREATE INDEX IF NOT EXISTS idx_workspace_folders_user ON workspace_folders(user_id);",
"CREATE INDEX IF NOT EXISTS idx_workspace_folders_sf ON workspace_folders(shared_folder_id);",
"CREATE TABLE IF NOT EXISTS document_blocks (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, sort_order REAL NOT NULL DEFAULT 0, type TEXT NOT NULL DEFAULT 'bullet', content TEXT NOT NULL DEFAULT '', level INTEGER NOT NULL DEFAULT 0, checked INTEGER NOT NULL DEFAULT 0, collapsed INTEGER NOT NULL DEFAULT 0, lang TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL, updated_by TEXT NOT NULL DEFAULT '', FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE);",
"CREATE INDEX IF NOT EXISTS idx_document_blocks_doc_order ON document_blocks(doc_id, sort_order);",
"ALTER TABLE documents ADD COLUMN folder_id TEXT NOT NULL DEFAULT '';",
"ALTER TABLE documents ADD COLUMN markdown_cache TEXT NOT NULL DEFAULT '';",
"CREATE TABLE IF NOT EXISTS blocks (id TEXT PRIMARY KEY, document_id TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', level INT NOT NULL DEFAULT 0, position REAL NOT NULL DEFAULT 0, type TEXT NOT NULL DEFAULT 'bullet', checked INTEGER NOT NULL DEFAULT 0, collapsed INTEGER NOT NULL DEFAULT 0, lang TEXT NOT NULL DEFAULT '', FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE);",
"CREATE INDEX IF NOT EXISTS idx_blocks_doc_pos ON blocks(document_id, position ASC);",
"CREATE TABLE IF NOT EXISTS block_tags (tag TEXT NOT NULL, document_id TEXT NOT NULL, block_id TEXT NOT NULL, PRIMARY KEY(tag, block_id), FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE, FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE);",
"CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);",
"CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);",
}
for _, q := range columnMigrations {
_, _ = s.db.Exec(q)
}
// Clean up legacy space_id from documents and task_lists if empty or orphaned
// Clean up legacy space and old document block/crdt tables
_, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_state`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_updates`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_blocks`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS doc_block_tags`)
// Backfill username for existing accounts where username is empty
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
@@ -445,7 +450,7 @@ func (s *Store) migrate() error {
// Reindex block tags if table is newly added or empty
var tagCount int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM doc_block_tags`).Scan(&tagCount)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM block_tags`).Scan(&tagCount)
if tagCount == 0 {
_ = s.reindexAllDocBlockTags()
}
@@ -1269,7 +1274,7 @@ func (s *Store) migrateWorkspaceToPureDB() {
for _, md := range docsToMigrate {
var count int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM document_blocks WHERE doc_id = ?`, md.id).Scan(&count)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM blocks WHERE document_id = ?`, md.id).Scan(&count)
if count == 0 && md.blocksJSON != "" && md.blocksJSON != "[]" {
var blocks []domain.DocumentBlock
if err := json.Unmarshal([]byte(md.blocksJSON), &blocks); err == nil && len(blocks) > 0 {
@@ -1327,17 +1332,17 @@ func (s *Store) migrateWorkspaceToPureDB() {
func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock {
rows, err := s.db.Query(`
SELECT id, type, content, level, checked, collapsed, lang
FROM document_blocks
WHERE doc_id = ?
ORDER BY sort_order ASC, rowid ASC
SELECT id, document_id, type, content, level, position, checked, collapsed, lang
FROM blocks
WHERE document_id = ?
ORDER BY position ASC, rowid ASC
`, docID)
if err == nil {
blocks := make([]domain.DocumentBlock, 0)
for rows.Next() {
var b domain.DocumentBlock
var chk, col int
if scanErr := rows.Scan(&b.ID, &b.Type, &b.Content, &b.Level, &chk, &col, &b.Lang); scanErr == nil {
if scanErr := rows.Scan(&b.ID, &b.DocumentID, &b.Type, &b.Content, &b.Level, &b.Position, &chk, &col, &b.Lang); scanErr == nil {
b.Checked = chk != 0
b.Collapsed = col != 0
blocks = append(blocks, b)
@@ -1359,50 +1364,111 @@ func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.Docume
return blocks
}
func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.DocumentBlock, now string) {
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, docID)
if len(blocks) == 0 {
return
func (s *Store) replaceDocBlocksInDB(docID, _ string, blocks []domain.DocumentBlock, now string) {
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, docID)
if len(blocks) > 0 {
stmt, err := s.db.Prepare(`
INSERT INTO blocks (id, document_id, content, level, position, type, checked, collapsed, lang)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
document_id = excluded.document_id,
content = excluded.content,
level = excluded.level,
position = excluded.position,
type = excluded.type,
checked = excluded.checked,
collapsed = excluded.collapsed,
lang = excluded.lang
`)
if err == nil {
defer stmt.Close()
for i, b := range blocks {
bID := strings.TrimSpace(b.ID)
if bID == "" {
bID = domain.NewID("blk")
}
bType := b.Type
if bType == "" {
bType = "bullet"
}
chk := 0
if b.Checked {
chk = 1
}
col := 0
if b.Collapsed {
col = 1
}
pos := b.Position
if pos <= 0 {
pos = float64((i + 1) * 1000)
}
_, _ = stmt.Exec(bID, docID, b.Content, b.Level, pos, bType, chk, col, b.Lang)
}
}
}
stmt, err := s.db.Prepare(`
INSERT INTO document_blocks (id, doc_id, sort_order, type, content, level, checked, collapsed, lang, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
doc_id = excluded.doc_id,
sort_order = excluded.sort_order,
type = excluded.type,
content = excluded.content,
level = excluded.level,
checked = excluded.checked,
collapsed = excluded.collapsed,
lang = excluded.lang,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by
`)
if err != nil {
return
}
defer stmt.Close()
for i, b := range blocks {
bID := strings.TrimSpace(b.ID)
if bID == "" {
bID = domain.NewID("blk")
}
bType := b.Type
if bType == "" {
bType = "bullet"
}
chk := 0
if b.Checked {
chk = 1
}
col := 0
if b.Collapsed {
col = 1
}
_, _ = stmt.Exec(bID, docID, float64((i+1)*1000), bType, b.Content, b.Level, chk, col, b.Lang, now, userID)
// Update markdown_cache on documents
mdCache := domain.CompileBlocksToMarkdown(blocks)
_, _ = s.db.Exec(`UPDATE documents SET markdown_cache = ?, updated_at = ? WHERE id = ?`, mdCache, now, docID)
}
func (s *Store) LoadDocumentState(docID string) (string, []domain.DocumentBlock, error) {
var title, blocksJSON string
err := s.db.QueryRow(`SELECT title, blocks_json FROM documents WHERE id = ?`, docID).Scan(&title, &blocksJSON)
if err != nil {
return "", nil, err
}
blocks := s.loadDocBlocks(docID, blocksJSON)
return title, blocks, nil
}
// FlushDocumentState persists the in-memory blocks into `blocks`, extracts all #tags into `block_tags`,
// and compiles the hierarchical Markdown into `documents.markdown_cache`.
func (s *Store) FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error {
now := domain.NowISO()
// Normalize positions sequentially
normalized := make([]domain.DocumentBlock, len(blocks))
var plainBuilder strings.Builder
for i, b := range blocks {
if strings.TrimSpace(b.ID) == "" {
b.ID = domain.NewID("blk")
}
if b.Type == "" {
b.Type = "bullet"
}
b.DocumentID = docID
b.Position = float64((i + 1) * 1000)
normalized[i] = b
if b.Content != "" {
plainBuilder.WriteString(b.Content)
plainBuilder.WriteString("\n")
}
}
s.replaceDocBlocksInDB(docID, "", normalized, now)
_ = s.indexDocBlockTags(docID, "", normalized)
mdCache := domain.CompileBlocksToMarkdown(normalized)
plainContent := plainBuilder.String()
blocksJSON, _ := json.Marshal(normalized)
if strings.TrimSpace(title) != "" {
_, err := s.db.Exec(`
UPDATE documents
SET title = ?, markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ?
WHERE id = ?
`, title, mdCache, plainContent, string(blocksJSON), now, docID)
return err
}
_, err := s.db.Exec(`
UPDATE documents
SET markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ?
WHERE id = ?
`, mdCache, plainContent, string(blocksJSON), now, docID)
return err
}
// PatchDocumentBlocks applies atomic block-level upserts, deletions, and order reconciliation without overwriting concurrent edits from other sessions.
@@ -1417,16 +1483,16 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
now := domain.NowISO()
// Ensure document_blocks is seeded if previously only in blocks_json
// Ensure blocks table is seeded if previously only in blocks_json
var existingCount int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM document_blocks WHERE doc_id = ?`, docID).Scan(&existingCount)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM blocks WHERE document_id = ?`, docID).Scan(&existingCount)
if existingCount == 0 && len(doc.Blocks) > 0 {
s.replaceDocBlocksInDB(docID, doc.UserID, doc.Blocks, doc.UpdatedAt)
}
// Read current ordered block IDs from DB before patch
var existingOrder []string
ordRows, err := s.db.Query(`SELECT id FROM document_blocks WHERE doc_id = ? ORDER BY sort_order ASC, rowid ASC`, docID)
ordRows, err := s.db.Query(`SELECT id FROM blocks WHERE document_id = ? ORDER BY position ASC, rowid ASC`, docID)
if err == nil {
for ordRows.Next() {
var bid string
@@ -1445,7 +1511,7 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
continue
}
deletedSet[delID] = true
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ? AND id = ?`, docID, delID)
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ? AND id = ?`, docID, delID)
}
// Filter existingOrder to exclude deleted blocks
@@ -1479,18 +1545,16 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
}
defaultSort := float64((len(survivingExisting) + idx + 1) * 1000)
_, _ = s.db.Exec(`
INSERT INTO document_blocks (id, doc_id, sort_order, type, content, level, checked, collapsed, lang, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO blocks (id, document_id, content, level, position, type, checked, collapsed, lang)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type,
content = excluded.content,
level = excluded.level,
checked = excluded.checked,
collapsed = excluded.collapsed,
lang = excluded.lang,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by
`, bID, docID, defaultSort, bType, b.Content, b.Level, chk, col, b.Lang, now, userID)
lang = excluded.lang
`, bID, docID, b.Content, b.Level, defaultSort, bType, chk, col, b.Lang)
upsertedIDs = append(upsertedIDs, bID)
survivingSet[bID] = true
}
@@ -1506,8 +1570,6 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
inMerged[bid] = true
}
}
// Any surviving block in DB that was not in req.BlockOrder (e.g. concurrently added by another user)
// is inserted right after its predecessor from survivingExisting!
for i, bid := range survivingExisting {
if inMerged[bid] {
continue
@@ -1528,7 +1590,6 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
mergedOrder = append(mergedOrder[:insertPos], append([]string{bid}, mergedOrder[insertPos:]...)...)
inMerged[bid] = true
}
// Ensure any upserted block not in BlockOrder is also appended
for _, bid := range upsertedIDs {
if !inMerged[bid] {
mergedOrder = append(mergedOrder, bid)
@@ -1550,9 +1611,9 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
}
}
// Update sort_order in document_blocks
// Update position in blocks
for idx, bid := range mergedOrder {
_, _ = s.db.Exec(`UPDATE document_blocks SET sort_order = ? WHERE doc_id = ? AND id = ?`, float64((idx+1)*1000), docID, bid)
_, _ = s.db.Exec(`UPDATE blocks SET position = ? WHERE document_id = ? AND id = ?`, float64((idx+1)*1000), docID, bid)
}
// Reload authoritative block list and update document snapshot
@@ -1565,12 +1626,14 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
}
}
plainContent := plainBuilder.String()
mdCache := domain.CompileBlocksToMarkdown(finalBlocks)
blocksJSON, _ := json.Marshal(finalBlocks)
_, _ = s.db.Exec(`UPDATE documents SET content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`,
plainContent, string(blocksJSON), now, docID)
_, _ = s.db.Exec(`UPDATE documents SET markdown_cache = ?, content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`,
mdCache, plainContent, string(blocksJSON), now, docID)
doc.Blocks = finalBlocks
doc.MarkdownCache = mdCache
doc.Content = plainContent
doc.UpdatedAt = now
_ = s.indexDocBlockTags(doc.ID, doc.UserID, finalBlocks)
@@ -1578,59 +1641,6 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
return doc, mergedOrder, nil
}
// GetDocumentCRDTState returns the compacted CRDT state blob and any pending updates for a document.
func (s *Store) GetDocumentCRDTState(docID string) ([]byte, [][]byte, error) {
var stateBlob []byte
err := s.db.QueryRow(`SELECT state_blob FROM document_crdt_state WHERE doc_id = ?`, docID).Scan(&stateBlob)
if err != nil && err != sql.ErrNoRows {
return nil, nil, err
}
rows, err := s.db.Query(`SELECT update_blob FROM document_crdt_updates WHERE doc_id = ? ORDER BY id ASC`, docID)
if err != nil {
return stateBlob, nil, nil
}
defer rows.Close()
var updates [][]byte
for rows.Next() {
var u []byte
if err := rows.Scan(&u); err == nil && len(u) > 0 {
updates = append(updates, u)
}
}
return stateBlob, updates, nil
}
// AppendDocumentCRDTUpdate appends a new CRDT update delta for a document.
func (s *Store) AppendDocumentCRDTUpdate(docID string, update []byte, userID string) error {
now := domain.NowISO()
_, err := s.db.Exec(`
INSERT INTO document_crdt_updates (doc_id, update_blob, created_at, created_by)
VALUES (?, ?, ?, ?)
`, docID, update, now, userID)
return err
}
// SaveDocumentCRDTState saves a compacted snapshot of the document CRDT state and prunes older incremental updates.
func (s *Store) SaveDocumentCRDTState(docID string, stateBlob []byte, userID string) error {
now := domain.NowISO()
_, err := s.db.Exec(`
INSERT INTO document_crdt_state (doc_id, state_blob, updated_at, updated_by)
VALUES (?, ?, ?, ?)
ON CONFLICT(doc_id) DO UPDATE SET
state_blob = excluded.state_blob,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by
`, docID, stateBlob, now, userID)
if err != nil {
return err
}
// Prune incremental updates older than the snapshot
_, _ = s.db.Exec(`DELETE FROM document_crdt_updates WHERE doc_id = ?`, docID)
return nil
}
// GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID.
func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) {
userSet := make(map[string]bool)
@@ -1964,6 +1974,8 @@ func (s *Store) scanDocumentWithBlocks(rows interface {
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
d.Tags = stripFolderTags(d.Tags)
d.Blocks = s.loadDocBlocks(d.ID, blocksStr)
d.MarkdownCache = domain.CompileBlocksToMarkdown(d.Blocks)
d.FolderID = d.FolderPath
return d, nil
}
@@ -2303,8 +2315,8 @@ func (s *Store) DeleteDocument(userID, docID string) error {
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
return s.DeleteDocShare(userID, docID, userID)
}
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, docID)
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID)
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, docID)
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID)
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, docID)
_, err = s.db.Exec(`DELETE FROM documents WHERE id = ?`, docID)
return err
@@ -2338,27 +2350,25 @@ func (s *Store) reindexAllDocBlockTags() error {
return nil
}
func (s *Store) indexDocBlockTags(docID, userID string, blocks []domain.DocumentBlock) error {
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID)
func (s *Store) indexDocBlockTags(docID, _ string, blocks []domain.DocumentBlock) error {
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID)
if len(blocks) == 0 {
return nil
}
stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO doc_block_tags (id, tag, user_id, doc_id, block_id, created_at) VALUES (?, ?, ?, ?, ?, ?)`)
stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO block_tags (tag, document_id, block_id) VALUES (?, ?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
now := domain.NowISO()
for _, b := range blocks {
if b.Content == "" {
continue
}
tags := domain.ExtractTags(b.Content)
for _, tag := range tags {
tagID := domain.NewID("dbtag")
_, _ = stmt.Exec(tagID, tag, userID, docID, b.ID, now)
_, _ = stmt.Exec(tag, docID, b.ID)
}
}
return nil
@@ -2384,9 +2394,9 @@ func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) {
func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
rows, err := s.db.Query(
`SELECT t.tag, COUNT(DISTINCT t.doc_id || ':' || t.block_id) as count
FROM doc_block_tags t
JOIN documents d ON t.doc_id = d.id
`SELECT t.tag, COUNT(DISTINCT t.document_id || ':' || t.block_id) as count
FROM block_tags t
JOIN documents d ON t.document_id = d.id
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
LEFT JOIN (
SELECT dts2.doc_id
@@ -2394,11 +2404,18 @@ func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
JOIN team_members tm ON tm.team_id = dts2.team_id
WHERE tm.user_id = ?
) dts ON d.id = dts.doc_id
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL)
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
LEFT JOIN (
SELECT wfs2.folder_id
FROM workspace_folder_shares wfs2
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
) wfs ON d.shared_folder_id = wfs.folder_id
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)))
AND d.is_archived = 0
GROUP BY t.tag
ORDER BY count DESC, t.tag ASC`,
userID, userID, userID, userID,
userID, userID, userID, userID, userID, userID, userID,
)
if err != nil {
return nil, err
@@ -2450,9 +2467,9 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
rows, err := s.db.Query(
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
COALESCE(u.display_name, '') as owner_name,
COALESCE(s.permission, dts.permission, '') as shared_permission
FROM doc_block_tags t
JOIN documents d ON t.doc_id = d.id
COALESCE(s.permission, dts.permission, wfs.permission, '') as shared_permission
FROM block_tags t
JOIN documents d ON t.document_id = d.id
LEFT JOIN users u ON d.user_id = u.id
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
LEFT JOIN (
@@ -2461,11 +2478,18 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
JOIN team_members tm ON tm.team_id = dts2.team_id
WHERE tm.user_id = ?
) dts ON d.id = dts.doc_id
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL)
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
LEFT JOIN (
SELECT wfs2.folder_id, wfs2.permission
FROM workspace_folder_shares wfs2
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
) wfs ON d.shared_folder_id = wfs.folder_id
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)))
AND t.tag = ?
AND d.is_archived = 0
ORDER BY d.updated_at DESC`,
userID, userID, userID, userID, cleanTag,
userID, userID, userID, userID, userID, userID, userID, cleanTag,
)
if err != nil {
return nil, err
@@ -2768,8 +2792,8 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
// Delete documents from SQLite, blocks, pins, shares, tags, user_folders
for _, id := range deletedDocIDs {
_, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ?`, id)
@@ -3416,6 +3440,24 @@ func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error {
return err
}
// GetSharedFolderDocIDs returns all non-archived document IDs belonging to a shared folder.
func (s *Store) GetSharedFolderDocIDs(folderID string) ([]string, error) {
rows, err := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ? AND is_archived = 0`, folderID)
if err != nil {
return nil, err
}
defer rows.Close()
var docIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil && id != "" {
docIDs = append(docIDs, id)
}
}
return docIDs, nil
}
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
shares := make([]domain.DocShare, 0)
+42 -34
View File
@@ -683,9 +683,9 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
}
}
func TestDocumentCRDTState(t *testing.T) {
func TestFlushDocumentStateAndSchema(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "crdt_test.db")
dbPath := filepath.Join(tmpDir, "tabula_rasa_test.db")
store, err := NewStore(dbPath)
if err != nil {
@@ -693,52 +693,60 @@ func TestDocumentCRDTState(t *testing.T) {
}
defer store.Close()
user, err := store.CreateUser("crdt@example.com", "hash", "CRDT User", "member")
user, err := store.CreateUser("outliner@example.com", "hash", "Outliner User", "member")
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
doc, err := store.CreateDocument(user.ID, "", "CRDT Test Doc", "📝", "", nil, nil, false)
doc, err := store.CreateDocument(user.ID, "", "Initial Note", "📝", "Projekte", nil, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
// 1. Initial CRDT state is empty
state, updates, err := store.GetDocumentCRDTState(doc.ID)
blocks := []domain.DocumentBlock{
{
ID: "blk_root_1",
Type: "text",
Content: "Architektur Übersicht #backend #go",
Level: 0,
},
{
ID: "blk_child_1",
Type: "todo",
Content: "SQLite WAL Modus aktivieren #sqlite",
Level: 1,
Checked: true,
},
}
if err := store.FlushDocumentState(doc.ID, "Architektur Notiz", blocks); err != nil {
t.Fatalf("FlushDocumentState failed: %v", err)
}
updatedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
if err != nil {
t.Fatalf("GetDocumentCRDTState failed: %v", err)
t.Fatalf("GetDocumentByID failed: %v", err)
}
if len(state) != 0 || len(updates) != 0 {
t.Fatalf("expected empty CRDT state, got state=%d updates=%d", len(state), len(updates))
if updatedDoc.Title != "Architektur Notiz" {
t.Fatalf("expected title 'Architektur Notiz', got '%s'", updatedDoc.Title)
}
if len(updatedDoc.Blocks) != 2 {
t.Fatalf("expected 2 blocks, got %d", len(updatedDoc.Blocks))
}
if updatedDoc.Blocks[1].Level != 1 || updatedDoc.Blocks[1].Position != 2000 {
t.Fatalf("expected block[1] level=1 position=2000, got level=%d position=%v", updatedDoc.Blocks[1].Level, updatedDoc.Blocks[1].Position)
}
if updatedDoc.MarkdownCache == "" {
t.Fatalf("expected markdown_cache to be populated")
}
// 2. Append incremental updates
u1 := []byte{1, 2, 3, 4}
u2 := []byte{5, 6, 7, 8}
if err := store.AppendDocumentCRDTUpdate(doc.ID, u1, user.ID); err != nil {
t.Fatalf("AppendDocumentCRDTUpdate 1 failed: %v", err)
}
if err := store.AppendDocumentCRDTUpdate(doc.ID, u2, user.ID); err != nil {
t.Fatalf("AppendDocumentCRDTUpdate 2 failed: %v", err)
}
_, updatesAfter, err := store.GetDocumentCRDTState(doc.ID)
if err != nil || len(updatesAfter) != 2 {
t.Fatalf("expected 2 updates, got %d (err: %v)", len(updatesAfter), err)
}
// 3. Compact snapshot state
snapshot := []byte{99, 100, 101}
if err := store.SaveDocumentCRDTState(doc.ID, snapshot, user.ID); err != nil {
t.Fatalf("SaveDocumentCRDTState failed: %v", err)
}
stateAfter, updatesAfterCompact, err := store.GetDocumentCRDTState(doc.ID)
tags, err := store.GetWorkspaceTags(user.ID)
if err != nil {
t.Fatalf("GetDocumentCRDTState after compact failed: %v", err)
t.Fatalf("GetWorkspaceTags failed: %v", err)
}
if len(stateAfter) != 3 || len(updatesAfterCompact) != 0 {
t.Fatalf("expected compacted state of length 3 and 0 updates, got state=%d updates=%d", len(stateAfter), len(updatesAfterCompact))
if len(tags) != 3 {
t.Fatalf("expected 3 extracted tags in block_tags (#backend, #go, #sqlite), got %d: %+v", len(tags), tags)
}
}
+736
View File
@@ -0,0 +1,736 @@
package ws
import (
"encoding/json"
"log/slog"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/sandrodach/mindsift/internal/domain"
)
// CursorPresence represents an active user's ephemeral cursor position in a document room.
type CursorPresence struct {
ClientID string `json:"clientId"`
UserID string `json:"userId"`
UserName string `json:"userName"`
UserColor string `json:"userColor"`
BlockID string `json:"blockId,omitempty"`
SelectionStart *int `json:"selectionStart,omitempty"`
SelectionEnd *int `json:"selectionEnd,omitempty"`
UpdatedAt int64 `json:"updatedAt"`
}
// DocWSClient represents an active WebSocket connection to a specific document room.
type DocWSClient struct {
hub *Hub
room *DocRoom
conn *websocket.Conn
userID string
clientID string
canWrite bool
send chan []byte
}
// DocSyncEvent is the lightweight JSON payload exchanged over /api/v1/ws/docs/{id}.
type DocSyncEvent struct {
Type string `json:"type"`
DocID string `json:"docId,omitempty"`
ClientID string `json:"clientId,omitempty"`
UserID string `json:"userId,omitempty"`
UserName string `json:"userName,omitempty"`
UserColor string `json:"userColor,omitempty"`
SelectionStart *int `json:"selectionStart,omitempty"`
SelectionEnd *int `json:"selectionEnd,omitempty"`
ActiveCursors []CursorPresence `json:"activeCursors,omitempty"`
Title string `json:"title,omitempty"`
Block *domain.DocumentBlock `json:"block,omitempty"`
BlockID string `json:"blockId,omitempty"`
AfterBlockID string `json:"afterBlockId,omitempty"`
NewIndex *int `json:"newIndex,omitempty"`
Content *string `json:"content,omitempty"`
Level *int `json:"level,omitempty"`
Checked *bool `json:"checked,omitempty"`
Collapsed *bool `json:"collapsed,omitempty"`
BlockType *string `json:"blockType,omitempty"`
Blocks []domain.DocumentBlock `json:"blocks,omitempty"`
}
// DocRoom coordinates in-memory real-time block state and 1.5s debounced SQLite persistence for one document.
type DocRoom struct {
mu sync.Mutex
docID string
hub *Hub
title string
blocks []domain.DocumentBlock
loaded bool
wsClients map[*DocWSClient]bool
activeCursors map[string]*CursorPresence
debounceTimer *time.Timer
dirty bool
}
// GetOrCreateDocRoom returns the active in-memory room for docID or initializes one from SQLite.
func (h *Hub) GetOrCreateDocRoom(docID string) *DocRoom {
h.mu.Lock()
if h.docRooms == nil {
h.docRooms = make(map[string]*DocRoom)
}
room, exists := h.docRooms[docID]
if !exists {
room = &DocRoom{
docID: docID,
hub: h,
wsClients: make(map[*DocWSClient]bool),
activeCursors: make(map[string]*CursorPresence),
}
h.docRooms[docID] = room
}
store := h.docStore
h.mu.Unlock()
room.mu.Lock()
if !room.loaded && store != nil {
title, blocks, err := store.LoadDocumentState(docID)
if err == nil {
room.title = title
room.blocks = normalizeRoomBlocks(docID, blocks)
room.loaded = true
}
}
room.mu.Unlock()
return room
}
// GetActiveDocState returns the live in-memory title and blocks if an active DocRoom exists in RAM.
func (h *Hub) GetActiveDocState(docID string) (string, []domain.DocumentBlock, bool) {
h.mu.RLock()
room, exists := h.docRooms[docID]
h.mu.RUnlock()
if !exists || room == nil {
return "", nil, false
}
room.mu.Lock()
defer room.mu.Unlock()
if !room.loaded {
return "", nil, false
}
blocksCopy := make([]domain.DocumentBlock, len(room.blocks))
copy(blocksCopy, room.blocks)
return room.title, blocksCopy, true
}
// SyncActiveDocRoom updates an in-memory room if one is currently loaded (e.g. after REST PUT/PATCH).
func (h *Hub) SyncActiveDocRoom(docID string, title string, blocks []domain.DocumentBlock) {
h.mu.RLock()
room, exists := h.docRooms[docID]
h.mu.RUnlock()
if !exists || room == nil {
return
}
room.mu.Lock()
if title != "" {
room.title = title
}
if blocks != nil {
room.blocks = normalizeRoomBlocks(docID, blocks)
}
room.loaded = true
room.mu.Unlock()
}
// CloseDocRoom removes a document room from memory when a document is deleted.
func (h *Hub) CloseDocRoom(docID string) {
h.mu.Lock()
room, exists := h.docRooms[docID]
if exists {
delete(h.docRooms, docID)
}
h.mu.Unlock()
if exists && room != nil {
room.mu.Lock()
if room.debounceTimer != nil {
room.debounceTimer.Stop()
}
room.dirty = false
for c := range room.wsClients {
_ = c.conn.Close()
}
room.wsClients = make(map[*DocWSClient]bool)
room.mu.Unlock()
}
}
func normalizeRoomBlocks(docID string, blocks []domain.DocumentBlock) []domain.DocumentBlock {
if len(blocks) == 0 {
return []domain.DocumentBlock{
{
ID: docID + "-b0",
DocumentID: docID,
Type: "text",
Content: "",
Level: 0,
Position: 1000,
},
}
}
out := make([]domain.DocumentBlock, len(blocks))
for i, b := range blocks {
if b.ID == "" {
b.ID = domain.NewID("blk")
}
b.DocumentID = docID
if b.Type == "" {
b.Type = "text"
}
if b.Level < 0 {
b.Level = 0
}
b.Position = float64((i + 1) * 1000)
out[i] = b
}
return out
}
// GetStateSnapshot returns a copy of the room's current live in-memory title and blocks.
func (r *DocRoom) GetStateSnapshot() (string, []domain.DocumentBlock) {
r.mu.Lock()
defer r.mu.Unlock()
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
copy(blocksCopy, r.blocks)
return r.title, blocksCopy
}
// HandleEvent applies a granular block or title mutation to the in-memory room,
// broadcasts it to all OTHER clients in the room (excluding senderClientID for echo protection),
// and resets the 1.5-second inactivity debounce timer for SQLite persistence.
func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
r.mu.Lock()
evt.DocID = r.docID
if sender != nil {
if evt.ClientID == "" {
evt.ClientID = sender.clientID
}
evt.UserID = sender.userID
}
mutated := false
switch evt.Type {
case "cursor_moved":
if evt.ClientID != "" {
cp := &CursorPresence{
ClientID: evt.ClientID,
UserID: evt.UserID,
UserName: evt.UserName,
UserColor: evt.UserColor,
BlockID: evt.BlockID,
SelectionStart: evt.SelectionStart,
SelectionEnd: evt.SelectionEnd,
UpdatedAt: time.Now().UnixMilli(),
}
if r.activeCursors == nil {
r.activeCursors = make(map[string]*CursorPresence)
}
r.activeCursors[evt.ClientID] = cp
msgBytes, err := json.Marshal(evt)
if err == nil {
for client := range r.wsClients {
if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) {
continue
}
if evt.ClientID != "" && client.clientID == evt.ClientID {
continue
}
select {
case client.send <- msgBytes:
default:
}
}
}
}
r.mu.Unlock()
return
case "title_updated":
if evt.Title != "" || r.title != evt.Title {
r.title = evt.Title
mutated = true
}
case "block_created":
if evt.Block != nil {
newBlock := *evt.Block
if newBlock.ID == "" {
newBlock.ID = domain.NewID("blk")
}
newBlock.DocumentID = r.docID
if newBlock.Type == "" {
newBlock.Type = "text"
}
if newBlock.Level < 0 {
newBlock.Level = 0
}
// Avoid duplicate ID if already created
existsIdx := -1
for i, b := range r.blocks {
if b.ID == newBlock.ID {
existsIdx = i
break
}
}
if existsIdx >= 0 {
r.blocks[existsIdx] = newBlock
} else {
insertIdx := len(r.blocks)
if evt.AfterBlockID != "" {
for i, b := range r.blocks {
if b.ID == evt.AfterBlockID {
insertIdx = i + 1
break
}
}
} else if evt.NewIndex != nil && *evt.NewIndex >= 0 && *evt.NewIndex <= len(r.blocks) {
insertIdx = *evt.NewIndex
}
updated := make([]domain.DocumentBlock, 0, len(r.blocks)+1)
updated = append(updated, r.blocks[:insertIdx]...)
updated = append(updated, newBlock)
updated = append(updated, r.blocks[insertIdx:]...)
r.blocks = normalizeRoomBlocks(r.docID, updated)
}
evt.Block = &newBlock
mutated = true
}
case "block_updated":
targetID := evt.BlockID
if targetID == "" && evt.Block != nil {
targetID = evt.Block.ID
}
if targetID != "" {
found := false
for i := range r.blocks {
if r.blocks[i].ID == targetID {
found = true
if evt.Block != nil {
r.blocks[i].Content = evt.Block.Content
r.blocks[i].Level = evt.Block.Level
if evt.Block.Type != "" {
r.blocks[i].Type = evt.Block.Type
}
r.blocks[i].Checked = evt.Block.Checked
r.blocks[i].Collapsed = evt.Block.Collapsed
r.blocks[i].Lang = evt.Block.Lang
} else {
if evt.Content != nil {
r.blocks[i].Content = *evt.Content
}
if evt.Level != nil {
lvl := *evt.Level
if lvl < 0 {
lvl = 0
}
r.blocks[i].Level = lvl
}
if evt.Checked != nil {
r.blocks[i].Checked = *evt.Checked
}
if evt.Collapsed != nil {
r.blocks[i].Collapsed = *evt.Collapsed
}
if evt.BlockType != nil && *evt.BlockType != "" {
r.blocks[i].Type = *evt.BlockType
}
}
bCopy := r.blocks[i]
evt.Block = &bCopy
evt.BlockID = targetID
mutated = true
break
}
}
if !found && evt.Block != nil {
newBlock := *evt.Block
newBlock.DocumentID = r.docID
if newBlock.Type == "" {
newBlock.Type = "text"
}
if newBlock.Level < 0 {
newBlock.Level = 0
}
r.blocks = append(r.blocks, newBlock)
r.blocks = normalizeRoomBlocks(r.docID, r.blocks)
bCopy := newBlock
evt.Block = &bCopy
evt.BlockID = targetID
mutated = true
}
}
case "block_deleted":
if evt.BlockID != "" {
filtered := make([]domain.DocumentBlock, 0, len(r.blocks))
for _, b := range r.blocks {
if b.ID != evt.BlockID {
filtered = append(filtered, b)
}
}
r.blocks = normalizeRoomBlocks(r.docID, filtered)
mutated = true
}
case "block_moved":
if evt.BlockID != "" && evt.NewIndex != nil {
fromIdx := -1
for i, b := range r.blocks {
if b.ID == evt.BlockID {
fromIdx = i
break
}
}
if fromIdx >= 0 {
moving := r.blocks[fromIdx]
if evt.Level != nil {
lvl := *evt.Level
if lvl < 0 {
lvl = 0
}
moving.Level = lvl
}
without := make([]domain.DocumentBlock, 0, len(r.blocks)-1)
without = append(without, r.blocks[:fromIdx]...)
without = append(without, r.blocks[fromIdx+1:]...)
toIdx := *evt.NewIndex
if toIdx < 0 {
toIdx = 0
}
if toIdx > len(without) {
toIdx = len(without)
}
reordered := make([]domain.DocumentBlock, 0, len(r.blocks))
reordered = append(reordered, without[:toIdx]...)
reordered = append(reordered, moving)
reordered = append(reordered, without[toIdx:]...)
r.blocks = normalizeRoomBlocks(r.docID, reordered)
mutated = true
}
}
case "blocks_replaced":
if evt.Blocks != nil {
r.blocks = normalizeRoomBlocks(r.docID, evt.Blocks)
evt.Blocks = r.blocks
mutated = true
}
}
if !mutated {
r.mu.Unlock()
return
}
r.dirty = true
r.loaded = true
msgBytes, err := json.Marshal(evt)
if err == nil {
for client := range r.wsClients {
// Strict Echo Protection: never send back to the originating connection or clientId
if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) {
continue
}
if evt.ClientID != "" && client.clientID == evt.ClientID {
continue
}
select {
case client.send <- msgBytes:
default:
slog.Warn("Doc room WS client send buffer full, dropping message",
"doc_id", r.docID,
"client_id", client.clientID,
"user_id", client.userID,
)
}
}
}
r.scheduleDebouncedFlushLocked()
r.mu.Unlock()
}
func (r *DocRoom) scheduleDebouncedFlushLocked() {
if r.debounceTimer != nil {
r.debounceTimer.Stop()
}
r.debounceTimer = time.AfterFunc(1500*time.Millisecond, func() {
r.Flush()
})
}
// Flush persists the current in-memory document state (blocks, #tags in block_tags, and markdown_cache) to SQLite.
func (r *DocRoom) Flush() {
r.mu.Lock()
if !r.dirty {
r.mu.Unlock()
return
}
if r.debounceTimer != nil {
r.debounceTimer.Stop()
r.debounceTimer = nil
}
docID := r.docID
title := r.title
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
copy(blocksCopy, r.blocks)
r.dirty = false
r.mu.Unlock()
tagsCount := 0
for _, b := range blocksCopy {
tagsCount += len(domain.ExtractTags(b.Content))
}
start := time.Now()
r.hub.mu.RLock()
store := r.hub.docStore
r.hub.mu.RUnlock()
if store != nil {
if err := store.FlushDocumentState(docID, title, blocksCopy); err != nil {
slog.Error("SQLite document flush failed",
"doc_id", docID,
"error", err,
)
} else {
durationMs := float64(time.Since(start).Microseconds()) / 1000.0
slog.Debug("SQLite document flushed",
"doc_id", docID,
"blocks_count", len(blocksCopy),
"tags_count", tagsCount,
"duration_ms", durationMs,
)
}
}
}
// ServeDocWS handles WebSocket connections for GET /api/v1/ws/docs/{id}
func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string, clientID string, canWrite bool) {
conn, err := upgrader.Upgrade(w, req, nil)
if err != nil {
slog.Error("WebSocket upgrade error", "doc_id", r.docID, "error", err)
return
}
if clientID == "" {
clientID = req.URL.Query().Get("clientId")
}
if clientID == "" {
clientID = domain.NewID("cli")
}
userName := req.URL.Query().Get("userName")
userColor := req.URL.Query().Get("userColor")
client := &DocWSClient{
hub: r.hub,
room: r,
conn: conn,
userID: userID,
clientID: clientID,
canWrite: canWrite,
send: make(chan []byte, 256),
}
r.mu.Lock()
r.wsClients[client] = true
activeClients := len(r.wsClients)
if r.activeCursors == nil {
r.activeCursors = make(map[string]*CursorPresence)
}
if userName != "" {
r.activeCursors[clientID] = &CursorPresence{
ClientID: clientID,
UserID: userID,
UserName: userName,
UserColor: userColor,
UpdatedAt: time.Now().UnixMilli(),
}
}
title := r.title
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
copy(blocksCopy, r.blocks)
activeList := make([]CursorPresence, 0, len(r.activeCursors))
for _, cp := range r.activeCursors {
if cp != nil {
activeList = append(activeList, *cp)
}
}
r.mu.Unlock()
slog.Debug("WS client joined doc room",
"doc_id", r.docID,
"client_id", clientID,
"user_id", userID,
"active_clients", activeClients,
)
// Immediately send live in-memory state and current cursors to the newly connected client
initEvt := DocSyncEvent{
Type: "doc_state",
DocID: r.docID,
ClientID: clientID,
Title: title,
Blocks: blocksCopy,
ActiveCursors: activeList,
}
if initBytes, err := json.Marshal(initEvt); err == nil {
client.send <- initBytes
}
// If userName was provided, broadcast initial presence to existing clients in room
if userName != "" {
joinEvt := DocSyncEvent{
Type: "cursor_moved",
DocID: r.docID,
ClientID: clientID,
UserID: userID,
UserName: userName,
UserColor: userColor,
}
if joinBytes, err := json.Marshal(joinEvt); err == nil {
r.mu.Lock()
for c := range r.wsClients {
if c != client && c.clientID != clientID {
select {
case c.send <- joinBytes:
default:
}
}
}
r.mu.Unlock()
}
}
go client.writePump()
go client.readPump()
}
func (c *DocWSClient) readPump() {
defer func() {
c.room.mu.Lock()
delete(c.room.wsClients, c)
delete(c.room.activeCursors, c.clientID)
remaining := len(c.room.wsClients)
// Broadcast client_left to all remaining clients in room
leftEvt := DocSyncEvent{
Type: "client_left",
DocID: c.room.docID,
ClientID: c.clientID,
UserID: c.userID,
}
if leftBytes, err := json.Marshal(leftEvt); err == nil {
for client := range c.room.wsClients {
if client != c && client.clientID != c.clientID {
select {
case client.send <- leftBytes:
default:
}
}
}
}
c.room.mu.Unlock()
slog.Debug("WS client left doc room",
"doc_id", c.room.docID,
"client_id", c.clientID,
"user_id", c.userID,
"active_clients", remaining,
)
_ = c.conn.Close()
// Flush immediately when the last client leaves the document room
if remaining == 0 {
c.room.Flush()
}
}()
c.conn.SetReadLimit(4 * 1024 * 1024)
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
break
}
var evt DocSyncEvent
if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" {
targetBlockID := evt.BlockID
if targetBlockID == "" && evt.Block != nil {
targetBlockID = evt.Block.ID
}
slog.Debug("WS doc event received",
"type", evt.Type,
"doc_id", c.room.docID,
"block_id", targetBlockID,
"sender_client_id", c.clientID,
)
// Read-only clients can still move their cursor and send presence updates
if !c.canWrite && evt.Type != "cursor_moved" {
continue
}
c.room.HandleEvent(evt, c)
}
}
}
func (c *DocWSClient) writePump() {
ticker := time.NewTicker(25 * time.Second)
defer func() {
ticker.Stop()
_ = c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package ws
import (
"testing"
"time"
)
func TestDocRoomCursorPresenceAndEchoProtection(t *testing.T) {
hub := NewHub()
room := hub.GetOrCreateDocRoom("doc_test_presence")
c1 := &DocWSClient{
hub: hub,
room: room,
userID: "user_1",
clientID: "cli_1",
canWrite: true,
send: make(chan []byte, 10),
}
c2 := &DocWSClient{
hub: hub,
room: room,
userID: "user_2",
clientID: "cli_2",
canWrite: false, // read-only viewer
send: make(chan []byte, 10),
}
room.mu.Lock()
room.wsClients[c1] = true
room.wsClients[c2] = true
room.mu.Unlock()
// 1. Send cursor_moved from c1
selStart := 5
selEnd := 5
room.HandleEvent(DocSyncEvent{
Type: "cursor_moved",
DocID: "doc_test_presence",
ClientID: "cli_1",
UserID: "user_1",
UserName: "Alice",
UserColor: "#3b82f6",
BlockID: "blk_123",
SelectionStart: &selStart,
SelectionEnd: &selEnd,
}, c1)
// c1 should NOT have received its own echo
select {
case <-c1.send:
t.Fatal("c1 received echo of its own cursor_moved event!")
default:
}
// c2 SHOULD receive the event
select {
case msg := <-c2.send:
if len(msg) == 0 {
t.Fatal("empty message received by c2")
}
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for cursor_moved event on c2")
}
// Room should have registered c1's presence
room.mu.Lock()
cp, exists := room.activeCursors["cli_1"]
room.mu.Unlock()
if !exists || cp == nil {
t.Fatal("expected active cursor for cli_1 in room")
}
if cp.UserName != "Alice" || cp.BlockID != "blk_123" {
t.Fatalf("unexpected cursor data: %+v", cp)
}
// Ephemeral cursor events must NOT mark the room dirty for disk flush
room.mu.Lock()
dirty := room.dirty
room.mu.Unlock()
if dirty {
t.Fatal("cursor_moved should not mark room dirty")
}
}
+36 -100
View File
@@ -1,10 +1,9 @@
package ws
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"log/slog"
"net/http"
"strings"
"sync"
@@ -47,10 +46,9 @@ type docLockItem struct {
expiresAt time.Time
}
type CRDTStore interface {
GetDocumentCRDTState(docID string) ([]byte, [][]byte, error)
AppendDocumentCRDTUpdate(docID string, update []byte, userID string) error
SaveDocumentCRDTState(docID string, stateBlob []byte, userID string) error
type DocStore interface {
LoadDocumentState(docID string) (string, []domain.DocumentBlock, error)
FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error
}
type Hub struct {
@@ -62,7 +60,8 @@ type Hub struct {
sseUserRooms map[string]map[*SSEClient]bool
sseRooms map[string]map[*SSEClient]bool
locks map[string]*docLockItem
crdtStore CRDTStore
docRooms map[string]*DocRoom
docStore DocStore
}
func NewHub() *Hub {
@@ -74,15 +73,16 @@ func NewHub() *Hub {
sseUserRooms: make(map[string]map[*SSEClient]bool),
sseRooms: make(map[string]map[*SSEClient]bool),
locks: make(map[string]*docLockItem),
docRooms: make(map[string]*DocRoom),
}
go h.cleanupLocksLoop()
return h
}
func (h *Hub) SetCRDTStore(store CRDTStore) {
func (h *Hub) SetDocStore(store DocStore) {
h.mu.Lock()
defer h.mu.Unlock()
h.crdtStore = store
h.docStore = store
}
func (h *Hub) Register(c *Client) {
@@ -499,7 +499,11 @@ func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) {
}
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
h.BroadcastToAll(event, payload)
if docID != "" {
h.BroadcastRoom("doc:"+docID, event, payload, nil)
} else {
h.BroadcastToAll(event, payload)
}
}
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
@@ -625,7 +629,7 @@ func (c *Client) SendEvent(event string, payload interface{}) {
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID string) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[ws] upgrade error: %v", err)
slog.Error("WebSocket upgrade error", "room_id", roomID, "user_id", userID, "error", err)
return
}
@@ -686,39 +690,6 @@ func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request, roomID, userID st
fmt.Fprintf(w, "data: %s\n\n", connectedMsg)
flusher.Flush()
// If initial rooms had doc rooms, send CRDT_INIT
if h.crdtStore != nil {
for rID := range client.subscribedRooms {
if strings.HasPrefix(rID, "doc:") {
docID := strings.TrimPrefix(rID, "doc:")
stateBlob, updates, err := h.crdtStore.GetDocumentCRDTState(docID)
if err == nil && (len(stateBlob) > 0 || len(updates) > 0) {
var b64Updates []string
for _, u := range updates {
b64Updates = append(b64Updates, base64.StdEncoding.EncodeToString(u))
}
b64State := ""
if len(stateBlob) > 0 {
b64State = base64.StdEncoding.EncodeToString(stateBlob)
}
initMsg := domain.WSMessage{
Event: "CRDT_INIT",
Payload: map[string]interface{}{
"docId": docID,
"state": b64State,
"updates": b64Updates,
},
Timestamp: domain.NowISO(),
}
if initBytes, err := json.Marshal(initMsg); err == nil {
fmt.Fprintf(w, "data: %s\n\n", initBytes)
flusher.Flush()
}
}
}
}
}
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
@@ -761,10 +732,11 @@ func (c *Client) readPump() {
break
}
// Handle client-initiated lock, CRDT, and presence messages
// Handle client-initiated lock, CRDT, room subscription, and presence messages
var incoming struct {
Event string `json:"event"`
DocID string `json:"docId"`
Room string `json:"room"`
Payload json.RawMessage `json:"payload"`
}
if json.Unmarshal(message, &incoming) == nil && c.userID != "" && c.userID != "anonymous" {
@@ -777,6 +749,25 @@ func (c *Client) readPump() {
docID = p.DocID
}
// Room subscription without requiring docID
if incoming.Event == "JOIN_ROOM" {
room := incoming.Room
if room == "" {
room = docID
}
if room != "" {
c.hub.JoinRoom(c, room)
}
} else if incoming.Event == "LEAVE_ROOM" {
room := incoming.Room
if room == "" {
room = docID
}
if room != "" {
c.hub.LeaveRoom(c, room)
}
}
if docID != "" {
switch incoming.Event {
case "LOCK_DOC":
@@ -789,61 +780,6 @@ func (c *Client) readPump() {
if c.hub.ReleaseDocLock(docID, c.userID, false) {
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
}
case "CRDT_JOIN":
c.hub.JoinRoom(c, "doc:"+docID)
if c.hub.crdtStore != nil {
stateBlob, updates, err := c.hub.crdtStore.GetDocumentCRDTState(docID)
if err == nil && (len(stateBlob) > 0 || len(updates) > 0) {
var b64Updates []string
for _, u := range updates {
b64Updates = append(b64Updates, base64.StdEncoding.EncodeToString(u))
}
b64State := ""
if len(stateBlob) > 0 {
b64State = base64.StdEncoding.EncodeToString(stateBlob)
}
c.SendEvent("CRDT_INIT", map[string]interface{}{
"docId": docID,
"state": b64State,
"updates": b64Updates,
})
}
}
case "CRDT_UPDATE":
var p struct {
Update string `json:"update"`
}
_ = json.Unmarshal(incoming.Payload, &p)
if p.Update != "" {
c.hub.BroadcastRoom("doc:"+docID, "CRDT_UPDATE", map[string]interface{}{
"docId": docID,
"update": p.Update,
"fromUserId": c.userID,
}, c)
if c.hub.crdtStore != nil {
if raw, err := base64.StdEncoding.DecodeString(p.Update); err == nil && len(raw) > 0 {
_ = c.hub.crdtStore.AppendDocumentCRDTUpdate(docID, raw, c.userID)
}
}
}
case "CRDT_COMPACT":
var p struct {
State string `json:"state"`
}
_ = json.Unmarshal(incoming.Payload, &p)
if p.State != "" && c.hub.crdtStore != nil {
if raw, err := base64.StdEncoding.DecodeString(p.State); err == nil && len(raw) > 0 {
_ = c.hub.crdtStore.SaveDocumentCRDTState(docID, raw, c.userID)
}
}
case "CRDT_AWARENESS":
c.hub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS", incoming.Payload, c)
case "CRDT_LEAVE":
c.hub.LeaveRoom(c, "doc:"+docID)
c.hub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
"docId": docID,
"userId": c.userID,
}, c)
}
}
}