Compare commits

...
68 Commits
Author SHA1 Message Date
sandrodachandClaude Sonnet 5.5 b6c197aba7 Merge branch 'dev' into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:09:40 +01:00
sandrodachandClaude Sonnet 5.5 bc13235da6 docs: rewrite documentation and README from current codebase, add flaw analysis
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Remove outdated claims (CRDT, DocRooms, presence, .md files on disk) and
document the actual architecture, API, permissions and known flaws.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:09:05 +01:00
sandrodachandClaude Sonnet 5.5 4e9c4ffa8c feat(tasks): add read-only sharing permission for task lists
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m5s
Task list shares now carry a permission (editor/viewer). The server rejects
writes from viewers with 403, and the UI hides or disables edit controls
for read-only lists.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 22:30:28 +01:00
sandrodach 8a32cdd525 Merge pull request 'Dev' (#8) from dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s
Reviewed-on: #8
2026-10-27 22:02:53 +01:00
sandrodach 6896a29285 chore(workspace): remove placeholder text from first bulletpoint
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 21:48:43 +01:00
sandrodach 554029a099 fix(workspace): group text and code blocks cleanly under headings on paste
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-27 21:40:51 +01:00
sandrodach c85f31eee5 fix(workspace): enable clicking into empty bulletpoint to focus cursor
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 21:33:18 +01:00
sandrodach 1c71b09f20 feat(workspace): hide bullet point on empty blocks unless hovered
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-27 21:29:05 +01:00
sandrodach 6a261e3453 feat(workspace): intelligent multi-line clipboard paste with markdown prefix & indentation parsing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-27 21:27:01 +01:00
sandrodach fdbf691a8d feat(workspace): split bulletpoint paragraph at cursor position on Enter
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 21:17:12 +01:00
sandrodach 5cf6b59377 feat(tasks): refine enhanced task linking, outliner badge positioning, completion sync and unlink behavior
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 21:09:25 +01:00
sandrodach 6129681035 chore(logging): remove excessive console.log and console.info statements in frontend
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 20:57:05 +01:00
sandrodach 63a5885155 fix(workspace): place cursor at end of line when jumping or clicking into bullet point
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 20:55:07 +01:00
sandrodach fd95c2f8da fix(api): add getThought endpoint and frontend api method to resolve docker tsc build error
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-27 20:36:58 +01:00
sandrodach 94acb2638e fix(workspace): fix outliner cursor focus, enhanced task linking, nav order and Hauptebene drop zone
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 19s
2026-10-27 20:26:45 +01:00
sandrodach d39d1d1122 feat: complete workspace, triage, outliner and sharing overhaul (tasks 1-26)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-27 19:46:46 +01:00
sandrodach df2f2bac67 feat(auth): log successful and failed login attempts with username and details
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 39s
2026-10-27 17:53:42 +01:00
sandrodach bce103e2c6 feat(outliner): prevent creating extra empty block on enter on last empty root bullet
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 17:43:20 +01:00
sandrodach 122ebccf8d refactor(outliner): make bullet point the drag handle and remove separate grip icon
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-27 17:41:25 +01:00
sandrodach e7eb94be45 fix(tasks): allow collaborative editor sharing in task lists
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-27 17:35:47 +01:00
sandrodach 2ecf71cc42 feat(lists): use unified modal for creating new shopping lists
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-27 17:33:20 +01:00
sandrodach fd89f92b0c feat(outliner): outdent empty bulletpoint on enter
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-27 17:31:08 +01:00
sandrodach 04fa774d10 refactor(collab): remove live document collaboration and enforce strictly read-only sharing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-27 17:08:38 +01:00
sandrodach aea05e868a feat(outliner): indent and outdent entire subtree with all children on Tab and Shift+Tab
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-27 16:06:31 +01:00
sandrodach 1bdd7d245d fix(outliner): move entire subtree with all children during drag and drop
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
2026-10-27 16:00:03 +01:00
sandrodach 266026da4c fix(workspace): make note editor use full available width instead of centering at max-w-4xl
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-27 15:49:40 +01:00
sandrodach 183199401d fix(collab): continuous document text synchronization preserving local cursor
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
2026-10-27 15:48:17 +01:00
sandrodach ee8d586e2f style(collab): right-align lock badge without shifting lines and highlight locked block with grey background
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 15:33:38 +01:00
sandrodach a1c7a65d84 fix(collab): fix corporate SSE fallback, realtime lock propagation and prevent stale overwrite
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-27 15:21:05 +01:00
sandrodach 996f27133c fix(collab): fix self-lockout, stuck live sessions, block locking propagation and reactive hashtags
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-27 14:57:30 +01:00
sandrodach 0e06cc5577 feat(collab): redesign note collaboration with REST auto-save, opt-in live session, and block locking
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 14:02:38 +01:00
sandrodach 50b017f387 fix(security): resolve FLAW-08 with token_version invalidation and jwt blacklist
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2026-10-27 12:09:15 +01:00
sandrodach f7e70b649f style: expand Workspace To-Dos and Hashtags views to full container width
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
2026-10-27 11:54:56 +01:00
sandrodach 8769aceb05 fix: instant graceful shutdown for SSE/WS connections and container stop
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 39s
2026-10-27 11:50:19 +01:00
sandrodach 5db126128e Merge pull request 'Dev' (#7) from dev into main
Reviewed-on: #7
2026-10-27 11:42:12 +01:00
sandrodach f74ff7c63b feat(workspace): hide folder sidebar in todos and tags, keep toggle button fixed and disabled
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-27 11:33:47 +01:00
sandrodach 37948d3a6a feat(workspace): move connection status indicator to top tabs header
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-27 11:32:25 +01:00
sandrodach 3d780856b0 refactor(workspace): remove duplicate new note button from top tabs
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
2026-10-27 11:11:44 +01:00
sandrodach 8ee7b135af style(tasks,lists): align header spacing with workspace by eliminating top margin
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-27 11:07:36 +01:00
sandrodach 894a2e3b76 style(workspace): move sidebar toggle to the left as icon-only button
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-27 11:02:28 +01:00
sandrodach 59ddca91f3 feat(workspace): align workspace UI with tasks and lists, add top tabs and collapsible sidebar
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-27 10:55:42 +01:00
sandrodach 7baab8faa2 fix(workspace): eliminate hover height shift on folder tree row with fixed height and opacity fade
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-27 08:49:08 +01:00
sandrodach 5a931e9bb6 style(workspace): style selected note with panel background and accent border matching task list selection
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-27 08:47:58 +01:00
sandrodach 60fd1c9ae7 feat(workspace): add hashtag tabs matching tasks view style and interactive tile grid with search and sorting
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-27 08:46:10 +01:00
sandrodach 23eff21520 style(workspace): style '+ Notiz' button in sidebar matching 'Neue Liste' dashed style
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-27 08:41:09 +01:00
sandrodach d11153309f fix(frontend): resolve TS2448 filteredDocs TDZ and import missing DocumentBlock in WorkspaceView
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2026-10-27 08:14:39 +01:00
sandrodach 7a8d759bf2 feat(workspace): real-time live child block resolution and interactive checkboxes in hashtag overview
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 18s
2026-10-27 07:27:12 +01:00
sandrodach 149cfdc244 fix(workspace): select and focus exact block line when jumping from todos or tags
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 18s
2026-10-27 07:23:23 +01:00
sandrodach 928cc72491 feat(workspace): enhance todo view switcher and add note-level collapsible cards in folder_note mode
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 17s
2026-10-27 07:21:31 +01:00
sandrodach 5144f7b01b fix(workspace): ensure immediate cursor autofocus and select on note title creation
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 19s
2026-10-27 07:19:47 +01:00
sandrodach ffa14b01a2 style(workspace): remove any border or box from doc title emoji
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 17s
2026-10-27 07:17:27 +01:00
sandrodach d3dd4c8cc6 fix(workspace): refine folder row drop target, depth-first tree sorting & auto-expansion
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 19s
2026-10-27 07:16:20 +01:00
sandrodach 27b3567008 style(workspace): align LinkModal 1:1 with UnifiedShareModal design language
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 17s
2026-10-27 07:11:28 +01:00
sandrodach 4850f9dfad feat(workspace): recursive folder tree, link modal redesign, todo grouping & line jumping, hashtag child listing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 23s
2026-10-27 07:09:48 +01:00
sandrodach 6c1898d489 feat(workspace): decouple folder sharing with independent mount points & real-time sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-26 22:53:51 +01:00
sandrodach 1776e87029 docs: update database schema, websocket protocol, workspace outliner spec, flaws backlog, and readme to match current architecture
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
2026-10-26 22:41:50 +01:00
sandrodach 634195b629 style(workspace): align UI styling with ListsView (rounded-full avatars, dark canvas borders, unified Radio live badge, unified Users share button)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
2026-10-26 22:36:32 +01:00
sandrodach cedf5aebff fix(sync): eliminate zombie focus, flush debounced state on window blur, remove stale closures and buffer ws messages
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-26 22:27:11 +01:00
sandrodach 06f5f7043c feat(workspace): add /link slash command with note selector and web link modal
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-26 22:19:53 +01:00
sandrodach 1bcf6b4f18 refactor(outliner): remove redundant /bullet slash command
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
2026-10-26 22:14:20 +01:00
sandrodach 48fe61fbce feat(logging): integrate slog logging with env control and request/sync tracing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 39s
- Configure structured slog logger in cmd/server/main.go via LOG_LEVEL and DEBUG env vars
- Add HTTP API request logging middleware in internal/api/ with status and duration_ms
- Add WebSocket join/leave, event reception, and debounced SQLite flush tracing in docroom
- Document LOG_LEVEL and DEBUG settings in .env.example, compose.yaml, and docker-compose.yml
2026-10-26 22:12:51 +01:00
sandrodach 177e24fdc8 feat(workspace): add focus mode and hoisting zoom-in for outliner blocks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
- Click bullet point (•) or hover zoom icon to zoom into any block branch
- Render hoisted block as prominent heading in focus mode with direct editing
- Display clickable breadcrumb path leading back to ancestors or root document
- Render isolated subtree with visual level offset and bound indentation
2026-10-26 22:04:53 +01:00
sandrodach 95977ba15a feat(workspace): add real-time live cursor and presence tracking
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
- Support ephemeral cursor_moved and client_left events in docroom Hub
- Keep active in-memory cursor presence map per document room
- Synchronize current presence state to newly connected WebSocket clients
- Allow read-only viewers to share cursor positions without document mutation rights
- Throttle frontend cursor move emissions to 100ms with instant blur cleanup
- Render active collaborator presence avatar pills in document header
- Display user-colored block indicator and non-destructive caret overlay with name tag
2026-10-26 21:57:19 +01:00
sandrodach 85e4010435 fix(workspace): restore theme design-system, fix 5x note creation, enforce privacy isolation, fix shared write permission, add drag-drop & slash-commands
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
2026-10-26 21:41:27 +01:00
sandrodach 86867b0d6f feat(workspace): tabula-rasa outliner architecture, SQLite blocks/block_tags schema & echo-protected live room sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
2026-10-26 21:14:06 +01:00
sandrodach f3b3f07e04 refactor(crdt): implement proxy-resilient Yjs dual-transport sync with indexeddb offline-first and debounced sqlite persistence
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m11s
2026-10-26 19:54:51 +01:00
sandrodach 297f3ed111 docs: remove non-existent flaw FLAW-09 (virtual keyboard viewport)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
2026-10-26 19:14:21 +01:00
sandrodach f90b0d5eb9 fix(realtime): eliminate cpu reconnect loop, singleton global connection & fix disappearing notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
2026-10-26 19:08:45 +01:00
47 changed files with 8951 additions and 9257 deletions

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 JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
BASE_URL=http://localhost:8080 BASE_URL=http://localhost:8080
ALLOW_REGISTRATION=false ALLOW_REGISTRATION=false
# Logging level: debug, info, warn, error (Default: info)
LOG_LEVEL=info LOG_LEVEL=info
# LOG_LEVEL=debug
# DEBUG=true
# Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI) # Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI)
SMTP_HOST= SMTP_HOST=
+29 -123
View File
@@ -1,134 +1,40 @@
# 📚 MindSift Technische Dokumentation & Wiki # MindSift Technische Dokumentation
Willkommen in der zentralen technischen Dokumentation von **MindSift**. Dieses Wiki dient als verbindliche, tiefgehende Referenz für Entwickler, DevOps-Ingenieure und Systemadministratoren. Es dokumentiert die Architektur, das Datenmodell, APIs, Echtzeit-Protokolle, Kernmodule sowie bekannte technische Schwachstellen der Plattform. Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code im Branch `dev` geprüft (Stand Oktober 2026). Abweichungen zwischen Doku und Code melde bitte als Fehler in [flaws.md](flaws.md).
--- ## Inhalt
## 🧭 Inhaltsverzeichnis (Wiki-Navigation) | Dokument | Inhalt |
|---|---|
| [architecture.md](architecture.md) | Aufbau, Technologie-Stack, Start, Konfiguration, Build |
| [database.md](database.md) | SQLite-Schema, Migrationen, Datenmodell |
| [api/rest.md](api/rest.md) | Alle REST-Endpunkte |
| [api/websocket.md](api/websocket.md) | Echtzeit über WebSocket und SSE, Ereignisse |
| [modules/triage.md](modules/triage.md) | Schnellerfassung und Triage |
| [modules/tasks.md](modules/tasks.md) | Aufgaben, Kanban, Alarme |
| [modules/workspace.md](modules/workspace.md) | Outliner-Notizen, Ordner, Tags |
| [modules/lists.md](modules/lists.md) | Geteilte Listen |
| [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben |
| [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare |
| [theming.md](theming.md) | Themes und Design-Variablen |
| [flaws.md](flaws.md) | Bekannte Schwachstellen und technische Schulden |
``` ## Überblick in 60 Sekunden
Doku/
├── README.md # Einstieg, Inhaltsverzeichnis & Gesamtübersicht (dieses Dokument)
├── architecture.md # Systemarchitektur, Schichtenmodell, Concurrency & Stack
├── database.md # SQLite-Datenbankdesign, Schemas, Indizes & Migrationen
├── api/
│ ├── rest.md # Vollständige REST-API Referenz aller Endpunkte
│ └── websocket.md # WebSocket-Echtzeitprotokoll, Rooms & Document Locking
├── modules/
│ ├── triage.md # Modul 1: Triage & Quick-Dump (State Machine & Audio)
│ ├── tasks.md # Modul 2: Aufgaben & Kanban-System (Alarme & Scheduler)
│ ├── workspace.md # Modul 3: Workspace Outliner-Engine, Wiki-Links & Pure-DB Storage
│ └── lists.md # Modul 4: Geteilte Listen (Live-Kollaboration & D&D)
├── sharing-and-permissions.md # Synchrones Teilen, Rollen, Isolation & Mount-Entkopplung
├── reverse-proxy.md # Nginx, Traefik, Caddy, Cloudflare Konfiguration & SSE
├── theming.md # Single-File CSS Theme-Engine & Design-System
└── flaws.md # Technische Schwachstellen, Risiken & zukünftige Optimierungen
```
### 📑 Kapitel im Detail - **Ein Go-Binary** liefert REST-API, WebSocket/SSE und das eingebettete React-Frontend (`go:embed`). Es gibt genau einen Container und eine SQLite-Datei (`/data/mindsift.db`).
1. **[Systemarchitektur & Technologie-Stack](architecture.md)** - **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`).
Schichtenmodell, Go-Backend, React-Frontend, Concurrency-Handling und Thread-Safety. - **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen. Verteilt werden Ereignisse nach REST-Änderungen. Es gibt keine CRDTs, Sperren oder Cursor-Presence.
2. **[Datenbank-Design & SQLite Schema](database.md)** - **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
Vollständiges relationales Datenmodell, ER-Diagramm, Tabellendefinitionen, Indizes und Migrationsstrategie. - **Freigaben:** Aufgabenlisten und geteilte Listen mit Rolle Bearbeiter oder Leser, Notizen und Ordner nur lesend.
3. **[REST-API Referenz](api/rest.md)**
Spezifikation aller Endpunkte für Authentifizierung, Gedanken, Aufgaben, Dokumente, Listen, Teams und Webhooks.
4. **[WebSocket & Echtzeit-Synchronisation](api/websocket.md)**
Gorilla WebSocket Hub, Rooms, Presence-Tracking, Yjs CRDT Kollaboration und SSE Fallback.
5. **[Kernmodul: Triage & Quick-Dump](modules/triage.md)**
Instant-Capture-Pipeline, Finite State Machine, Gestensteuerung, Spracheingabe und Webhook-Ingestion.
6. **[Kernmodul: Aufgaben & Kanban-System](modules/tasks.md)**
Multi-Listen-Konzept, 3-Spalten Kanban-Board, Subtasks, Fälligkeiten und Multi-Channel Alarm-Ticker (SMTP, Discord, Webhooks).
7. **[Kernmodul: Workspace & Outliner-Engine](modules/workspace.md)**
Logseq-inspirierte hierarchische Block-Engine, Zoom-Hoisting, Wiki-Links, Pure-Database Storage und Globales To-Do Rollup.
8. **[Kernmodul: Geteilte Listen & Kollaboration](modules/lists.md)**
Listen-Typen, Mengen, Kategorien, Live-Checkoff und Drag & Drop Sortierung.
9. **[Synchrones Teilen & Berechtigungskonzept](sharing-and-permissions.md)**
Rollenmodell (Admin/Member), System-Isolations-Modi (`strict_private`, `teams_only`, `open`), entkoppelte Ordner-Mounts und Notizen-Freigaben.
10. **[Reverse-Proxy- & Ingress-Handbuch](reverse-proxy.md)**
Produktionsfertige Konfigurationen für Nginx, Traefik, Caddy, Cloudflare und Apache mit WebSocket-Upgrades und SSE-Streaming.
11. **[Single-File Theming & Design-System](theming.md)**
1-Datei = 1-Theme Philosophie, die 6 CSS-Variablen-Blöcke, Semantische CSS-Klassen und Geometrie-Steuerung.
12. **[Technische Schwachstellen & Optimierungspotenziale (Flaws)](flaws.md)**
Kritische Analyse von Concurrency-Grenzen, Volltextsuche, Viewport-Anpassung und Skalierungs-Engpässen.
--- ## Entwickeln
## 🏛️ Systemübersicht (High-Level Architektur)
```mermaid
graph TB
subgraph Client ["Client Layer (Browser / PWA)"]
UI["React 18 SPA (Vite, TS, Tailwind)"]
WSClient["WebSocket Client (Auto-Reconnect)"]
Cache["IndexedDB / Offline Queue"]
end
subgraph ReverseProxy ["Reverse Proxy / Gateway"]
Traefik["Traefik / Nginx (HTTPS, WSS, Rate Limits)"]
end
subgraph Backend ["Backend Layer (Go 1.23 Engine)"]
ChiRouter["Chi Router (REST API)"]
WSHub["Gorilla WebSocket Hub & Lock Manager"]
AlarmTicker["Alarm Scheduler (Background Ticker)"]
StorageEngine["Storage Layer (Domain & Repositories)"]
end
subgraph Storage ["Storage Layer"]
SQLite[("SQLite WAL Database (/data/mindsift.db)")]
end
subgraph External ["Externe Schnittstellen"]
HomeAssistant["Home Assistant / Webhooks"]
Discord["Discord Webhooks"]
SMTPServer["SMTP Server (E-Mail Alarme)"]
end
UI -->|HTTPS REST| Traefik
WSClient -->|WSS Realtime| Traefik
Traefik --> ChiRouter
Traefik --> WSHub
ChiRouter --> StorageEngine
StorageEngine --> SQLite
AlarmTicker --> Discord
AlarmTicker --> SMTPServer
HomeAssistant -->|POST /api/v1/inbox| Traefik
HomeAssistant -->|POST /api/v1/webhooks/ingest| Traefik
```
---
## 🛠️ Schnellstart für Entwickler
### Voraussetzungen
- **Go:** 1.22 oder 1.23+
- **Node.js:** 20+ (LTS) & npm
- **Container-Runtime:** Docker oder Podman
### Lokale Entwicklung ohne Container
```bash ```bash
# 1. Repository klonen # Backend (Go 1.23)
git clone https://git.dsit.me/sandrodach/MindSift.git go vet ./... && go test ./...
cd MindSift
# 2. Frontend Abhängigkeiten installieren & Dev-Server starten # Frontend (Node 22)
cd frontend cd frontend && npm ci && npx tsc --noEmit && npm run build
npm install
npm run dev
# 3. Backend starten (in neuem Terminal)
cd ..
go run ./cmd/server
``` ```
### Container Build & Testlauf (wie in der CI/CD) Das Frontend wird für das Binary nach `cmd/server/dist` gebaut (siehe `Dockerfile`). Die CI läuft unter `.gitea/workflows/build-and-publish.yaml`.
```bash
# Backend-Tests ausführen
podman run --rm -v $(pwd):/app:z -w /app golang:1.23 go test -v ./internal/...
# Frontend Typecheck & Build testen
podman run --rm -v $(pwd):/app:z -w /app/frontend node:20 sh -c "npx tsc --noEmit && npm run build"
# Komplettes Multi-Stage Image bauen
podman build -t mindsift:dev .
```
+119 -175
View File
@@ -1,185 +1,129 @@
# 🔌 REST-API Referenz # REST-API
Die MindSift REST-API ermöglicht den vollständigen Zugriff auf Gedanken, Aufgaben, Workspace-Notizen, Listen, Benutzerkonten und Smarthome-Automationen. Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>` oder `Bearer mindsift_sec_…` bzw. Header `X-API-Key`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
--- ## Öffentlich
## 🔐 1. Authentifizierung | Methode | Pfad | Zweck |
|---|---|---|
| GET | `/health` | Lebenszeichen |
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode` |
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md) |
| GET | `/events` | SSE-Fallback |
| POST | `/events/rooms` | SSE-Räume beitreten/verlassen ([FLAW-02](../flaws.md)) |
MindSift unterstützt zwei Authentifizierungsmethoden: ## Konto und Allgemeines (angemeldet)
1. **JSON Web Token (JWT Bearer):** | Methode | Pfad | Zweck |
Wird beim Login oder Setup erzeugt und im HTTP-Header mitgesendet: |---|---|---|
`Authorization: Bearer <jwt-token>` | GET | `/auth/me` | Benutzer und Kennzahlen |
2. **Persönliche API-Schlüssel (Smarthome / Automatisierung):** | POST | `/auth/logout` | Token sperren |
Werden unter *Einstellungen ➔ Private Webhooks* erzeugt (Präfix `mindsift_sec_...`): | POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
- Per HTTP-Header: `X-API-Key: mindsift_sec_xxxxxxxxxxxx` | PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` |
- Per Authorization-Header: `Authorization: Bearer mindsift_sec_xxxxxxxxxxxx` | POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
- Per Query-Parameter: `?token=mindsift_sec_xxxxxxxxxxxx` | GET | `/users/directory`, `/collaborators` | Benutzerverzeichnis für Freigaben ([FLAW-10](../flaws.md)) |
| GET | `/stats` | Zähler fürs Dashboard |
| GET | `/search?q=` | Suche in eigenen Gedanken und Notizen |
| GET | `/export` | Datenexport als JSON |
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
--- ## Gedanken, Triage, Aufgaben
## 🚦 2. Status- & Fehler-Codes | Methode | Pfad | Zweck |
|---|---|---|
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`); Löschen |
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
| GET | `/triage/deck` | Posteingang (max. 100) |
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
- `200 OK`: Erfolgreiche Abfrage oder Aktualisierung. ## Aufgabenlisten
- `201 Created`: Ressource erfolgreich angelegt.
- `400 Bad Request`: Ungültiges JSON oder fehlende Pflichtfelder.
- `401 Unauthorized`: Token ungültig, abgelaufen oder fehlend.
- `403 Forbidden`: Unzureichende Berechtigungen (z.B. nur Server-Admin oder nur Lesezugriff).
- `404 Not Found`: Ressource existiert nicht oder gehört einem anderen Benutzer.
- `409 Conflict`: Ressource gesperrt (z.B. Dokument wird bereits von anderem Benutzer bearbeitet).
Fehlerrückgaben erfolgen im einheitlichen Format: | Methode | Pfad | Zweck |
```json |---|---|---|
{ | GET/POST | `/task-lists` | Listen (`permission` je Liste: `owner`/`editor`/`viewer`); anlegen (`title`, `icon`, `color`, `teamId`) |
"error": "Fehlermeldung im Klartext" | PUT/DELETE | `/task-lists/{id}` | Ändern (`title`, `icon`, `color`, `teamId`, `columns`) – nicht für Leser; Löschen nur Eigentümer |
} | GET | `/task-lists/{id}/shares` | Freigaben |
| POST | `/task-lists/{id}/shares` | Nur Eigentümer. Body: `targetType` (`team`/`user`), `targetId`, `permission` (`editor`/`viewer`, Standard `editor`). Erneutes Senden für dasselbe Ziel ändert die Stufe |
| DELETE | `/task-lists/{id}/shares/{shareId}` | Nur Eigentümer |
Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Keine Schreibrechte für diese Aufgabenliste (nur Lesen)“).
## Workspace
| Methode | Pfad | Zweck |
|---|---|---|
| GET/POST/DELETE | `/folders` | Ordnerliste, anlegen (`path`), löschen |
| POST | `/folders/move` | `sourcePath`, `targetParentPath` |
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
| PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) |
| GET/POST/DELETE | `/workspace/folders/shared/{id}/shares[/{shareId}]` | Freigaben (nur Lesen wirksam) |
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`); Löschen |
| PATCH | `/documents/{id}/blocks` | Teil-Update der Blöcke (`upserted`, `deletedIds`, Reihenfolge); löst `DOC_BLOCKS_PATCHED` aus |
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`; wirksam nur Lesen) |
Nur der Eigentümer schreibt in eine Notiz; Empfänger erhalten `403`/„nur Lesezugriff“ ([FLAW-17](../flaws.md)).
## Geteilte Listen
| Methode | Pfad | Zweck |
|---|---|---|
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
| GET/POST/DELETE | `/lists/{id}/shares[/{shareId}]` | Freigaben (`targetType`, `targetId`, `role`: `editor`/`viewer`) |
| POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) |
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
| DELETE | `/lists/{id}/items/{itemId}` | Löschen |
| POST/PUT | `/lists/{id}/items/reorder` | Reihenfolge/Kategorie (`items[{id, sortOrder, category, content}]`) |
| POST | `/lists/{id}/clear-completed` | Erledigte entfernen |
## Gruppen
| Methode | Pfad | Zweck |
|---|---|---|
| GET/POST | `/teams` | Eigene Gruppen; anlegen (`name`, `description`) – Ersteller wird Gruppen-Admin |
| PUT/DELETE | `/teams/{id}` | Nur Gruppen-Admin |
| POST | `/teams/{id}/members` | Mitglied hinzufügen (`userId` oder `email`, `role`) |
| PUT/DELETE | `/teams/{id}/members/{userId}` | Rolle ändern (`admin`/`member`); entfernen |
| GET/POST/DELETE | `/organizations`, `/organizations/{id}[/teams]` | Veraltet, ungeschützt ([FLAW-24](../flaws.md)) |
## Administration (`role = admin`)
| Methode | Pfad | Zweck |
|---|---|---|
| GET/POST | `/admin/users` | Benutzer auflisten, anlegen (`email`, `password`, `displayName`, `role`) |
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `teamId`, `validDays`) |
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode`, SMTP-Felder |
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
## Beispiele
```bash
# Gedanken per API-Key erfassen
curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
# Aufgabenliste nur lesend freigeben
curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"targetType":"user","targetId":"usr_…","permission":"viewer"}'
``` ```
---
## 📋 3. Endpunkte nach Modulen
### 3.1 Authentifizierung & System-Status
| Methode | Pfad | Auth | Beschreibung |
| :--- | :--- | :--- | :--- |
| `GET` | `/api/v1/health` | Öffentlich | Liveness-Check (`{"status":"ok"}`) |
| `GET` | `/api/v1/auth/status` | Öffentlich | Prüft ob Ersteinrichtung nötig ist (`needsSetup: true/false`) |
| `POST` | `/api/v1/auth/setup` | Öffentlich | Führt First-Boot Setup durch (legt ersten Admin an) |
| `POST` | `/api/v1/auth/register` | Öffentlich | Registrierung über Einladungscode (`inviteCode`) |
| `POST` | `/api/v1/auth/login` | Öffentlich | Login via E-Mail oder Benutzername + Passwort |
| `GET` | `/api/v1/auth/me` | JWT | Gibt das aktuelle Benutzerprofil zurück |
| `PATCH` | `/api/v1/auth/settings` | JWT | Aktualisiert Profil, Passwort, Theme oder Alarm-Kanäle |
| `POST` | `/api/v1/auth/alarms/test` | JWT | Sendet Test-Alarm an E-Mail, Discord oder Custom Webhook |
#### Beispiel: `POST /api/v1/auth/login`
```json
// Request
{
"login": "sandro",
"password": "geheimesPasswort123"
}
// Response (200 OK)
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "usr_abc123",
"email": "sandro@example.com",
"username": "sandro",
"displayName": "Sandro",
"role": "admin",
"alarmChannels": ["discord", "webhook"]
}
}
```
---
### 3.2 Triage & Instant Capture
| Methode | Pfad | Auth | Beschreibung |
| :--- | :--- | :--- | :--- |
| `POST` | `/api/v1/inbox` | JWT / API-Key | Erfasst einen neuen Quick Dump oder leitet direkt weiter |
| `GET` | `/api/v1/triage/deck` | JWT | Liefert den Stapel aller noch nicht triagierten Gedanken (`status: 'inbox'`) |
| `PATCH` | `/api/v1/triage/{id}` | JWT | Führt Triage-Aktion aus (`keep`, `discard`, `restore`) |
#### Beispiel: `POST /api/v1/inbox`
Ermöglicht flexible Direkt-Zuordnungen:
```json
// Request (Quick Dump in Triage-Deck)
{
"content": "Kellerfeuchtigkeit über 68% gestiegen #smarthome #alarm",
"priority": "urgent"
}
// Request (Direkt als Aufgabe anlegen)
{
"content": "Server Backup prüfen #devops",
"priority": "high",
"target": "task",
"dueDate": "2026-10-30"
}
```
*Hinweis:* Wenn `/api/v1/inbox` über einen API-Key aufgerufen wird und kein `source` definiert ist, wird die Quelle automatisch als `"webhook"` markiert!
---
### 3.3 Aufgaben & Kanban-System
| Methode | Pfad | Auth | Beschreibung |
| :--- | :--- | :--- | :--- |
| `GET` | `/api/v1/tasks` | JWT | Liefert alle Aufgaben des Benutzers (inkl. geteilter Aufgaben) |
| `POST` | `/api/v1/tasks/reorder` | JWT | Speichert Kanban-Status und Reihenfolge (`sort_order`) |
| `GET` | `/api/v1/task-lists` | JWT | Liefert alle Aufgabenlisten (eigene & geteilte) |
| `POST` | `/api/v1/task-lists` | JWT | Erstellt eine neue Aufgabenliste (mit Farbe & Spalten) |
| `PUT` | `/api/v1/task-lists/{id}` | JWT | Ändert Titel, Farbe, Icon oder Kanban-Spalten |
| `DELETE` | `/api/v1/task-lists/{id}` | JWT | Löscht Aufgabenliste |
| `POST` | `/api/v1/task-lists/{id}/shares` | JWT | Teilt Liste mit Team oder Benutzer |
---
### 3.4 Workspace & Dokumente
| Methode | Pfad | Auth | Beschreibung |
| :--- | :--- | :--- | :--- |
| `GET` | `/api/v1/documents` | JWT | Liefert Liste aller Dokumente des Benutzers (Metadaten) |
| `POST` | `/api/v1/documents` | JWT | Erstellt neues Notiz-Dokument (mit Blocks oder Markdown) |
| `GET` | `/api/v1/documents/{id}` | JWT | Lädt komplettes Dokument inklusive aller Blöcke |
| `PUT` | `/api/v1/documents/{id}` | JWT | Aktualisiert Dokumenttitel, Icon oder Ordnerpfad |
| `PATCH` | `/api/v1/documents/{id}/blocks` | JWT | Granulares Patching von Dokument-Blöcken (Database-First) |
| `DELETE` | `/api/v1/documents/{id}` | JWT | Löscht Dokument (Datenbank & physische Datei) |
| `POST` | `/api/v1/documents/{id}/shares` | JWT | Notiz mit anderem Benutzer teilen (`read` / `write`) |
| `GET` | `/api/v1/documents/{id}/lock` | JWT | Fragt Bearbeitungs-Sperrstatus ab |
| `POST` | `/api/v1/documents/{id}/lock` | JWT | Sperrt Dokument für Bearbeitung (30s Lease) |
| `DELETE` | `/api/v1/documents/{id}/lock` | JWT | Gibt Bearbeitungssperre wieder frei |
---
### 3.5 Workspace Ordner & Geteilte Ordner
| Methode | Pfad | Auth | Beschreibung |
| :--- | :--- | :--- | :--- |
| `GET` | `/api/v1/folders` | JWT | Liefert alle Ordnerpfade des Benutzers |
| `POST` | `/api/v1/folders` | JWT | Legt neuen (auch leeren) Ordner an |
| `POST` | `/api/v1/folders/move` | JWT | Benennt Ordner um oder verschiebt ihn hierarchisch |
| `DELETE` | `/api/v1/folders` | JWT | Löscht Ordner und verschiebt Notizen |
| `GET` | `/api/v1/workspace/folders/shared` | JWT | Liefert für den Benutzer freigegebene geteilte Ordner |
| `POST` | `/api/v1/workspace/folders/shared` | JWT | Erstellt neuen geteilten Ordner |
| `PUT` | `/api/v1/workspace/folders/shared/{id}/mount` | JWT | Ändert den lokalen Einhängepfad beim Empfänger |
| `POST` | `/api/v1/workspace/folders/shared/{id}/shares` | JWT | Gibt Ordner für Teams oder Benutzer frei |
---
### 3.6 Geteilte Listen (Listen & Einkauf)
| Methode | Pfad | Auth | Beschreibung |
| :--- | :--- | :--- | :--- |
| `GET` | `/api/v1/lists` | JWT | Liefert alle geteilten Listen |
| `POST` | `/api/v1/lists` | JWT | Erstellt neue geteilte Liste |
| `GET` | `/api/v1/lists/{id}` | JWT | Lädt Liste inklusive aller Einträge |
| `POST` | `/api/v1/lists/{id}/items` | JWT | Fügt Eintrag hinzu |
| `PATCH` | `/api/v1/lists/{id}/items/{itemId}` | JWT | Hakt Eintrag ab (`isCompleted: true/false`) |
| `POST` | `/api/v1/lists/{id}/items/reorder` | JWT | Speichert sortierte Einträge |
| `POST` | `/api/v1/lists/{id}/clear-completed`| JWT | Löscht alle abgehakten Einträge |
| `POST` | `/api/v1/lists/{id}/shares` | JWT | Teilt Liste mit Team oder Benutzer |
---
### 3.7 Smarthome Ingestion & Externe Webhooks
| Methode | Pfad | Auth | Beschreibung |
| :--- | :--- | :--- | :--- |
| `POST` | `/api/v1/webhooks/ingest` | Token / Key | Empfängt externe Webhook-Payloads von Home Assistant / n8n |
#### Beispiel: `POST /api/v1/webhooks/ingest?token=mindsift_sec_...`
Unterstützt flexible Payloads (JSON oder Plain Text):
```json
{
"title": "Waschmaschine Keller fertig",
"priority": "high",
"tags": ["smarthome", "keller"]
}
```
*Live-Aktualisierung:* Sendet automatisch ein `TASK_CREATED` WebSocket-Event an alle Clients des Benutzers, sodass die neue Karte sofort ohne Neuladen im Triage-Deck erscheint!
+37 -152
View File
@@ -1,170 +1,55 @@
# 📡 WebSocket & Echtzeit-Synchronisation # Echtzeit: WebSocket und SSE
MindSift implementiert eine bidirektionale Echtzeit-Synchronisationsschicht auf Basis von WebSockets ([`internal/ws/hub.go`](../../internal/ws/hub.go)). Sie sorgt dafür, dass Änderungen an Aufgaben, Triage-Karten, Notizen und geteilten Einkaufslisten ohne manuelles Neuladen sofort auf allen geöffneten Geräten und bei allen Kollaborationspartnern sichtbar werden. Der Server verteilt Änderungen als JSON-Ereignisse an verbundene Clients. Die Ereignisse entstehen **nach** einer REST-Änderung; es gibt keinen Schreibkanal über die Verbindung. Implementierung: [hub.go](../../internal/ws/hub.go), Client: [api.ts](../../frontend/src/lib/api.ts).
--- > Keine CRDTs, keine Sperren, keine Cursor-Presence. Frühere Fassungen dieser Doku beschrieben DocRooms, `clientId`-Echo-Schutz und Yjs; das existiert nicht (mehr).
## 🔌 1. Verbindung & Handshake ## Transporte
### Verbindungs-Endpunkte | Transport | Endpunkt | Hinweise |
``` |---|---|---|
wss://<domain>/api/v1/ws?token=<jwt-token> | WebSocket | `GET /api/v1/ws` (auch `/ws/lists/{id}`, `/ws/spaces/{id}`) | Ping alle 30 s, Lesefrist 60 s, Nachrichten bis 4 MB |
wss://<domain>/api/v1/ws/lists/<listId>?token=<jwt-token> | SSE | `GET /api/v1/events` | Kommentar-Ping alle 15 s, Header `X-Accel-Buffering: no` |
```
### Authentifizierung & Upgrade Der Client versucht zuerst WebSocket. Gelingt der Aufbau nicht innerhalb von 2,5 s oder bricht die Verbindung unsauber ab, wechselt er auf SSE und bleibt dabei (Firmen-Proxys). Dort liefert der Server `SSE_CONNECTED` mit einer `sseId`; Räume werden über `POST /api/v1/events/rooms` (`sseId`, `room`, `action`: `join`/`leave`) abonniert.
- Die Authentifizierung erfolgt über das JWT-Token im Query-Parameter `?token=...` oder den `Authorization: Bearer`-Header während des HTTP-Upgrade-Requests.
- Der Server extrahiert die `userID` und registriert die Verbindung im internen `Hub`.
- Der `Upgrader` akzeptiert alle Origins (`CheckOrigin: return true`), um volle Kompatibilität mit Reverse-Proxies (Traefik, Nginx, Caddy, Cloudflare) und Docker-Netzwerken zu gewährleisten.
--- **Authentifizierung:** Token als `?token=<JWT|API-Key>` oder `Authorization`-Header. Ohne gültiges Token wird die Verbindung als `anonymous` akzeptiert ([FLAW-01](../flaws.md), [FLAW-02](../flaws.md)). Das Token wird nur beim Verbinden geprüft.
## 🏛️ 2. Hub-Architektur & Rooms ## Nachrichtenformat
Der WebSocket-Hub unterteilt Verbindungen in drei Abstraktionsebenen:
```
┌─────────────────────────────────────────────────────────────────┐
│ WebSocket Hub │
├─────────────────┬─────────────────────────────┬─────────────────┤
│ allClients │ userRooms │ rooms │
│ (Alle aktiven │ (Pro Benutzer-ID) │ (Pro Liste/ │
│ Verbindungen) │ Cross-Device Sync für │ Space) │
│ │ Tasks, Triage & Workspace │ Geteilte Liste │
└────────┬────────┴──────────────┬──────────────┴────────┬────────┘
▼ ▼ ▼
Presence Stats TASK_CREATED ITEM_TOGGLED
DOC_LOCKED TASK_UPDATED ITEM_CREATED
DOC_UNLOCKED DOC_UPDATED LIST_UPDATED
```
1. **`allClients` (Global):** Hält jede aktive Client-Verbindung. Ermöglicht Broadcasts systemweiter Ereignisse (z.B. globale Dokument-Sperrungen).
2. **`userRooms[userID]` (Benutzer-Räume):** Sendet Events an alle Geräte desselben Benutzers (z.B. Desktop und Smartphone). Erzeugt ein Webhook oder Quick Dump auf dem Smartphone einen Eintrag, taucht dieser zeitgleich auf dem Desktop auf.
3. **`rooms[listID]` (Kollaborations-Räume):** Multiplexing für geteilte Listen. Alle Mitglieder einer Liste empfangen Aktionen (wie Abhaken, Löschen, Umsortieren) in Echtzeit.
---
## 📨 3. Nachrichten-Format (Envelope)
Alle WebSocket-Nachrichten werden als JSON mit einheitlicher Hülle übertragen:
```json ```json
{ { "event": "TASK_UPDATED", "listId": "", "payload": { … }, "timestamp": "2026-10-27T12:00:00Z" }
"event": "ITEM_TOGGLED",
"listId": "lst_12345",
"payload": {
"itemId": "itm_987",
"isCompleted": true,
"completedBy": "usr_sandro",
"completedByName": "Sandro"
},
"timestamp": "2026-10-26T13:30:00Z"
}
``` ```
--- Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":"<id>"}` und `{"event":"LEAVE_ROOM","room":"<id>"}`. Räume heißen `doc:<docId>` für Notizen oder `<listId>` für geteilte Listen.
--- ## Zustellung
## ⚡ 4. CRDT-Echtzeitprotokoll (Yjs Collaborative Editing) | Kanal | Wer erhält es |
|---|---|
| Benutzer-Kanal (`BroadcastToUser`) | alle Verbindungen eines Benutzers |
| Raum (`BroadcastRoom`, `Broadcast`) | Verbindungen im Raum |
| Alle (`BroadcastToAll`) | **jede** Verbindung, auch unangemeldete |
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: ## Ereignisse
### CRDT-Nachrichtentypen | Ereignis | Auslöser | Kanal |
| Event | Richtung | Zweck / Payload |
|---|---|---| |---|---|---|
| `CRDT_JOIN` | Client ➔ Server | Tritt dem Dokumentenraum (`doc:{docId}`) bei. | | `TASK_CREATED` | `POST /inbox` | Benutzer |
| `CRDT_INIT` | Server ➔ Client | Sendet den aktuellen Basiszustand (`stateBlob`) und offene Delta-Updates (`updates[]`) an den Client. | | `TASK_UPDATED` | `PATCH /thoughts/{id}` (Triage: Benutzer) | alle |
| `CRDT_UPDATE` | Bidirektional | Überträgt inkrementelle binäre Yjs-Updates (Base64-kodiert). Server speichert in `document_crdt_updates` und verteilt an alle anderen Raumnutzer. | | `TASK_DELETED` | `DELETE /thoughts/{id}` | alle |
| `CRDT_COMPACT` | Client ➔ Server | Komprimiert mehrere Updates zu einem konsolidierten Snapshot in `document_crdt_state`. | | `TASK_REORDERED` | Aufgaben umsortieren | alle |
| `CRDT_AWARENESS` | Bidirektional | Überträgt Live-Präsenz (Fokus auf `blockId`, Benutzername, Farbe). | | `TASK_LIST_UPDATED` / `TASK_LIST_DELETED` | Liste ändern, (un)teilen, löschen | alle |
| `CRDT_AWARENESS_LEAVE` | Bidirektional | Signalisiert, dass ein Nutzer den Fokus verloren hat oder das Dokument verlassen hat. | | `DOC_CREATED`, `DOC_UPDATED`, `DOC_DELETED` | Notiz anlegen/ändern/löschen, Freigaben | berechtigte Benutzer |
| `CRDT_LEAVE` | Client ➔ Server | Verlässt den Dokumentenraum explizit. | | `DOC_BLOCKS_PATCHED` | `PATCH /documents/{id}/blocks` (`docId`, `updatedBy`, `upserted`, `deletedIds`, `blockOrder`) | Raum `doc:<id>` und berechtigte Benutzer |
| `WORKSPACE_FOLDERS_UPDATED` | Ordner/Mounts ändern | Benutzer |
| `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste |
| `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste |
| `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum |
| `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung |
```mermaid ## Verhalten im Frontend
sequenceDiagram
autonumber
actor UserA as Benutzer A
participant WS as WebSocket Hub & SQLite
actor UserB as Benutzer B
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->>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->>WS: CRDT_AWARENESS (docId, focusedBlockId)
WS->>UserB: CRDT_AWARENESS (docId, user, focusedBlockId)
Note over UserB: Avatar-Badge erscheint am Block
```
---
## 🔒 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.
---
## 🔄 5. 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).
MindSift löst dieses Problem durch eine **vollwertige Dual-Transport-Architektur** ([FLAW-03](../flaws.md)):
```
┌───────────────────────────────┐
│ MindSift Frontend │
└───────────────┬───────────────┘
│
┌─────────────────────┴─────────────────────┐
▼ ▼
Standard: WebSockets Fallback: SSE + REST
(wss://.../api/v1/ws) (https://.../api/v1/events)
│ │
│ [Proxy Upgrade Block (Code 1006/Timeout)]│
│ ────────────────────────────────────────> │
▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
│ Gorilla WS Conn │ │ Server-Sent Events │
│ Bidirektionaler │ │ (text/event-stream) │
│ TCP-WebSocket │ │ Standard HTTPS Port │
└────────────┬───────────┘ └────────────┬───────────┘
│ │
└─────────────────────┬─────────────────────┘
▼
┌─────────────────────────────┐
│ Realtime Hub │
│ (Multiplexing WS & SSE) │
└─────────────────────────────┘
```
### 1. Automatischer SSE-Fallback (`GET /api/v1/events`)
- Scheitert der WebSocket-Handshake oder blockiert ein Proxy das Upgrade, wechselt MindSift innerhalb von 3,5 Sekunden automatisch auf einen **Server-Sent Events (SSE) Stream**.
- Da SSE ein standardmäßiger HTTP/1.1- bzw. HTTP/2-GET-Request über Standard-Port 443/80 ist (`Content-Type: text/event-stream`), wird er von jedem Proxy und jeder Firewall ungehindert durchgeleitet.
- 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
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`).
### 3. Verbindungs-Indikator
- **Grün pulsierend:** Live verbunden über WebSockets (`Live (WebSocket)`).
- **Türkis pulsierend:** Live verbunden über Server-Sent Events (`Live (Server-Sent Events / Proxy-Stream)`).
- **Gelb/Amber:** Temporäre Unterbrechung mit automatischem HTTP-Polling-Schutz.
Ausführliche Konfigurationsbeispiele für Nginx, Traefik, Caddy, Cloudflare und Apache finden sich im separaten Handbuch:
👉 **[Reverse-Proxy-Konfigurationshandbuch](../reverse-proxy.md)**
- Die globale Verbindung (ein Singleton) leitet jede Nachricht als Browser-Ereignis `mindsift:ws` weiter; Ansichten (`TasksView`, `WorkspaceView`, `SharedListsView`) hören darauf und laden bzw. patchen ihren Zustand.
- Nach einem Abbruch verbindet sich der Client mit Verzögerung neu.
- Weil Notizen nur vom Eigentümer geschrieben werden, dient `DOC_BLOCKS_PATCHED` vor allem dazu, Lesenden und weiteren Geräten des Eigentümers Änderungen zu zeigen. Es gibt keine Konfliktauflösung (letzter Schreiber gewinnt).
+73 -128
View File
@@ -1,142 +1,87 @@
# 🏛️ Systemarchitektur & Technologie-Stack # Architektur
Dieses Dokument beschreibt die interne Architektur von MindSift, die Interaktion zwischen den Systemkomponenten, das Concurrency- und Thread-Safety-Modell sowie die Deployment-Strategie. ## Überblick
---
## 🧩 1. Schichten-Architektur
MindSift ist als monolithische, hochperformante Single-Binary-Lösung konzipiert. Das Go-Backend liefert sowohl die REST- und WebSocket-Schnittstellen aus als auch die vorkompilierte React Single Page Application (SPA) als eingebettetes statisches Dateisystem (`embed.FS`).
``` ```
┌────────────────────────────────────────────────────────┐ Browser (React SPA, PWA-Manifest)
│ Frontend (React 18) │ │ REST /api/v1/* Authorization: Bearer <JWT | mindsift_sec_…>
│ Vite • TypeScript • Tailwind CSS • Lucide • Motion │ │ WebSocket /api/v1/ws Fallback: SSE /api/v1/events
└───────────────────────────┬────────────────────────────┘ ▼
│ HTTPS REST / WSS WebSocket Go-Server (chi) ── internal/api Handler, Middleware, Alarm-Ticker
┌───────────────────────────▼────────────────────────────┐ │ internal/ws Hub (Räume, Benutzer-Kanäle, SSE)
│ Go Chi Routing Layer │ │ internal/auth JWT, bcrypt, API-Key-Hash
│ CORS • RealIP • Recoverer • Auth / Role Guard │ ▼ internal/config
├───────────────────────────┬────────────────────────────┤ SQLite (modernc.org/sqlite, WAL) ── internal/storage
│ Business Engine │ Gorilla WebSocket │
│ Triage • Tasks • Alarms │ Rooms • Hub • Doc-Locking │
├───────────────────────────┴────────────────────────────┤
│ Storage Abstraction │
│ sqlite.go (modernc.org/sqlite pure Go, WAL-Mode) │
└───────────────────────────┬────────────────────────────┘
│
┌─────────────┴─────────────┐
▼ ▼
/data/mindsift.db /data/workspace/
(SQLite Datenbank) (Physische .md Dateien)
``` ```
--- Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/main.go)). Unbekannte Pfade liefern `index.html` (SPA-Routing); `index.html`, `sw.js` und `manifest.json` werden mit `no-store` ausgeliefert.
## 🛠️ 2. Technologie-Stack ## Stack
### Backend (Go 1.23) | Schicht | Technik |
- **HTTP Routing:** [`github.com/go-chi/chi/v5`](https://github.com/go-chi/chi) – Minimalistischer, idiomatisch schneller Router mit Middleware-Ketten. |---|---|
- **WebSocket Hub:** [`github.com/gorilla/websocket`](https://github.com/gorilla/websocket) – Verbindungs-Upgrade, Keep-Alive/Ping-Pong und Room-Multiplexing. | Backend | Go 1.23, `go-chi/chi` v5, `go-chi/cors`, `gorilla/websocket`, `golang-jwt/jwt` v5, `x/crypto/bcrypt` |
- **Datenbank-Treiber:** [`modernc.org/sqlite`](https://gitlab.com/cznic/sqlite) – CGO-freier, rein in Go übersetzter SQLite-Treiber für einfache plattformübergreifende Kompilierung und Containerisierung ohne GCC-Toolchain. | Datenbank | SQLite über `modernc.org/sqlite` (reines Go, kein CGO), WAL, `foreign_keys=ON` |
- **Authentifizierung:** [`github.com/golang-jwt/jwt/v5`](https://github.com/golang-jwt/jwt) (HMAC-SHA256 Token) und [`golang.org/x/crypto/bcrypt`](https://pkg.go.dev/golang.org/x/crypto/bcrypt) für Passwort-Hashing. | Frontend | React 18, TypeScript, Vite 5, Tailwind 3, framer-motion (Triage-Karten), lucide-react, prismjs (Code-Blöcke) |
| Auslieferung | Mehrstufiges Dockerfile (Node 22 → Go 1.23 → Alpine 3.21), Image `git.dsit.me/sandrodach/mindsift` |
### Frontend (React 18 SPA) ## Verzeichnisse
- **Build-Tool:** [Vite 5](https://vitejs.dev/) für schnelles Hot-Module-Replacement (HMR) und optimierte Production-Bundles.
- **Sprache:** TypeScript 5 mit strikter Typisierung.
- **Design & Layout:** [Tailwind CSS 3](https://tailwindcss.com/) in Verbindung mit dem MindSift Single-File CSS Theming System.
- **Icons & Animationen:** [Lucide React](https://lucide.dev/) und [Framer Motion](https://www.framer.com/motion/) für Gesten, Karten-Swipes und Übergänge.
--- | Pfad | Inhalt |
|---|---|
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
| `internal/api/` | `server.go` (Routen, Handler, Alarm-Ticker, SMTP/Discord/Webhook-Versand) |
| `internal/storage/` | `sqlite.go` (Schema, Migrationen, gesamte Datenlogik) |
| `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
| `internal/ws/` | `hub.go` |
| `internal/auth/` | Tokens und Passwörter |
| `internal/config/` | Umgebungsvariablen |
| `frontend/src/features/` | `triage`, `workspace`, `lists`, `teams`, `settings`, `capture`, `auth`, `help` |
| `frontend/src/lib/` | `api.ts` (REST-Client, Transport-Umschaltung WS/SSE), `types.ts`, `markdown.tsx`, `theme.ts` |
| `frontend/src/themes/` | CSS pro Theme |
## 📁 3. Verzeichnisstruktur des Projekts ## Start und Laufzeit
1. `config.Load()` liest die Umgebung (siehe unten).
2. `storage.NewStore()` öffnet SQLite (`busy_timeout`, WAL, max. 8 Verbindungen) und führt `migrate()` aus.
3. SMTP-Werte aus der Umgebung werden in `system_settings` übernommen, sofern dort noch nichts steht.
4. Mit Demo-Flag legt `EnsureDemoAccounts` die Konten `admin/admin` und `user/user` an.
5. `api.NewServer` startet den **Alarm-Ticker** (jede Minute) und baut den Router. Server-Timeouts: Lesen 15 s, Schreiben 30 s, Leerlauf 60 s. Beim Beenden wird sauber heruntergefahren.
## Konfiguration
| Variable | Standard | Bedeutung |
|---|---|---|
| `PORT` | `8080` | HTTP-Port |
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
| `JWT_SECRET` | festes Fallback (unsicher!) | Signaturschlüssel der Tokens; unbedingt setzen, siehe [FLAW-04](flaws.md) |
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz |
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
## Authentifizierung
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und authentifiziert als Besitzer ([FLAW-07](flaws.md)).
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
## Hintergrundjob: Alarme
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist `due_date <= heute` (Serverzeit), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
## Build
``` ```
MindSift/ Stage 1 node:22-alpine npm ci && npm run build → frontend/dist
├── cmd/ Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (dist wird eingebettet)
│ └── server/ Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
│ └── main.go # Einstiegspunkt: Initialisierung, Config, Store, Start
├── internal/
│ ├── api/
│ │ ├── server.go # HTTP-Routen, Handler-Methoden, Middleware
│ │ └── server_test.go # Integrationstests für alle REST- und Ingest-Routen
│ ├── auth/
│ │ ├── auth.go # JWT Validierung, Token-Erstellung, Context-Helpers
│ │ └── apikey.go # API-Key Erzeugung (mindsift_sec_...) & SHA-256 Hashing
│ ├── config/
│ │ └── config.go # Environment-Variablen (PORT, DATA_DIR, JWT_SECRET, SMTP)
│ ├── domain/
│ │ └── models.go # Go-Structs für Thought, Document, List, User, Team etc.
│ ├── storage/
│ │ ├── sqlite.go # DB-Schema, CRUD-Operationen, Queries, Migrationen
│ │ └── storage_test.go # Unit-Tests für Speicheroperationen
│ └── ws/
│ └── hub.go # WebSocket Client, Room-Management, Locking-Mechanismus
├── frontend/
│ ├── src/
│ │ ├── components/ # Wiederverwendbare UI-Elemente (Modals, Avatar, Menüs)
│ │ ├── context/ # MobileActionContext (Mobile Bottom Bar State)
│ │ ├── features/
│ │ │ ├── auth/ # SetupWizard & Login
│ │ │ ├── capture/ # InstantCaptureBar (Schnelleingabe)
│ │ │ ├── help/ # Integrierte Benutzerdokumentation
│ │ │ ├── lists/ # Geteilte Listen & Einkauf
│ │ │ ├── settings/ # Benutzer-, Admin- & Theme-Einstellungen
│ │ │ ├── teams/ # Gruppen & Mitglieder-Management
│ │ │ ├── triage/ # Triage-Deck, Aufgabenliste, Kanban & TaskModal
│ │ │ └── workspace/ # Outliner-Editor, Datei-Explorer, Wiki & Rollup
│ │ ├── lib/ # api.ts (HTTP/WS Client), types.ts, theme.ts
│ │ ├── themes/ # Eigenständige Theme CSS-Dateien (dark, light, etc.)
│ │ ├── App.tsx # Root-Komponente, Navigation, Top/Bottom Bars
│ │ └── main.tsx # React DOM Bootstrap
│ ├── package.json
│ └── vite.config.ts
├── Doku/ # Dieses technische Wiki
├── Dockerfile # Multi-Stage Build Definition
└── compose.yaml # Docker Compose Vorlage für Produktivbetrieb
``` ```
--- Die Tests laufen beim Image-Build (`go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests existieren nicht ([FLAW-19](flaws.md)).
## ⚡ 4. Thread-Safety & Concurrency-Modell
Da SQLite Datei-basiert arbeitet und Go standardmäßig hochgradig nebenläufig (Multi-Threaded via Goroutines) operiert, sind folgende Schutzmaßnahmen implementiert:
### 1. SQLite Pragmas & WAL-Modus
Im Storage-Layer ([`internal/storage/sqlite.go`](../internal/storage/sqlite.go)) wird die Datenbankverbindung beim Start zwingend mit folgenden DSN-Pragmas konfiguriert:
```go
dsn := filepath.Join(dataDir, "mindsift.db") +
"?_pragma=busy_timeout(10000)&" +
"_pragma=journal_mode(WAL)&" +
"_pragma=synchronous(NORMAL)&" +
"_pragma=foreign_keys(1)"
```
- **`journal_mode(WAL)`:** Write-Ahead Logging erlaubt parallele Leser (`SELECT`), während ein Schreiber (`INSERT`/`UPDATE`) aktiv ist. Leser blockieren niemals Schreiber und Schreiber blockieren niemals Leser.
- **`busy_timeout(10000)`:** Sollte ein Schreibvorgang kurzzeitig blockiert sein, wartet SQLite bis zu 10.000 ms (10 Sekunden), bevor ein `SQLITE_BUSY` Fehler geworfen wird.
- **`foreign_keys(1)`:** Erzwingt referenzielle Integrität und automatische Kaskadierungs-Löschungen (`ON DELETE CASCADE`).
### 2. Connection Pool Begrenzung
SQLite unterstützt nur einen einzigen Schreib-Thread zur gleichen Zeit:
```go
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(time.Hour)
```
### 3. Cursor-Deadlock-Vermeidung
> **Kritische Backend-Regel:** Niemals verschachtelte Abfragen bei offenem `*sql.Rows`-Cursor ausführen!
In Go hält ein offener Cursor eine Verbindung aus dem Pool besetzt. Führt der Code innerhalb von `for rows.Next()` eine weitere Abfrage auf `s.db` aus, kann der Connection-Pool bei mehreren parallelen Benutzern innerhalb von Millisekunden einfrieren.
*Pattern:* Alle Zeilen werden sofort vollständig in ein Go-Slice gelesen, `rows.Close()` wird ausgeführt, und erst danach werden verknüpfte Entitäten geladen.
---
## 🌐 5. Deployment & Container-Architektur
MindSift wird als schlanker, autarker Docker-Container ausgeliefert:
1. **Stage 1 (Frontend Build):** `node:20-alpine` kompiliert TypeScript und generiert statische Assets nach `frontend/dist/`.
2. **Stage 2 (Backend Build):** `golang:1.23-alpine` bettet `frontend/dist/` via `//go:embed` ein, führt Unit-Tests aus und kompiliert ein statisches Binary (`mindsift-server`).
3. **Stage 3 (Production Runtime):** `alpine:3.20` mit `ca-certificates` und `tzdata`. Das finale Image ist ca. 45 MB groß und benötigt keine externen Datenbankserver oder Abhängigkeiten.
Alle persistenten Daten befinden sich in einem einzigen Verzeichnis `/data`:
- `/data/mindsift.db` (SQLite Hauptdatenbank inklusive WAL-Journal und Shared-Memory-Dateien)
- Notizen und Workspace-Blöcke werden zu 100% in der SQLite-Datenbank persistiert (keine losen Markdown-Dateien auf der Festplatte).
+68 -333
View File
@@ -1,348 +1,83 @@
# 🗄️ Datenbank-Design & SQLite Schema # Datenbank
MindSift verwendet als persistente Datenspeicherung eine lokale SQLite-Datenbank (`/data/mindsift.db`). Dieses Dokument beschreibt das relationale Datenmodell, Tabellenstrukturen, Indizes, Fremdschlüsselbeziehungen und das Migrationskonzept. SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`).
--- Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist veraltet ([FLAW-13](flaws.md)).
## 📊 1. Entity-Relationship-Diagramm (ERD) ## Tabellen
```mermaid ### Benutzer und Zugang
erDiagram | Tabelle | Zweck | Wichtige Spalten |
users ||--o{ thoughts : "erstellt" |---|---|---|
users ||--o{ documents : "besitzt" | `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url` |
users ||--o{ lists : "besitzt" | `user_invites` | Einladungscodes | `code`, `email`, `role`, `team_id`, `expires_at`, `used_by` |
users ||--o{ task_lists : "besitzt" | `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
users ||--o{ api_keys : "besitzt" | `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
users ||--o{ team_members : "ist Mitglied in" | `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
teams ||--o{ team_members : "besteht aus" ### Gedanken und Aufgaben
teams ||--o{ task_list_shares : "erhält Zugriff" | Tabelle | Zweck | Wichtige Spalten |
teams ||--o{ list_shares : "erhält Zugriff" |---|---|---|
teams ||--o{ doc_team_shares : "erhält Zugriff" | `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (nur Datum), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
teams ||--o{ workspace_folder_shares : "erhält Zugriff" | `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `team_id` (Altfeld), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
task_lists ||--o{ thoughts : "gruppiert" Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND converted_type='task'`. Aufgaben, die aus einem Notiz-Block entstehen, tragen `converted_type='document_block'` und `converted_ref_id='<docId>:<blockId>'`.
task_lists ||--o{ task_list_shares : "wird geteilt mit"
lists ||--o{ list_items : "enthält" ### Workspace
lists ||--o{ list_shares : "wird geteilt mit" | Tabelle | Zweck |
|---|---|
| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived`, `markdown_cache`, `shared_folder_id`, `sub_folder_path`, Altspalten `content`, `blocks_json` |
| `blocks` | Outliner-Blöcke: `document_id`, `type`, `content`, `level`, `position` (REAL), `checked`, `collapsed`, `lang` |
| `block_tags` | `#Hashtags` je Block (`tag`, `document_id`, `block_id`) |
| `tag_pages` | Freitext-Notiz je Benutzer und Tag |
| `doc_pins` | Favoriten je Benutzer |
| `doc_shares`, `doc_team_shares` | Notiz-Freigaben an Benutzer bzw. Gruppen (`permission` effektiv immer `read`) |
| `workspace_folders` | Explizit angelegte Ordnerpfade |
| `workspace_shared_folders` | Geteilte Ordner (Entität mit Eigentümer) |
| `workspace_folder_shares` | Freigaben geteilter Ordner |
| `workspace_folder_mounts` | Persönlicher Einhängepfad je Empfänger und geteiltem Ordner |
| `doc_user_folders` | Persönliche Ablage einer geteilten Einzelnotiz je Empfänger |
documents ||--o{ document_blocks : "besteht aus" Blocktypen: `h1`, `h2`, `h3`, `paragraph`, `text`, `todo`, `bullet`, `code`, `callout`, `divider`, `table`, `progress`.
documents ||--o{ doc_shares : "geteilt mit Benutzern"
documents ||--o{ doc_team_shares : "geteilt mit Teams"
documents ||--o{ doc_user_folders : "eigene Platzierung"
documents ||--o{ doc_pins : "Favorit von"
workspace_shared_folders ||--o{ workspace_folder_shares : "wird geteilt mit" ### Listen
workspace_shared_folders ||--o{ workspace_folder_mounts : "eingehängt bei" | Tabelle | Zweck |
workspace_shared_folders ||--o{ documents : "enthält" |---|---|
| `lists` | Geteilte Listen: `title`, `icon`, `list_type` (`shopping`/`checklist`/`vacation`) |
| `list_shares` | Freigaben: `target_type`, `target_id`, `role` (`editor`/`viewer`) |
| `list_items` | Einträge: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order` |
### Gruppen
| Tabelle | Zweck |
|---|---|
| `teams` | `name`, `description`, `org_id` (Standard `default`) |
| `team_members` | `team_id`, `user_id`, `role` (`admin`/`member`) |
| `organizations` | Rudimentär; wird vom UI nicht mehr benötigt ([FLAW-24](flaws.md)) |
## Beziehungen
```
users 1─n thoughts thoughts n─1 task_lists (task_list_id, lose)
users 1─n task_lists task_lists 1─n task_list_shares ─(team|user)→ teams/users
users 1─n documents 1─n blocks 1─n block_tags
documents n─n users via doc_shares / doc_team_shares (nur Lesen)
workspace_shared_folders 1─n workspace_folder_shares / workspace_folder_mounts
users 1─n lists 1─n list_items; lists 1─n list_shares
teams 1─n team_members n─1 users
``` ```
--- `thoughts.task_list_id` ist keine Fremdschlüsselbeziehung; beim Löschen einer Liste setzt der Code die Verweise auf `''`.
## 📑 2. Detaillierte Tabellen-Spezifikation ## Migrationen und Aufräumarbeiten beim Start
### 2.1 Benutzerverwaltung, System & Sicherheit - Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert).
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`.
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
- Alle Dokument-, Team- und Ordnerfreigaben werden auf `read` gesetzt (bei **jedem** Start, siehe [FLAW-17](flaws.md)).
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=teams_only`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
- Die Tabelle `teams` wird einmalig ohne Fremdschlüssel auf `organizations` neu aufgebaut.
#### `users` ## Sicherung
Verwaltet Konten, Rollen, Multi-Channel Alarmziele und Benutzerpräferenzen.
```sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- Präfix "usr_..."
email TEXT UNIQUE NOT NULL, -- E-Mail-Adresse
username TEXT NOT NULL DEFAULT '', -- Eindeutiger Login-Name
password_hash TEXT NOT NULL, -- bcrypt Hash
display_name TEXT NOT NULL, -- Anzeigename
avatar_url TEXT NOT NULL DEFAULT '', -- Profilbild URL oder leer
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
is_active INTEGER NOT NULL DEFAULT 1, -- 1 = aktiv, 0 = gesperrt
alarm_channels TEXT NOT NULL DEFAULT 'discord', -- Kommagetrennt: 'email', 'discord', 'webhook'
alarm_email TEXT NOT NULL DEFAULT '', -- Ziel-E-Mail für Alarme
discord_webhook_url TEXT NOT NULL DEFAULT '', -- Discord Webhook URL
custom_webhook_url TEXT NOT NULL DEFAULT '', -- HTTP POST JSON Webhook URL
notify_before_minutes INTEGER NOT NULL DEFAULT 30, -- Vorwarnzeit in Minuten
created_at TEXT NOT NULL, -- ISO 8601 String
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
```
#### `system_settings` Die gesamte Instanz besteht aus der Datei `mindsift.db` (plus `-wal`/`-shm` im laufenden Betrieb). Konsistent sichern per `sqlite3 mindsift.db ".backup 'backup.db'"` oder durch Stoppen des Containers und Kopieren des `data/`-Verzeichnisses. Benutzerexport: `GET /api/v1/export` (JSON mit Gedanken, Notizen, Listen, Aufgabenlisten).
Globale Instanz-Konfiguration (Key-Value-Tabelle).
```sql
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY, -- 'sharing_mode', 'allow_registration', 'instance_name', 'smtp_...'
value TEXT NOT NULL
);
```
#### `user_invites`
Einladungscodes für kontrollierte Benutzer-Registrierungen.
```sql
CREATE TABLE IF NOT EXISTS user_invites (
id TEXT PRIMARY KEY, -- Präfix "inv_..."
code TEXT UNIQUE NOT NULL, -- Format "MS-XXXXXXXX"
email TEXT DEFAULT '', -- Optional: an bestimmte E-Mail gebunden
role TEXT NOT NULL DEFAULT 'member', -- Rolle des neuen Kontos ('member', 'admin')
created_by TEXT NOT NULL, -- Ersteller User-ID
used_by TEXT DEFAULT '', -- Eingelöste User-ID
expires_at TEXT NOT NULL, -- Gültig bis (ISO 8601)
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_invites_code ON user_invites(code);
```
#### `api_keys`
Persönliche API-Schlüssel für externe Skripte, Smarthome und Ingestion.
```sql
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY, -- Präfix "key_..."
user_id TEXT NOT NULL,
name TEXT NOT NULL, -- Bezeichnung (z.B. "Home Assistant")
key_prefix TEXT NOT NULL, -- Erste 12 Zeichen im Klartext für Anzeige
key_hash TEXT UNIQUE NOT NULL, -- SHA-256 Hash des kompletten Schlüssels
scopes TEXT NOT NULL DEFAULT 'ingest,read',
last_used_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
```
---
### 2.2 Gruppen & Organisations-Teams
#### `organizations` & `teams` & `team_members`
```sql
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY, -- Präfix "org_..."
name TEXT NOT NULL,
owner_id TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS teams (
id TEXT PRIMARY KEY, -- Präfix "tm_..."
org_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS team_members (
team_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
joined_at TEXT NOT NULL,
PRIMARY KEY (team_id, user_id),
FOREIGN KEY(team_id) REFERENCES teams(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
```
---
### 2.3 Gedanken, Triage & Aufgaben
In MindSift nutzen Quick Dumps (Triage) und Aufgaben dieselbe Basistabelle **`thoughts`**. Ein Quick Dump ist ein Gedanke mit `status = 'inbox'`, der durch Triage in `status = 'kept'` oder `status = 'converted'` übergeht.
#### `thoughts`
```sql
CREATE TABLE IF NOT EXISTS thoughts (
id TEXT PRIMARY KEY, -- Präfix "th_..."
user_id TEXT NOT NULL,
content TEXT NOT NULL, -- Textinhalt inkl. Hashtags
tags TEXT NOT NULL DEFAULT '[]', -- JSON Array: ["smarthome", "keller"]
priority TEXT NOT NULL DEFAULT 'normal', -- 'low' | 'normal' | 'high' | 'urgent'
source TEXT NOT NULL DEFAULT 'manual', -- 'manual' | 'voice' | 'webhook'
status TEXT NOT NULL DEFAULT 'inbox', -- 'inbox' | 'kept' | 'discarded' | 'converted'
is_completed INTEGER NOT NULL DEFAULT 0, -- 0 = offen, 1 = erledigt
converted_type TEXT DEFAULT '', -- 'task' | 'document' | 'list_item'
converted_ref_id TEXT DEFAULT '', -- Verknüpfte ID (z.B. doc_id oder list_item_id)
due_date TEXT DEFAULT '', -- Fälligkeitsdatum "YYYY-MM-DD"
task_list_id TEXT NOT NULL DEFAULT '', -- Zugeordnete Aufgabenliste ("" = Standard)
kanban_status TEXT NOT NULL DEFAULT 'todo',-- 'todo' | 'in_progress' | 'done' | column.id
notes TEXT NOT NULL DEFAULT '', -- Zusätzliche Notizen / Beschreibung
subtasks TEXT NOT NULL DEFAULT '[]', -- JSON Array von SubTask: [{id, title, isCompleted}]
alarm_sent INTEGER NOT NULL DEFAULT 0, -- 1 = Alarm wurde bereits versendet
sort_order INTEGER NOT NULL DEFAULT 0, -- Manuelle Reorder-Sortierung
created_at TEXT NOT NULL,
triaged_at TEXT DEFAULT '',
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);
```
#### `task_lists` & `task_list_shares`
```sql
CREATE TABLE IF NOT EXISTS task_lists (
id TEXT PRIMARY KEY, -- Präfix "tl_..."
user_id TEXT NOT NULL,
title TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '📋',
color TEXT NOT NULL DEFAULT '#3b82f6',
team_id TEXT NOT NULL DEFAULT '', -- Optional: an Team gebunden
columns TEXT NOT NULL DEFAULT '[]', -- JSON Array von Kanban-Spalten
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS task_list_shares (
id TEXT PRIMARY KEY,
task_list_id TEXT NOT NULL,
target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')),
target_id TEXT NOT NULL, -- Team-ID oder User-ID
created_at TEXT NOT NULL,
created_by TEXT NOT NULL,
UNIQUE(task_list_id, target_type, target_id),
FOREIGN KEY(task_list_id) REFERENCES task_lists(id) ON DELETE CASCADE
);
```
---
### 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).
```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
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
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,
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'
content TEXT NOT NULL DEFAULT '', -- Textinhalt des Blocks
level INTEGER NOT NULL DEFAULT 0, -- Hierarchieebene (0 = Root, 1 = Kind...)
checked INTEGER NOT NULL DEFAULT 0, -- Für Checklisten (0 = offen, 1 = abgehakt)
collapsed INTEGER NOT NULL DEFAULT 0, -- Für eingeklappte Unterbäume
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
);
CREATE INDEX IF NOT EXISTS idx_document_blocks_doc_order ON document_blocks(doc_id, sort_order);
```
#### Entkoppelte Ordner-Freigabe (`workspace_shared_folders` & `workspace_folder_mounts`)
Ermöglicht das Teilen kompletter Ordnerstrukturen. Verschiebt ein Empfänger den geteilten Ordner in seiner Hierarchie, wird **nur sein Mount-Pfad** aktualisiert – die Quellstruktur beim Eigentümer und anderen Empfängern bleibt unberührt!
```sql
CREATE TABLE IF NOT EXISTS workspace_shared_folders (
id TEXT PRIMARY KEY, -- Präfix "wsf_..."
owner_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_folder_shares (
id TEXT PRIMARY KEY,
folder_id TEXT NOT NULL,
target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')),
target_id TEXT NOT NULL,
permission TEXT NOT NULL CHECK(permission IN ('read', 'write')),
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(folder_id, target_type, target_id)
);
CREATE TABLE IF NOT EXISTS workspace_folder_mounts (
user_id TEXT NOT NULL,
folder_id TEXT NOT NULL,
mount_path TEXT NOT NULL, -- Eigener lokaler Pfad des Benutzers (z.B. "Arbeit/Projekte/Team-Docs")
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, folder_id)
);
CREATE TABLE IF NOT EXISTS doc_user_folders (
user_id TEXT NOT NULL,
doc_id TEXT NOT NULL,
folder_path TEXT NOT NULL DEFAULT '', -- Individuelle Ordnerplatzierung für geteilte Einzelnotizen
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, doc_id)
);
```
---
### 2.5 Geteilte Listen & Listen-Einträge
#### `lists`, `list_items` & `list_shares`
```sql
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY, -- Präfix "lst_..."
user_id TEXT NOT NULL,
title TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '🛒',
list_type TEXT NOT NULL DEFAULT 'shopping',-- 'shopping', 'packing', 'inventory', 'todo'
updated_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_lists_user ON lists(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS list_shares (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL,
target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')),
target_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')),
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(list_id, target_type, target_id),
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS list_items (
id TEXT PRIMARY KEY, -- Präfix "itm_..."
list_id TEXT NOT NULL,
content TEXT NOT NULL, -- Bezeichnung des Eintrags
category TEXT NOT NULL DEFAULT '', -- Regal/Kategorie (z.B. "Kühlregal")
quantity TEXT NOT NULL DEFAULT '', -- Menge (z.B. "2x", "500g")
is_completed INTEGER NOT NULL DEFAULT 0, -- 1 = abgehakt
completed_by TEXT NOT NULL DEFAULT '', -- User-ID des Abhakers
completed_by_name TEXT NOT NULL DEFAULT '',-- Name des Abhakers für Live-Badge
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
completed_at TEXT NOT NULL DEFAULT '',
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_list_items_sync ON list_items(list_id, is_completed, sort_order ASC);
```
---
## 🔄 3. Migrationsstrategie
MindSift setzt auf **idempotente automatische In-Process-Migrationen** beim Serverstart ([`internal/storage/sqlite.go`](../internal/storage/sqlite.go)):
1. **Schema-Bootstrap:** Ausführung des Basis-Schemas via `CREATE TABLE IF NOT EXISTS`.
2. **Column & Index Migration Array:** Ein vordefiniertes Array idempotenter DDL-Befehle (z.B. `ALTER TABLE thoughts ADD COLUMN ...`, `CREATE TABLE IF NOT EXISTS doc_user_folders ...`).
3. **Fehlerbehandlung:** Wenn Spalten bereits existieren (z.B. `"duplicate column name"`), wird der Fehler ignoriert und der Start fortgesetzt.
4. **Vorteil:** Keine manuellen Migrations-Skripte nötig; Container-Updates können jederzeit ohne Downtime oder Vorbereitung über bestehende Datenbanken ausgerollt werden.
+160 -138
View File
@@ -1,151 +1,173 @@
# Bekannte Schwachstellen, Risiken & Technische Schulden (Flaws) # Bekannte Schwachstellen, Risiken & technische Schulden (Flaws)
Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse aller bekannten Limitierungen, Architektur-Kompromisse, Skalierungsrisiken und UX-Schwachstellen in **MindSift**. Sie dient als verbindlicher Backlog für zukünftige Refactorings und Architektur-Meilensteine. Stand: Code-Review des Branches `dev` (Oktober 2026). Jeder Eintrag wurde gegen den Quellcode geprüft; die Fundstellen sind angegeben. Einträge ohne Live-Test sind als *nicht live verifiziert* markiert.
Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (Fehlfunktion oder Schutz greift nicht), **Niedrig** (Wartbarkeit, Komfort, Hygiene).
--- ---
## 📌 Übersicht & Risikomatrix ## Übersicht
| ID | Bereich | Problem / Schwachstelle | Schweregrad | Risiko im Produktivbetrieb | | ID | Bereich | Kurzbeschreibung | Schwere |
|---|---|---|---|---| |---|---|---|---|
| **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 | Realtime | `BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen, auch unauthentifizierte | **Hoch** |
| **FLAW-02** | Storage | Dual-Storage Drift (SQLite vs. Dateisystem) | **Obsolet / Gelöst** | Kein Risiko: MindSift nutzt Pure-DB (keine `.md`-Dateien auf Platte) | | FLAW-02 | Realtime | Räume (WS `JOIN_ROOM`, SSE `/events/rooms`, `?listId=`) ohne Berechtigungsprüfung | **Hoch** |
| **FLAW-03** | Netzwerk / WS | Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback | **Mittel** | Verzögerte Synchronisierung (1–3s Polling) in Firmennetzwerken | | FLAW-03 | Aufgaben | `ReorderTasks` ändert beliebige Aufgaben per ID, ohne Zugriffsprüfung (IDOR) | **Hoch** |
| **FLAW-04** | Datenbank / Suche | Volltextsuche via `LIKE '%query%'` statt SQLite FTS5 | **Mittel** | Performance-Einbrüche bei wachsenden Notizbeständen (>10.000 Blöcke) | | FLAW-04 | Auth | Fest eincodiertes Fallback-`JWT_SECRET` | **Hoch** |
| **FLAW-05** | Frontend DOM | Fehlende Virtualisierung im Outliner-Baum | **Mittel** | Frame-Drops und Lags bei Dokumenten mit >500 Blöcken auf Mobilgeräten | | FLAW-05 | Benachrichtigungen | Discord-/Webhook-URLs ungeprüft (SSRF) | Mittel |
| **FLAW-06** | SQLite Concurrency | Single-Writer-Bottleneck bei hohem parallelem Schreibaufkommen | **Mittel** | `SQLITE_BUSY` Fehler bei vielen gleichzeitigen Team-Nutzern | | FLAW-06 | Aufgaben | Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben | Mittel |
| **FLAW-07** | Benachrichtigungen | Fehlende Dead-Letter-Queue für Webhook- & E-Mail-Alarme | **Niedrig** | Verlorene Benachrichtigungen bei temporärem Ausfall von Drittsystemen | | FLAW-07 | Auth | API-Key-`scopes` werden nie ausgewertet; ein Key ist ein Vollzugriff | Mittel |
| **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne rotierende Refresh-Tokens & Blacklisting | **Niedrig** | Keine sofortige Sitzungsinvalidierung bei Rechteentzug | | FLAW-08 | Auth | Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active` | Mittel |
| **FLAW-09** | Mobile UX | Viewport-Verschiebung durch Virtual-Keyboard auf iOS/Android | **Niedrig** | Verdeckte Eingabefelder und springende Bottom-Navigationsleisten | | FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
| **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 | | FLAW-10 | Freigaben | `sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten | Mittel |
| FLAW-11 | Workspace | Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht | Mittel |
| FLAW-12 | Benachrichtigungen | `notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder | Mittel |
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
| FLAW-15 | Realtime | `WriteTimeout` von 30 s trifft vermutlich auch SSE-Streams *(nicht live verifiziert)* | Mittel |
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
| FLAW-17 | Workspace | Dokument- und Ordnerfreigaben sind per Migration erzwungen schreibgeschützt; tote Schreibpfade | Niedrig |
| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig |
| FLAW-19 | Qualität | Keine Frontend-Tests, `lint` und `test:run` sind Platzhalter | Niedrig |
| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig |
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig |
| FLAW-24 | Gruppen | Organisations-Endpunkte ohne Berechtigungsprüfung | Mittel |
--- ---
## 🔍 Detaillierte Analyse & Maßnahmen ## Details
### FLAW-01: [GELÖST / IMPLEMENTIERT] Yjs Outliner CRDT & Multi-User Kollaboration ### FLAW-01: Broadcast an alle Verbindungen (Datenabfluss)
- **Status:** **Gelöst via Yjs CRDT Engine (Lösung i)** - **Fundstellen:** [`internal/ws/hub.go`](../internal/ws/hub.go) `BroadcastToAll`; Aufrufer in [`internal/api/server.go`](../internal/api/server.go): `handleUpdateThought` (`TASK_UPDATED`), `handleDeleteThought` (`TASK_DELETED`), `handleReorderTasks` (`TASK_REORDERED`), `handleUpdateTaskList`, `handleCreateTaskListShare`, `handleDeleteTaskListShare` (`TASK_LIST_UPDATED`), `handleDeleteTaskList`.
- **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` - **Problem:** `BroadcastToAll` schreibt an `allClients` und `sseClients`. Dazu zählen auch Verbindungen mit `userID = "anonymous"`, denn `handleWebSocket` und `handleSSE` liegen außerhalb der Auth-Gruppe und lassen jede Verbindung zu. Der vollständige Aufgabeninhalt (Titel, Notizen, Unteraufgaben) und die Listen samt Mitgliedern gehen so an jeden, der `/api/v1/events` öffnet.
- **Ursache (Historisch):** MindSift nutzte ein kooperatives Dokumenten-Lease-Locking mit Last-Write-Wins. Bei parallelem Zugriff oder Netztrennung konnten Änderungen überschrieben werden. - **Maßnahme:** Ereignisse an die berechtigten Nutzer adressieren (wie bei `DOC_*` über `GetAuthorizedDocUsers`), unauthentifizierte Verbindungen ablehnen.
- **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. ### FLAW-02: Räume ohne Berechtigungsprüfung
2. **Wegfall von Ganzdokument-Sperren:** Keine blockierenden Sperren mehr (`isLockedByOther` entfernt). Mehrere Nutzer können zeitgleich am selben Dokument schreiben. - **Fundstellen:** `Hub.readPump` (`JOIN_ROOM`), `handleSSERooms` (Route `/api/v1/events/rooms` liegt **außerhalb** der Auth-Gruppe), `Hub.Register` (Raum aus `?listId=`/`?spaceId=`).
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. - **Problem:** Wer eine Raum-ID kennt (`doc:<id>`, Listen-ID), tritt bei und erhält Inhalte wie `DOC_BLOCKS_PATCHED` oder `ITEM_*`. Raum-IDs tauchen in Ereignissen auf und sind nicht geheim. Für den SSE-Beitritt reicht die `sseId`, die der Server selbst im Stream mitsendet.
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. - **Maßnahme:** Beitritt nur nach Zugriffsprüfung (`GetDocumentByID`, `GetListByID`); `/events/rooms` hinter `authMiddleware`.
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-03: `ReorderTasks` ohne Zugriffsprüfung (IDOR)
- **Fundstelle:** `Store.ReorderTasks` (`UPDATE thoughts SET sort_order … WHERE id = ?`) und `handleReorderTasks`.
- **Problem:** Die SQL-Anweisung ist weder auf `user_id` noch auf die Liste eingeschränkt. Jeder angemeldete Nutzer kann `sortOrder`, `kanbanStatus` und damit `isCompleted` fremder Aufgaben ändern, sofern er die ID kennt. Die Leser-Prüfung für Aufgabenlisten überspringt Aufgaben, auf die der Nutzer gar keinen Zugriff hat (`GetThoughtByID` liefert dann einen Fehler), und schützt davor nicht.
- **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen.
### FLAW-04: Fallback-JWT-Secret
- **Fundstelle:** [`internal/config/config.go`](../internal/config/config.go): `getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")`; `docker-compose.yml` und `.env.example` liefern ebenfalls einen Platzhalter.
- **Problem:** Wer `JWT_SECRET` nicht setzt (oder den Platzhalter behält), läuft mit einem öffentlich bekannten Schlüssel. Damit lassen sich gültige Tokens für jede Benutzer-ID fälschen. `install.sh` erzeugt zwar ein Secret, andere Installationswege nicht.
- **Maßnahme:** Start verweigern oder Secret beim ersten Start erzeugen und in `/data` ablegen.
### FLAW-05: SSRF über Alarm-URLs
- **Fundstellen:** `handleUpdateUserSettings` (keine Validierung), `sendDiscordNotification`, `sendCustomWebhookNotification`.
- **Problem:** Jeder Nutzer kann beliebige URLs hinterlegen. Der Server sendet POST-Anfragen dorthin, auch an interne Adressen (`http://169.254.169.254`, Docker-Netz, `localhost`). Über `handleTestUserAlarm` lässt sich das sofort auslösen.
- **Maßnahme:** Nur `https`, Discord-Host prüfen, private/Loopback-Adressen beim Verbinden sperren.
### FLAW-06: Editor kann Aufgabenliste weiterteilen
- **Fundstelle:** `Store.UpdateTaskList` (`teamID`-Zweig ruft `AddTaskListShare`); `handleUpdateTaskList` prüft nur, dass der Nutzer **kein** Leser ist.
- **Problem:** `POST /task-lists/{id}/shares` ist Eigentümern vorbehalten. Über `PUT /task-lists/{id}` mit `teamId` kann aber jeder Editor die Liste einer weiteren Gruppe zuweisen (mit Schreibrecht) und die Zuweisung der bisherigen Gruppe aufheben.
- **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen.
### FLAW-07: API-Key-Scopes ohne Wirkung
- **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`.
- **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert.
- **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken.
### FLAW-08: Webhook-Ingest umgeht Sitzungsprüfung
- **Fundstelle:** `handleWebhookIngest`, JWT-Zweig.
- **Problem:** Das Token wird nur kryptografisch validiert. `IsTokenRevoked`, `token_version` und `is_active` werden nicht geprüft; ein ausgeloggtes oder deaktiviertes Konto kann weiter Aufgaben anlegen (bis Token-Ablauf nach 30 Tagen).
- **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`).
### FLAW-09: Kein Schutz gegen Brute-Force
- **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`.
- **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar.
- **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort.
### FLAW-10: `sharing_mode` wird nicht durchgesetzt
- **Fundstellen:** `Store.GetDiscoverableUsers`, `Store.GetCollaborators`; Share-Handler in `server.go`.
- **Problem:**
1. Der Modus beeinflusst nur `GetDiscoverableUsers`. In `teams_only` liefert die Funktion trotzdem **alle** Benutzer (`if … mode == "open" || mode == "teams_only" { return GetAllUsers() }`).
2. `GET /collaborators` ignoriert den Modus komplett und liefert alle aktiven Konten mit E-Mail-Adresse.
3. Keine der Share-Funktionen prüft den Modus. `strict_private` und `teams_only` verhindern Freigaben nicht, wenn man die Ziel-ID kennt.
- **Maßnahme:** Eine zentrale Funktion `canShareWith(owner, target)` und in allen Share-Handlern verwenden; Verzeichnisse darauf einschränken.
### FLAW-11: Block-Upsert prüft das Dokument nicht
- **Fundstelle:** `Store.PatchDocumentBlocks`, `INSERT INTO blocks … ON CONFLICT(id) DO UPDATE`.
- **Problem:** `blocks.id` ist global eindeutig. Existiert die ID bereits in einem **anderen** Dokument, überschreibt der Konflikt-Zweig dessen Inhalt, ohne `document_id` zu prüfen. Voraussetzung: Schreibrecht im eigenen Dokument und Kenntnis einer fremden Block-ID.
- **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen.
### FLAW-12: Alarm-Logik
- **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`.
- **Probleme:**
- `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar.
- Verglichen wird mit `time.Now()` in der Zeitzone des Containers (ohne `TZ` also UTC).
- `alarm_sent` wird nie zurückgesetzt. Wird die Fälligkeit nach einem Alarm verschoben, gibt es keinen neuen.
- Schlagen alle Kanäle fehl, wird jede Minute erneut gesendet (kein Backoff, keine Dead-Letter-Queue).
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
### FLAW-13: Migrationen
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
- **Probleme:**
- Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf.
- Mehrere `UPDATE`/`DROP TABLE`-Bereinigungen laufen bei jedem Start.
- `migrations/001_init.sql` wird nur in das Image kopiert, aber nirgends gelesen, und ist gegenüber dem tatsächlichen Schema veraltet.
- **Maßnahme:** `schema_migrations`-Tabelle mit nummerierten, einmalig ausgeführten Schritten; die SQL-Datei entweder einbetten oder entfernen.
### FLAW-14: Verschluckte Fehler
- **Fundstellen:** u. a. `PatchDocumentBlocks` (Upserts/Deletes), `AddTaskListShare`, `RemoveTaskListShare`, viele `_, _ = s.db.Exec`.
- **Problem:** Scheitert ein Schreibzugriff (z. B. `SQLITE_BUSY`), antwortet die API trotzdem mit Erfolg und broadcastet den Stand, der nie gespeichert wurde. Fehler lassen sich nur schwer nachvollziehen.
- **Maßnahme:** Mehrstufige Änderungen in Transaktionen bündeln und Fehler zurückgeben.
### FLAW-15: WriteTimeout und SSE *(nicht live verifiziert)*
- **Fundstelle:** [`cmd/server/main.go`](../cmd/server/main.go) `WriteTimeout: 30 * time.Second`.
- **Problem:** Go beendet nach diesem Zeitraum das Schreiben der Antwort. Für den SSE-Fallback (`ServeSSE`) bedeutet das voraussichtlich einen Verbindungsabbruch alle 30 s. WebSockets sind durch Hijacking nicht betroffen. Der Client verbindet sich neu, daher fällt es kaum auf.
- **Maßnahme:** Im SSE-Handler `http.ResponseController.SetWriteDeadline(time.Time{})` setzen oder `WriteTimeout` für diese Route aufheben.
### FLAW-16: Ersteinrichtung und Demo-Modus
- **Fundstellen:** `handleFirstBootSetup`, `Store.EnsureDemoAccounts`, `config.Load` (`MINDA_DEMO`, `MINDSIFT_DEMO`, `DEMO_MODE`).
- **Probleme:** Solange kein Konto existiert, kann jeder `POST /auth/setup` aufrufen und wird Administrator. Mit gesetztem Demo-Flag entstehen Konten `admin/admin` und `user/user`; versehentlich auf einer öffentlichen Instanz aktiv wäre das eine Komplettübernahme.
- **Maßnahme:** Setup per einmaligem Token aus dem Server-Log absichern; Demo-Modus nur zusammen mit expliziter Warnung/Bestätigung.
### FLAW-17: Schreibgeschützte Dokumentfreigaben
- **Fundstellen:** Migration in `Store.migrate` (`UPDATE doc_shares SET permission = 'read' …`, ebenso `doc_team_shares`, `workspace_folder_shares`), `PatchDocumentBlocks`/`UpdateDocument` (nur Eigentümer).
- **Hinweis:** Das ist offenbar gewollt: Nur der Eigentümer schreibt in ein Dokument, Empfänger lesen. Es gibt daher **kein** kollaboratives Bearbeiten. Das Schema (`CHECK(permission IN ('read','write'))`), die Zweige für `write` im Code und frühere Dokumentation suggerieren etwas anderes. Es gibt weder Sperren noch Konfliktauflösung, noch Cursor-Presence.
- **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen.
### FLAW-18: Suche
- **Fundstelle:** `Store.SearchAll`.
- **Probleme:** Durchsucht nur eigene Gedanken (30) und eigene Dokumente (20); geteilte Dokumente, Aufgaben aus geteilten Listen, Einkaufslisten und Blöcke bleiben außen vor. `LIKE '%…%'` ohne Escaping von `%`/`_`, keine Indizes, keine Relevanz.
- **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen.
### FLAW-19: Keine automatisierten Frontend-Prüfungen
- **Fundstelle:** [`frontend/package.json`](../frontend/package.json): `"lint": "echo 'Linting passed'"`, `"test:run": "echo 'Frontend checks passed'"`.
- **Problem:** Die Skripte melden immer Erfolg. Die CI (`.gitea/workflows/build-and-publish.yaml`) prüft daher nur Go-Tests und den Vite-Build. Regressionen im UI (z. B. Berechtigungsanzeigen) fallen nicht auf.
- **Maßnahme:** `tsc --noEmit` und ESLint als echte Skripte, Komponententests für kritische Pfade.
### FLAW-20: Dateigröße und Duplikate
- `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500).
- Die Zugriffsabfrage für Aufgabenlisten steht dreimal fast identisch im Code (`accessibleTaskListIDsQuery`, `GetTaskLists`, `GetTaskListByID`). Dokumentzugriff ist ähnlich verteilt. Änderungen an Berechtigungen müssen an mehreren Stellen erfolgen und werden leicht vergessen.
- **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren.
### FLAW-21: Lizenzdatei fehlt
- README und Badge nennen die MIT-Lizenz und verlinken auf `LICENSE`; die Datei existiert nicht im Repository.
### FLAW-22: Netzwerk-Details
- `authenticateUserFromReq` akzeptiert das Token als `?token=` (nötig für `WebSocket`/`EventSource`, die keine Header setzen). Tokens landen dadurch in Proxy- und Zugriffslogs.
- `AllowedOrigins: ["*"]` (Auth läuft über `Authorization`-Header, nicht über Cookies, das Risiko ist daher begrenzt) und `CheckOrigin` liefert immer `true`.
- Ein einmal verbundener WebSocket/SSE-Stream wird bei Logout oder Token-Widerruf nicht getrennt.
- **Maßnahme:** Kurzlebiges, einmaliges Stream-Ticket statt Langzeit-JWT in der URL; Origin gegen `BASE_URL` prüfen.
### FLAW-23: SQLite-Schreibengpass
- Eine SQLite-Datei (WAL, `busy_timeout`, bis zu 8 offene Verbindungen) erlaubt nur einen gleichzeitigen Schreiber. Für Familien und kleine Teams unkritisch; bei vielen aktiven Editoren sind `SQLITE_BUSY`-Fehler möglich, die wegen FLAW-14 stumm bleiben.
### FLAW-24: Organisations-Endpunkte ohne Berechtigungsprüfung
- **Fundstellen:** `handleDeleteOrganization`, `handleGetOrgTeams`, `handleCreateOrgTeam` (und `Store.DeleteOrganization`) in `server.go` bzw. `sqlite.go`.
- **Problem:** Diese Handler prüfen weder Eigentümer noch Mitgliedschaft. Jeder angemeldete Nutzer kann mit bekannter ID eine fremde Organisation löschen, deren Gruppen auflisten oder dort Gruppen anlegen. Gruppenbezogene Endpunkte (`/teams/{id}…`) prüfen dagegen korrekt auf Gruppen-Admin. Das Datenmodell kennt Organisationen nur noch rudimentär; im UI werden Gruppen direkt über `POST /teams` angelegt (`orgId` Standard `default`).
- **Maßnahme:** Organisationen entweder konsequent absichern (Eigentümer/Server-Admin) oder entfernen.
--- ---
### FLAW-02: [OBSOLET / GELÖST] Dual-Storage Drift (SQLite vs. Dateisystem) ## Aus der früheren Fassung entfernt
- **Status:** **Entkräftet & Behoben (Pure Database Architecture)**
- **Klarstellung:** In früheren Entwürfen wurde angedacht, Notizen zusätzlich als Markdown-Dateien auf dem Dateisystem zu spiegeln. MindSift speichert Notizen und Blöcke jedoch **zu 100% relational in SQLite** (`documents` und `document_blocks`).
- **Verifikation:**
- Es existieren **keine** Markdown-Dateien auf der Festplatte.
- Das Go-Backend schreibt keine Dateien in ein Notizverzeichnis.
- Unnötige Verzeichniserstellungen (`docDir`/`workspace`) wurden aus `internal/storage/sqlite.go` vollständig entfernt.
- **Ergebnis:** Es gibt keinen Dual-Storage und somit keinen File-Drift oder externe Dateikonflikte. Notizen verhalten sich vollständig transaktional und konsistent innerhalb der SQLite-Datenbank.
--- Die frühere `flaws.md` führte als „gelöst“, was im Code nicht existiert: In-Memory-DocRooms, `clientId`-Echo-Schutz, Cursor-Presence, 1,5-s-Debounce-Flush, `internal/ws/docroom.go`, `frontend/src/lib/useDocumentSync.ts` und Yjs/CRDT. Der Code enthält nur noch `internal/ws/hub.go` (Räume, Benutzer-Kanäle, SSE-Fallback). Die CRDT-Tabellen werden beim Start gelöscht (`DROP TABLE document_crdt_*`). Dokumente werden per REST gespeichert (`PATCH /documents/{id}/blocks`) und als `DOC_BLOCKS_PATCHED` verteilt. Die frühere Aussage zur Token-Versionierung (`token_version`, `revoked_tokens`) trifft dagegen zu und ist in [sharing-and-permissions.md](sharing-and-permissions.md) und [architecture.md](architecture.md) beschrieben.
### FLAW-03: [GELÖST / IMPLEMENTIERT] Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback
- **Status:** **Gelöst & Vollständig Implementiert (Dual-Transport SSE Fallback & Proxy-Resilienz)**
- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`reverse-proxy.md`](./reverse-proxy.md), [`internal/ws/hub.go`](../internal/ws/hub.go), [`internal/api/server.go`](../internal/api/server.go), [`frontend/src/lib/api.ts`](../frontend/src/lib/api.ts)
- **Problemursache:** Corporate Proxies (z.B. Zscaler, BlueCoat, strikte Firewalls) oder unvollständig konfigurierte Reverse-Proxies verwerfen den HTTP-Upgrade-Header `Upgrade: websocket` (`NS_ERROR_CONNECTION_REFUSED`, Status 1006 / 1002). Früher fiel der Client dabei auf 2-Sekunden-Polling zurück, was zu Verzögerungen und Last führte.
- **Implementierte Lösung:**
1. **Unidirektionaler Server-Sent Events (SSE) Fallback:**
- Neuer Streaming-Endpunkt `GET /api/v1/events` im Go-Backend mit `Content-Type: text/event-stream`, `X-Accel-Buffering: no` und 15s Heartbeat-Pings (`: ping\n\n`).
- Standard HTTPS Ports (443/80), wodurch kein Proxy den Datenstrom blockieren kann.
2. **Hub-Multiplexing:**
- `ws.Hub` verwaltet WebSocket- und SSE-Clients parallel in gemeinsamen Räumen (`rooms`, `userRooms`, `allClients`).
- Sämtliche Events (`BroadcastToAll`, `BroadcastToUser`, `BroadcastRoom`) werden transparent an beide Transportarten verteilt.
3. **REST-Parität für Outbound CRDT & Awareness:**
- `PUT /api/v1/documents/:id/crdt`: Akzeptiert Delta-Updates (`update`) und broadcastet sie an alle Peers.
- `POST /api/v1/documents/:id/awareness` und `DELETE /api/v1/documents/:id/awareness`: Übertragen Blockfokus und Cursor ohne WebSocket.
- `POST /api/v1/events/rooms`: Erlaubt SSE-Clients dynamisches Room-Joining.
4. **Frontend Realtime Transport Manager (`frontend/src/lib/api.ts`):**
- Automatischer 3,5s-Watchdog: Schlägt das WebSocket-Upgrade fehl oder wird Code 1006/1002 gemeldet, wechselt MindSift nahtlos auf `EventSource('/api/v1/events')`.
- Grüner/türkiser Statusindikator im Header zeigt `Live (WebSocket)` bzw. `Live (Server-Sent Events / Proxy-Stream)`.
5. **Reverse-Proxy-Handbuch:**
- Vollständige Produktions-Konfigurationen für Nginx, Traefik, Caddy, Cloudflare und Apache unter [`reverse-proxy.md`](./reverse-proxy.md).
---
### FLAW-04: Volltextsuche via `LIKE '%query%'`
- **Betroffene Komponenten:** [`database.md`](./database.md), `internal/api/search.go`, `internal/api/thoughts.go`
- **Ursache:** Suchabfragen über Gedanken, Notizblöcke und Aufgaben nutzen aktuell einfaches SQL:
```sql
WHERE content LIKE '%' || ? || '%'
```
- **Folgen:** `LIKE '%...'` kann B-Tree-Indizes prinzipbedingt nicht nutzen und erzwingt einen sequentiellen Full Table Scan. Bei Datenbanken mit Zehntausenden Blöcken führt dies zu CPU-Spitzen und trägen Suchreaktionen.
- **Empfohlene Lösung:**
1. Einrichten einer virtuellen SQLite FTS5-Tabelle (`document_blocks_fts`) mit Tokenizer (`unicode61` oder `trigram`).
2. Automatische Synchronisierung über SQLite Trigger (`AFTER INSERT`, `AFTER UPDATE`, `AFTER DELETE`).
---
### FLAW-05: Fehlende Virtualisierung im Outliner-Baum
- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/lib/components/Outliner.svelte`
- **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:**
1. Implementierung einer virtuellen Fensterung (Virtual Windowing) für Blöcke außerhalb des Sichtbereichs.
2. Automatisches Einklappen (*Collapsing*) tiefer Hierarchieebenen beim initialen Laden langer Dokumente.
---
### FLAW-06: SQLite Concurrency & Single-Writer-Bottleneck
- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), [`database.md`](./database.md), `internal/storage/sqlite.go`
- **Ursache:** Obwohl SQLite mit `PRAGMA journal_mode = WAL;` parallele Leser erlaubt, kann zu jedem Zeitpunkt immer nur **ein einziger Thread** schreiben. Bei intensiver gleichzeitiger Nutzung (z. B. 20 Teammitglieder tippen gleichzeitig in Listen und Notizen, während im Hintergrund Webhook-Events einlaufen) können Schreib-Transaktionen aufeinander warten.
- **Folgen:** Risiko von `database is locked (SQLITE_BUSY)`-Fehlern, wenn der Busy-Timeout überschritten wird.
- **Empfohlene Lösung:**
1. Setzen von `PRAGMA busy_timeout = 5000;` (5 Sekunden Wartezeit vor Fehlerwurf).
2. Kapselung aller Schreibzugriffe über einen dedizierten Go-Channel / Single-Worker-Pool oder Migration auf PostgreSQL bei größeren Installationen.
---
### FLAW-07: Fehlende Dead-Letter-Queue für Benachrichtigungen
- **Betroffene Komponenten:** [`modules/tasks.md`](./modules/tasks.md), `internal/scheduler/alarm_ticker.go`
- **Ursache:** Der Alarm-Ticker überprüft minütlich fällige Aufgaben und sendet Webhooks bzw. E-Mails. Schlägt der HTTP-Post an den externen Webhook fehl (z. B. Zielserver vorübergehend offline), wird der Fehler lediglich ins Log geschrieben.
- **Folgen:** Der Alarm wird nicht erneut versucht und die Benachrichtigung geht dauerhaft verloren.
- **Empfohlene Lösung:**
1. Einführung einer Tabelle `notification_outbox` mit Spalten `retry_count`, `next_retry_at`, `status`.
2. Exponentieller Backoff (1m, 5m, 15m, 1h) und Status-Markierung (*failed*, *delivered*).
---
### FLAW-08: Statische JWT-Tokens ohne Blacklisting
- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), `internal/api/auth.go`
- **Ursache:** Nach dem Login wird ein JWT ausgestellt, das clientseitig im `localStorage` gespeichert wird. Der Server prüft lediglich die kryptografische Signatur des Tokens, führt aber keine serverseitige Sitzungsliste.
- **Folgen:** Wird einem Benutzer die Berechtigung entzogen oder ändert er sein Passwort, bleibt ein bereits ausgestellter Token bis zu seinem Ablaufdatum (z. B. 7 Tage) gültig.
- **Empfohlene Lösung:**
1. Einführung einer `revoked_tokens`-Tabelle oder einer `token_version`-Spalte in der `users`-Tabelle.
2. Implementierung von kurzlebigen Access-Tokens (15 Min.) in Verbindung mit rotierenden Refresh-Tokens in `httpOnly`-Cookies.
---
### 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)
- 🔌 [WebSocket & Live-Protokoll](api/websocket.md)
- 🧠 [Workspace-Modul (Outliner)](modules/workspace.md)
- 🛡️ [Rechte & Freigabesystem](sharing-and-permissions.md)
+14 -57
View File
@@ -1,62 +1,19 @@
# 🛒 Kernmodul 4: Geteilte Listen & Kollaboration # Modul: Geteilte Listen
Das Modul für geteilte Listen ([`SharedListsView.tsx`](../../frontend/src/features/lists/SharedListsView.tsx)) ist auf **Echtzeit-Kollaboration im Alltag** ausgelegt (z.B. gemeinsamer Wocheneinkauf im Supermarkt, Packlisten für Reisen, Inventarlisten oder schnelle Checklisten). Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsView.tsx](../../frontend/src/features/lists/SharedListsView.tsx); Backend: `*List*`-Handler in [server.go](../../internal/api/server.go), `Store.CreateList` und Verwandte.
--- ## Datenmodell
## 🛍️ 1. Listen-Typen & Kategorien - `lists`: Eigentümer, `title`, `icon`, `list_type` (`shopping`, `checklist`, `vacation`).
- `list_items`: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order`.
- `list_shares`: Ziel (Benutzer oder Gruppe) und Rolle `editor` oder `viewer`.
Jede Liste besitzt einen Typ, ein Icon und eine Farb-Codierung: ## Funktionen
| Listen-Typ | Icon | Einsatzzweck | Typische Kategorien | - **Kategorien per Hashtag:** `Hafermilch #Kühlregal` landet in der Kategorie „Kühlregal“. Kategorien werden alphabetisch als Abschnitte angezeigt; die Eingabe schlägt vorhandene Hashtags vor.
| :--- | :--- | :--- | :--- | - **Menge:** Feld `quantity` (z. B. `2x`, `500g`).
| `shopping` | 🛒 | Supermarkt- & Lebensmitteleinkauf | Obst & Gemüse, Kühlregal, Vorrat, Drogerie | - **Drag & Drop** ordnet Einträge um; in eine andere Kategorie gezogen, wird der Hashtag im Text angepasst (`items/reorder` mit `category` und `content`).
| `packing` | 🎒 | Urlaubs- & Reise-Packlisten | Kleidung, Elektronik, Dokumente, Kosmetik | - **Abhaken** speichert, wer und wann; Erledigte stehen in einem eigenen Bereich und lassen sich per „Erledigte entfernen“ löschen (`clear-completed`).
| `inventory` | 📦 | Vorratskammer, Keller, Homelab-Hardware | Regal 1, Gefrierschrank, Server-Rack | - **Freigabe** über den Dialog `UnifiedShareModal` (Bearbeiten oder Nur Lesen). Ein Leser sieht Änderungen live, ändert aber nichts.
| `todo` | 📝 | Einfache Gruppen-Checklisten | Heute, Später, Ideen | - **Live-Sync:** Ereignisse `ITEM_*` und `LIST_*` im Raum der Liste ([websocket.md](../api/websocket.md)). Abhaken erscheint bei allen Beteiligten sofort.
- Aus der Triage lassen sich Gedanken direkt als Eintrag in eine Liste übernehmen.
### Automatische Kategorisierung & Mengenangaben
- Einträge können mit einer Mengenangabe (z.B. `3x`, `500g`, `1 Kasten`) versehen werden.
- Die intelligente Eingabe erkennt Einheiten automatisch oder trennt Mengen über Eingabefelder.
- Einträge werden übersichtlich nach Kategorien gruppiert. Jede Kategorie kann zur besseren Übersicht eingeklappt werden.
---
## ⚡ 2. Echtzeit-Abhaken (Multi-User Live Sync)
Wenn mehrere Familienmitglieder oder Teammitglieder gleichzeitig im Supermarkt unterwegs sind, synchronisiert MindSift jeden Schritt in Echtzeit über WebSockets:
```mermaid
sequenceDiagram
autonumber
actor Alice as Alice (im Markt)
participant WS as WebSocket Hub
actor Bob as Bob (zu Hause)
Alice->>WS: PATCH /items/123 (isCompleted: true)
WS->>Bob: Event: ITEM_TOGGLED (itemId: "123", completedBy: "Alice")
Note over Bob: Eintrag wird sofort durchgestrichen<br/>Grünes Badge "von Alice" erscheint
```
- **Transparenz:** Beim Abhaken speichert MindSift `completed_by_name`. Ein dezentes grünes Pillen-Badge zeigt an, wer den Artikel eingepackt hat.
- **Erledigte Einträge:** Abgehakte Artikel wandern nach unten in einen einklappbaren Bereich „Erledigt".
- **Liste leeren:** Ein Klick auf „Erledigte entfernen" löscht alle abgehakten Einträge (`LIST_CLEARED`) für alle Teilnehmer.
---
## 🖐️ 3. Drag & Drop Umsortierung
Innerhalb einer Liste können Einträge frei per Drag & Drop sortiert werden:
1. **Innerhalb einer Kategorie:** Ändert die Reihenfolge (`sort_order`).
2. **Kategorie-Übergreifend:** Zieht der Benutzer einen Eintrag in eine andere Kategorie (z.B. von „Kühlregal" nach „Obst & Gemüse"), aktualisiert das Frontend optimistisch die Kategorie und sendet das Update ans Backend.
---
## 👥 4. Freigaben & Berechtigungen
Listen können über den einheitlichen Freigabe-Dialog ([`UnifiedShareModal.tsx`](../../frontend/src/components/UnifiedShareModal.tsx)) geteilt werden:
- **Freigabe für Teams:** Alle aktuellen und zukünftigen Mitglieder der gewählten Gruppe erhalten automatisch Zugriff.
- **Freigabe für Einzelbenutzer:** Gezielter Zugriff für einzelne Personen.
- **Rollen:**
- `editor`: Darf Einträge hinzufügen, bearbeiten, abhaken und löschen.
- `viewer`: Nur Lesezugriff (Einträge ansehen, aber nicht verändern).
+26 -74
View File
@@ -1,86 +1,38 @@
# 📋 Kernmodul 2: Aufgaben & Kanban-System # Modul: Aufgaben, Kanban & Alarme
Das Aufgaben-Modul ([`TasksView.tsx`](../../frontend/src/features/triage/TasksView.tsx)) bietet eine flexible Aufgabenverwaltung mit frei konfigurierbaren Listen, Kanban-Boards, Subtask-Checklisten und einem automatisierten Multi-Channel Alarm-System. Code: [TasksView.tsx](../../frontend/src/features/triage/TasksView.tsx), [TaskDetailModal.tsx](../../frontend/src/features/triage/TaskDetailModal.tsx), [ManageColumnsModal.tsx](../../frontend/src/features/triage/ManageColumnsModal.tsx); Backend: `handleUpdateThought`, `handleReorderTasks`, `*TaskList*`-Handler, `startAlarmTicker`.
--- ## Datenmodell
## 🗂️ 1. Datenmodell & Listen-Hierarchie Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId` und `sortOrder`. Aufgaben ohne Liste sind persönlich.
Aufgaben basieren technisch auf der Tabelle **`thoughts`** in SQLite. Ein Eintrag gilt als Aufgabe, wenn `status = 'kept'` oder `(status = 'converted' AND converted_type = 'task')` zutrifft. ## Ansichten
``` - **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
Benutzer (users) - **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
└── Aufgabenlisten (task_lists) - Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
├── Eigene Listen (z.B. "Privat", "Homelab") - Aufgaben lassen sich in eine Notiz umwandeln („Als Notiz vertiefen“). Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
└── Mit Teams geteilte Listen (task_list_shares)
└── Aufgaben (thoughts)
├── Kanban-Status (todo, in_progress, done)
├── Priorität (low, normal, high, urgent)
├── Fälligkeitsdatum (dueDate)
├── Notizen (notes)
└── Subtasks (JSON Array von Teilaufgaben)
```
### Listen-Sharing & Avatar-Stack ## Aufgabenlisten und Freigabe
Aufgabenlisten können für Gruppen/Teams freigegeben werden. Ein Avatar-Stack im Listen-Header und in der Tab-Leiste visualisiert alle Mitglieder, die Zugriff auf die jeweilige Liste haben.
--- - Jede Liste hat Eigentümer, Titel, Symbol, Farbe und eigene Kanban-Spalten.
- Freigabe an Gruppen oder Benutzer über den Dialog `UnifiedShareModal` (Stufe pro Freigabe):
- **Bearbeiter:** darf Aufgaben anlegen, ändern, abhaken, verschieben, löschen sowie Liste umbenennen und Spalten ändern.
- **Leser:** sieht alles, kann nichts ändern. Der Server antwortet mit `403`; die Oberfläche blendet Eingaben aus und zeigt „Nur Lesen“.
- Gruppenmitglieder mit mehreren Wegen erhalten die höchste Stufe. Nur der Eigentümer legt Freigaben an oder entfernt sie und kann die Liste löschen.
- Umsetzung: `Store.GetTaskListPermission` liefert `owner`/`editor`/`viewer`/`""`; `ensureTaskListWritable` schützt Schreibpfade.
- Bekannte Lücken: [FLAW-03](../flaws.md) (Umsortieren per ID), [FLAW-06](../flaws.md) (Editor kann weiterteilen).
## 🖥️ 2. Ansichten: Liste vs. Kanban-Board ## Alarme
Der Benutzer kann jederzeit nahtlos zwischen zwei Ansichten umschalten: Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `due_date <= heute` und `alarm_sent = 0` sendet er an die Kanäle des Aufgaben-**Eigentümers**:
### 1. Kompakte Listenansicht | Kanal | Ziel | Konfiguration |
- Optimiert für schnelles Abarbeiten und Tastatursteuerung. |---|---|---|
- Zeigt Checkbox, Titel, Prioritäts-Badge, Herkunfts-Pille (`Webhook`, `Diktat`), Fälligkeitsdatum und Subtask-Zähler (`2/5`). | E-Mail | `alarmEmail` (sonst Konto-Adresse) | SMTP aus Admin-Einstellungen oder Umgebung (Port 465 mit TLS wird direkt unterstützt, sonst STARTTLS über `smtp.SendMail`) |
- 1-Klick-Prioritäts-Wechsel: Klick auf ein Badge rotiert durch die Prioritäten. | Discord | Webhook-URL | Benutzereinstellungen |
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
### 2. Kanban-Board (Drag & Drop) Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war.
- Standardmäßig 3 Spalten: **Zu erledigen** (`todo`), **In Arbeit** (`in_progress`) und **Erledigt** (`done`).
- **Freie Spalten-Konfiguration:** Über das Spalten-Menü können Spalten umbenannt, farblich markiert oder neue Spalten hinzugefügt werden.
- **Drag & Drop:** Aufgaben können per HTML5 Drag & Drop zwischen Spalten verschoben werden. Das Backend speichert die relative Position (`sort_order`) und den neuen `kanban_status` automatisch.
--- Einschränkungen ([FLAW-12](../flaws.md)): Fälligkeiten haben keine Uhrzeit, `notifyBeforeMinutes` wird ignoriert, die Serverzeit gilt, und ein verschobener Termin löst nicht erneut aus. URLs werden nicht geprüft ([FLAW-05](../flaws.md)).
## 🎯 3. Prioritäten & Subtasks
### 4-Stufen-Prioritätensystem
Aufgaben unterstützen vier einheitliche Prioritätsstufen:
- **Niedrig (`low`):** Dezent grau hinterlegt.
- **Normal (`normal`):** Akzentfarbe.
- **Wichtig (`high`):** Gelb hervorgehoben mit Warndreieck (`⚠️ Wichtig`).
- **Dringend (`urgent`):** Rot pulsierend mit Flammen-Icon (`🔥 Dringend`).
### Subtasks (Teilaufgaben)
Jede Aufgabe kann eine hierarchische Checkliste von Subtasks enthalten:
- Gespeichert als JSON in der Spalte `subtasks`.
- Fortschrittsanzeige im Kartenkopf (z.B. `3/5 erledigt`).
- Einzelnes Abhaken synchronisiert direkt in die Datenbank.
---
## ⏰ 4. Multi-Channel Alarm-Scheduler
MindSift enthält einen serverseitigen Hintergrund-Ticker in Go ([`internal/api/server.go`](../../internal/api/server.go)), der fällige Aufgaben proaktiv an externe Kanäle meldet.
```mermaid
flowchart TD
Ticker["Go Background Ticker (alle 30s)"] --> Query["SELECT thoughts WHERE due_date <= now + Vorwarnzeit AND alarm_sent = 0"]
Query --> CheckChannels{"Welche Kanäle sind beim User aktiv?"}
CheckChannels -->|email| SendSMTP["E-Mail via SMTP versenden"]
CheckChannels -->|discord| SendDiscord["Discord Webhook POST"]
CheckChannels -->|webhook| SendCustom["Custom HTTP POST Webhook"]
SendSMTP --> MarkSent["UPDATE thoughts SET alarm_sent = 1"]
SendDiscord --> MarkSent
SendCustom --> MarkSent
```
### Alarm-Konfiguration
1. **Vorwarnzeit:** Einstellbar in Minuten (z.B. 15, 30, 60 Minuten vor Fälligkeit).
2. **Kanal-Multi-Select:** Benutzer können beliebig viele Kanäle parallel aktivieren:
- **E-Mail:** Versand über den in den Admin-Einstellungen hinterlegten SMTP-Server.
- **Discord:** Benachrichtigung mit formatierter Embed-Karte in einen Discord-Kanal.
- **Eigener Webhook:** Beliebige HTTP POST JSON Endpunkte (z.B. Gotify, Ntfy, Home Assistant).
3. **Schutz vor Doppel-Alarmen:** Sobald ein Alarm abgesetzt wurde, wird `alarm_sent = 1` gesetzt. Wird das Datum der Aufgabe später geändert, setzt der Server `alarm_sent = 0` automatisch zurück.
+25 -77
View File
@@ -1,88 +1,36 @@
# 📥 Kernmodul 1: Triage & Quick-Dump Engine # Modul: Schnellerfassung & Triage
Das Triage-Modul von MindSift basiert auf dem *Getting Things Done (GTD)*-Prinzip. Es trennt die **Erfassung von Gedanken** strikt von der **Entscheidung, was damit geschehen soll**. Ziel: Gedanken in Sekunden festhalten und später in Ruhe sortieren. Code: [InstantCaptureBar.tsx](../../frontend/src/features/capture/InstantCaptureBar.tsx), [TriageDeck.tsx](../../frontend/src/features/triage/TriageDeck.tsx), Backend `handleCaptureInbox`, `handleTriageAction` und `Store.UpdateThoughtTriage`.
--- ## Erfassung
## 💡 1. Das 2-Phasen-Konzept - Die Eingabeleiste sitzt im Header; `C` oder `/` fokussiert sie (außer beim Tippen in einem Feld).
- Ziel (`target`): **Posteingang** (`inbox`, Standard), **Direkt-Aufgabe** (`task`), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`).
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt die Web Speech API des Browsers; Quelle `voice` fügt den Tag `#diktat` an.
- Quelle `webhook` (per API-Key/Webhook) fügt den Tag `#webhook` an. Hashtags im Text werden als Tags extrahiert (`domain.ExtractTags`).
- Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)).
1. **Phase 1: Schnelles Erfassen (Instant Dump)** ## Triage-Deck
Ein Gedanke, ein Anruf, eine Idee oder ein Server-Alarm wird ohne Reibung erfasst. Der Benutzer muss in diesem Moment weder ein Projekt auswählen, noch Fälligkeitsdaten eingeben oder Ordner durchsuchen.
2. **Phase 2: Fokussierte Triage (Inbox Zero)**
Zu einem späteren Zeitpunkt (z.B. morgens oder beim Feierabend) sichtet der Benutzer den Stapel im Triage-Deck Karte für Karte und trifft für jeden Eintrag eine eindeutige Entscheidung.
--- Der Posteingang (`status = inbox`) wird Karte für Karte abgearbeitet.
## 🔄 2. Triage State Machine | Aktion | Desktop | Mobil | Ergebnis |
|---|---|---|---|
| Verwerfen | `H` oder `←` | Wischen links | `status = discarded` (Papierkorb) |
| Zur Aufgabe | `L` oder `→` | Wischen rechts | `kept`/`converted` als Aufgabe |
| Aufgabe mit Termin | `T` | Aktionsmenü | wie oben, mit Fälligkeit |
| In Notiz wandeln | `D` | Aktionsmenü | neues Dokument, Gedanke wird `converted` (`document`) |
| Später | Leertaste | Aktionsmenü | Karte ans Stapelende |
| Menü schließen | `Esc` | – | – |
```mermaid Die Umwandlung in eine Notiz übernimmt Titel, Erledigt-Status, Notizen, Unteraufgaben sowie Fälligkeit und Priorität als eingerückte Blöcke. Eine Umwandlung in einen Listeneintrag legt einen Eintrag in der gewählten Liste an und benachrichtigt deren Teilnehmer.
stateDiagram-v2
[*] --> Capture: Gedanke erfasst (Desktop / Mobile / Webhook)
Capture --> Inbox: Standard (status: 'inbox')
Capture --> Task: Direkt als Aufgabe (target: 'task')
Capture --> Document: Direkt als Notiz (target: 'document')
Capture --> ListItem: Direkt auf Liste (target: 'list_item')
state Inbox { ## Zustände eines Gedankens
[*] --> ActiveCard: Oben auf Stapel
ActiveCard --> Skipped: Leertaste / Später (Ans Ende)
Skipped --> ActiveCard
}
ActiveCard --> Discarded: Swipe links / H (Wegwerfen) ```
ActiveCard --> Task: Swipe rechts / L (Als Aufgabe) inbox ──keep──▶ kept ──convert──▶ converted (task | document | list_item)
ActiveCard --> TaskDated: T (Mit Fälligkeitsdatum) │ │
ActiveCard --> Document: D (In Notiz umwandeln) └─discard─▶ discarded ──restore──▶ inbox (Papierkorb leeren: DELETE /thoughts/discarded/purge)
ActiveCard --> ListItem: Auf Liste schieben
Discarded --> Trash: status: 'discarded'
Task --> TasksModule: status: 'kept', converted: 'task'
TaskDated --> TasksModule: status: 'kept', converted: 'task', dueDate
Document --> WorkspaceModule: status: 'converted', converted: 'document'
ListItem --> ListsModule: status: 'converted', converted: 'list_item'
``` ```
--- Ein Gedanke mit `status = kept` zählt bereits als Aufgabe und erscheint in der Aufgabenansicht.
## 🖐️ 3. Steuerung & Gesten (Mobile vs. Desktop)
### Desktop-Bedienung (Tastatur-First)
- `C` oder `/`: Fokussiert sofort die Eingabeleiste oben im Header.
- `Enter`: Speichert den Gedanken sofort im Deck.
- `←` oder `H`: Aktive Karte wegwerfen (`discard`).
- `→` oder `L`: Aktive Karte sofort zur Aufgabe machen (`task`).
- `T`: Datumsauswahl für Termin-Aufgabe öffnen.
- `D`: Aktive Karte in eine vollwertige Workspace-Notiz umwandeln.
- `Leertaste`: Karte überspringen und ans Ende des Stapels schieben („Später").
### Mobile Bedienung (Touch & Swipe)
- **Mittlerer Plus-Button (`+`):** Öffnet das design-angeglichene Bottom-Sheet mit Spracheingabe, 4 Prioritäts-Buttons (`Niedrig`, `Normal`, `Wichtig`, `Dringend`) und Fälligkeitsdatum.
- **Wischgeste nach links (Swipe Left):** Roter Indikator erscheint, Karte wird verworfen.
- **Wischgeste nach rechts (Swipe Right):** Grüner Indikator erscheint, Karte wird sofort zur Aufgabe.
- **Long-Press (Langes Drücken auf Karte):** Öffnet das Schnell-Aktions-Menü (Auf Liste, Mit Termin, Als Notiz, Später).
---
## 🎙️ 4. Spracheingabe (Speech-to-Text)
In der Desktop-Schnelleingabeleiste ([`InstantCaptureBar.tsx`](../../frontend/src/features/capture/InstantCaptureBar.tsx)) sowie im mobilen Triage-Modal ([`App.tsx`](../../frontend/src/App.tsx)) ist ein nativer Browser-Sprachassistent integriert:
- Verwendet die standardisierte `SpeechRecognition` bzw. `webkitSpeechRecognition` API.
- Erkennt Sprache auf Deutsch (`lang: 'de-DE'`).
- Live-Transkription: Gesprochener Text wird in Echtzeit in das Textfeld eingefügt.
- Haptisches Feedback: Vibrieren beim Starten und Stoppen der Aufnahme.
- Markiert erfasste Gedanken automatisch mit dem Herkunfts-Attribut `source: 'voice'` (Diktat-Badge).
---
## ⚡ 5. Webhook-Ingestion & Smarthome-Pille
MindSift kann externe Alarme (Home Assistant, Zigbee2MQTT, n8n, Proxmox, Monitoring) automatisch ins Triage-Deck einspeisen:
### Herkunftserkennung
1. Wird ein Gedanke über `/api/v1/inbox` mit `X-API-Key` oder über `/api/v1/webhooks/ingest` eingeliefert, setzt das Backend automatisch `source = "webhook"`.
2. Im Frontend visualisiert die Triage-Karte eine dezente Webhook-Pille (`<Webhook className="w-3 h-3 text-accent" /> Webhook`).
3. Diese Kennzeichnung bleibt auch erhalten, wenn die Karte später zu einer Aufgabe umgewandelt wird.
### Live-WebSocket Update
Nach erfolgreichem Ingest sendet der Server sofort ein `TASK_CREATED` Event über den WebSocket-Hub. Das geöffnete Triage-Deck fügt die neue Alarm-Karte sofort oben in den Stapel ein – ohne dass der Benutzer die Seite neu laden muss.
+28 -81
View File
@@ -1,92 +1,39 @@
# 📝 Kernmodul 3: Workspace Outliner-Engine, Wiki-Links & Pure-DB Storage # Modul: Workspace (Outliner-Notizen)
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**. Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockItem.tsx](../../frontend/src/features/workspace/CodeBlockItem.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, `Store.PatchDocumentBlocks`.
--- ## Datenhaltung
## 🌳 1. Die Logseq-Outliner-Engine - Eine Notiz ist ein Datensatz in `documents` plus geordnete Zeilen in `blocks` (`position` als Kommazahl, `level` für die Einrückung). Alles liegt in SQLite; es gibt keine Dateien auf der Platte.
- Bei jeder Änderung wird `documents.markdown_cache` neu erzeugt; `block_tags` wird aus `#Hashtags` der Blöcke neu aufgebaut.
- Der Export als `.md` erfolgt im Browser (Button im Editor, Blob-Download).
- Blocktypen: Überschriften `h1`–`h3`, `paragraph`/`text`, `bullet`, `todo`, `code`, `callout`, `divider`, `table`, `progress`.
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, ...`). ## Editor
``` - **Outliner:** `Enter` neuer Block, `Tab`/`Shift+Tab` ein-/ausrücken, `Shift+Enter` weicher Umbruch, Blöcke ein-/ausklappen.
• Projekt MindSift v2.0 (level 0) - **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
• Architektur-Entscheidung (level 1) - **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
- [x] Database-First Blocks implementieren (level 2) - **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
- [ ] Performance Benchmark mit 10.000 Blöcken (level 2) - **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
• Notizen aus dem Dev-Meeting (level 1) - **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
> Wichtig: Keine verschachtelten SQLite Queries ausführen! (level 2) - **Speichern:** Der Editor speichert 800 ms nach der letzten Eingabe das gesamte Dokument (`PUT /documents/{id}`). Der schlanke Block-Endpunkt `PATCH /documents/{id}/blocks` wird derzeit vom To-Do-Rollup beim Abhaken genutzt.
```
### Tastatur-Navigation & Shortcuts ## Explorer, Tags, To-Dos
- `Enter`: Erstellt einen neuen Block auf derselben Hierarchieebene.
- `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`).
### Zoom-In Hoisting (Fokus-Modus) - Ordnerbaum mit Favoriten (`doc_pins`), Verschieben per Drag & Drop (`/documents/{id}/move`, `/folders/move`).
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. - **Tags:** Alle `#Hashtags` werden aggregiert (`/workspace/tags`). Eine Tag-Seite zeigt alle Fundstellen gruppiert nach Notiz und kann eine eigene Freitext-Notiz tragen (`tag_pages`).
- Eine dezente Breadcrumb-Leiste oben zeigt den Pfad an (z.B. `Workspace / Projekte / MindSift / Architektur`). - **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz.
- Ein Klick auf ein beliebiges Breadcrumb-Element zoomt sofort auf die jeweilige Ebene zurück. - **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron.
### Drag & Drop von Blöcken ## Freigabe
Der Bullet-Dot dient gleichzeitig als Drag-Anfasser. Wird ein Block verschoben, erkennt die Engine automatisch alle untergeordneten Kind-Blöcke (`isDescendantBlock`) und verschiebt den **kompletten Unterbaum** an die neue Zielposition.
--- - Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md), [FLAW-17](../flaws.md)).
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
- Änderungen erreichen offene Fenster über `DOC_BLOCKS_PATCHED`/`DOC_UPDATED` ([websocket.md](../api/websocket.md)).
## 🔗 2. Wiki-Links (`[[Link]]`) & Hashtags ## Grenzen
1. **Interne Wiki-Links:** - Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
- Text in doppelten eckigen Klammern (z.B. `[[Reiseplanung 2026]]`) wird automatisch als klickbarer Link gerendert. - Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
- Ein Klick darauf durchsucht die Notizen des Benutzers und öffnet das Ziel-Dokument direkt im Editor. - Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen ([FLAW-18](../flaws.md)).
2. **Hashtags (`#tag`):**
- Hashtags im Fließtext werden automatisch indiziert (`doc_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. 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/):
### 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.
### Dual-Persistence in SQLite
```
Client (Y.Doc Mutation)
│
├──▶ WebSocket (CRDT_UPDATE Base64 Delta)
│ │
│ ▼
│ SQLite: document_crdt_updates / document_crdt_state
│ (Mathematisch exakte CRDT-Konvergenz)
│
└──▶ Periodischer Snapshot / Flush
│
▼
SQLite: document_blocks
(Relationale Indizes für Global To-Dos, Tag-Explorer & FTS)
```
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**.
---
## 📊 4. Globaler To-Do Rollup Dashboard
Über den Tab **„Globale To-Dos"** bietet der Workspace eine aggregierte Übersicht aller im gesamten Wiki verteilten Aufgaben:
- Scannt in Echtzeit alle unvollendeten `- [ ] ` Checkboxen über alle Ordner und Notizen hinweg.
- Übersichtliche Gruppierung nach **Ordner ➔ Dokument ➔ Aufgabe**.
- **Ordner-Filter:** Klick auf einen Ordner isoliert nur dessen Aufgaben für fokussiertes Arbeiten.
- **2-Wege-Live-Sync:** Wird eine Checkbox im Rollup abgehakt, wird der Block in SQLite sofort auf `checked = 1` aktualisiert und via WebSocket an alle aktiven Clients gebroadcastet.
+7 -7
View File
@@ -8,9 +8,9 @@ Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und
| Protokoll | Standard-Port | Benötigte Header & Einstellungen | Zweck | | Protokoll | Standard-Port | Benötigte Header & Einstellungen | Zweck |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Yjs-CRDT-Synchronisation & Awareness | | **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Live-Updates (Aufgaben, Listen, Notizen) |
| **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`<br>`proxy_buffering off`<br>`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade | | **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`<br>`proxy_buffering off`<br>`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade |
| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Anhänge & CRDT-Zustandsblöcke | | **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Import/Export (JSON) |
--- ---
@@ -38,7 +38,7 @@ server {
ssl_certificate /etc/letsencrypt/live/mindsift.example.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/mindsift.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mindsift.example.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/mindsift.example.com/privkey.pem;
# Maximale Payload-Größe für Backups und große CRDT-Zustände # Maximale Payload-Größe für große Notizen und Exporte
client_max_body_size 64M; client_max_body_size 64M;
# MindSift Frontend & REST API # MindSift Frontend & REST API
@@ -71,8 +71,8 @@ server {
proxy_send_timeout 3600s; proxy_send_timeout 3600s;
} }
# 2. Server-Sent Events Fallback (/api/v1/events) # 2. Server-Sent Events Fallback (/api/v1/events & /api/v1/sync/)
location /api/v1/events { location ~ ^/api/v1/(events|sync/) {
proxy_pass http://127.0.0.1:8080; proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -132,7 +132,7 @@ Caddy unterstützt WebSockets und HTTP/2 Server-Sent Events von Haus aus vollst
mindsift.example.com { mindsift.example.com {
encode gzip zstd encode gzip zstd
# Notizen, CRDT & Event-Stream ohne Pufferung weiterleiten # Event-Stream (SSE) ohne Pufferung weiterleiten
reverse_proxy 127.0.0.1:8080 { reverse_proxy 127.0.0.1:8080 {
# Streaming für SSE optimieren # Streaming für SSE optimieren
flush_interval -1 flush_interval -1
@@ -148,7 +148,7 @@ Wird MindSift hinter dem Cloudflare CDN betrieben:
1. **WebSockets aktivieren:** 1. **WebSockets aktivieren:**
- Navigiere in das Cloudflare Dashboard: **Network** > **WebSockets** > Schalter auf **ON** stellen. - Navigiere in das Cloudflare Dashboard: **Network** > **WebSockets** > Schalter auf **ON** stellen.
2. **Cloudflare Rocket Loader / Auto Minify:** 2. **Cloudflare Rocket Loader / Auto Minify:**
- Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe die Yjs-CRDT-Initialisierung stören können. - Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe den Start der Single-Page-App stören können.
3. **Cloudflare WAF / Bot Fight Mode:** 3. **Cloudflare WAF / Bot Fight Mode:**
- Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden. - Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden.
4. **SSE-Unterstützung:** 4. **SSE-Unterstützung:**
+39 -77
View File
@@ -1,93 +1,55 @@
# 👥 Synchrones Teilen & Berechtigungskonzept # Teilen & Berechtigungen
MindSift bietet ein mehrstufiges Kollaborations- und Freigabesystem. Es gewährleistet strikte Datensouveränität und Privatsphäre, ermöglicht aber gleichzeitig reibungslose Zusammenarbeit in Teams, Familien oder Unternehmen. ## Rollen
--- | Ebene | Werte | Bedeutung |
|---|---|---|
| Server | `admin`, `member` | Admins verwalten Benutzer, Einladungen und Einstellungen (`/admin/*`). |
| Gruppe | `admin`, `member` | Gruppen-Admins ändern/löschen die Gruppe und verwalten Mitglieder. Wer eine Gruppe anlegt, wird Admin. |
| Ressource | siehe unten | Eigentümer, Bearbeiter, Leser. |
## 🛡️ 1. Globale Rollen & System-Isolation ## Berechtigungen je Ressource
### Globale Rollen | Ressource | Eigentümer | Bearbeiter | Leser | Freigabe an |
- **Server-Admin (`role = 'admin'`):** Vollzugriff auf System-Einstellungen, E-Mail-Konfiguration (SMTP), Benutzerverwaltung, Einladungscodes und System-Statistiken. |---|---|---|---|---|
- **Mitglied (`role = 'member'`):** Regulärer Benutzer; arbeitet in seinen eigenen Notizen, Aufgaben und freigegebenen Teams. | **Aufgabenliste** (`task_list_shares.permission`) | alles, löschen, (un)teilen | Aufgaben anlegen/ändern/abhaken/verschieben/löschen, Liste umbenennen, Spalten ändern | nur ansehen | Benutzer, Gruppen |
| **Geteilte Liste** (`list_shares.role`) | alles, löschen, (un)teilen | Einträge anlegen/ändern/abhaken/löschen | nur ansehen | Benutzer, Gruppen |
| **Notiz** (`doc_shares`, `doc_team_shares`) | schreiben | – | nur lesen | Benutzer, Gruppen |
| **Geteilter Ordner** (`workspace_folder_shares`) | schreiben | – | nur lesen | Benutzer, Gruppen |
### System-Isolations-Modi (`sharing_mode`) - **Notizen und Ordner sind absichtlich schreibgeschützt für Empfänger.** Das erzwingt eine Migration beim Start (`permission = 'read'`); Schreibpfade im Code sind tot ([FLAW-17](flaws.md)).
Der Server-Admin legt global fest, wie Benutzer miteinander interagieren dürfen: - Bei Aufgabenlisten und Listen gilt: Hat jemand mehrere Zugriffswege (direkt und über Gruppen), zählt die **höchste** Stufe.
- Für Gruppen gilt die Freigabe für alle aktuellen und künftigen Mitglieder.
- Aufgabenlisten besitzen zusätzlich das Altfeld `team_id`: Mitglieder dieser Gruppe haben ohne Freigabezeile Bearbeiterrechte. Eine Gruppenfreigabe über den Dialog setzt `team_id`, falls es noch leer ist.
``` ## Aufgabenlisten teilen (Details)
┌────────────────────────────────────────────────────────────────────────┐
│ System-Isolations-Modi │
├──────────────────────┬─────────────────────────┬───────────────────────┤
│ strict_private │ teams_only │ open │
│ (Strikt Isoliert) │ (Teams & Gruppen) │ (Offene Instanz) │
│ │ [EMPFOHLENER STANDARD]│ │
├──────────────────────┼─────────────────────────┼───────────────────────┤
│ Kein Adressbuch │ Adressbuch zeigt nur │ Alle Benutzer sehen │
│ Keine Freigaben │ gemeinsame Teammit- │ alle anderen Konten │
│ Autarke Einzel-Instanz│ glieder │ im System │
└──────────────────────┴─────────────────────────┴───────────────────────┘
```
1. **`strict_private`:** Vollständige Abschottung. Benutzer wissen nicht voneinander. Keine Freigaben möglich. 1. Eigentümer öffnet „Teilen“ an der Liste, wählt Gruppe oder Benutzer und die Stufe **Bearbeiten** oder **Nur Lesen**.
2. **`teams_only` (Standard):** Kollaboration ist strikt an Gruppen gebunden. Ein Benutzer kann Objekte nur mit Personen teilen, die mindestens in einer gemeinsamen Gruppe/Team mit ihm sind. 2. `POST /task-lists/{id}/shares` speichert `permission`; erneutes Hinzufügen desselben Ziels ändert die Stufe.
3. **`open`:** Geeignet für Familien oder kleine Firmen. Jeder Benutzer kann für jeden anderen Benutzer Objekte freigeben. 3. `GET /task-lists` liefert je Liste `permission` (`owner`/`editor`/`viewer`). Das Frontend blendet für `viewer` alle Eingabemöglichkeiten aus und zeigt „Nur Lesen“.
4. Der Server prüft unabhängig davon jeden Schreibzugriff (`403`, `storage.ErrTaskListReadOnly`).
--- Bestehende Freigaben aus der Zeit vor dieser Stufe wurden mit `editor` migriert, behalten also ihr Verhalten.
## 🏢 2. Gruppen & Teams (`teams` & `team_members`) ## Isolations-Modi (`system_settings.sharing_mode`)
Gruppen strukturieren Benutzer (z.B. „Familie", „Entwicklung", „Marketing"): Werte: `strict_private`, `teams_only` (Standard), `open`. Gedacht ist:
- **Gruppen-Admin:** Darf Mitglieder einladen, Rollen ändern oder die Gruppe löschen.
- **Gruppen-Mitglied:** Hat Zugriff auf alle mit der Gruppe geteilten Ressourcen (Ordner, Aufgabenlisten, Einkaufslisten, Notizen).
Wird eine Ressource für eine Gruppe freigegeben, erhalten **alle aktuellen und zukünftigen Mitglieder** der Gruppe automatisch Zugriff, ohne dass Einzelberechtigungen gepflegt werden müssen. | Modus | Zweck |
|---|---|
| `strict_private` | Keine Sichtbarkeit zwischen Benutzern |
| `teams_only` | Freigaben nur zwischen Mitgliedern gemeinsamer Gruppen |
| `open` | Jeder kann für jeden freigeben |
--- **Tatsächlich** wird der Modus nur in `GetDiscoverableUsers` ausgewertet, und dort liefert `teams_only` bereits alle Benutzer. `GET /collaborators` ignoriert den Modus, und keine Freigabe-Funktion prüft ihn. Die Einstellung hat derzeit also kaum Wirkung ([FLAW-10](flaws.md)).
## 📁 3. Entkoppelte Ordner-Freigabe (Decoupled Folder Mounts) ## Entkoppelte Ablage geteilter Notizen und Ordner
### Das traditionelle Problem - **Geteilte Ordner:** Der Ordner existiert einmal (`workspace_shared_folders`); jeder Empfänger hat seinen eigenen Einhängepfad (`workspace_folder_mounts.mount_path`). Verschiebt ein Empfänger den Ordner, ändert sich nur sein Pfad. Der Unterpfad innerhalb des Ordners (`documents.sub_folder_path`) gilt für alle.
In herkömmlichen Cloud-Systemen (wie Google Drive oder Nextcloud) führt das Verschieben eines geteilten Ordners oft dazu, dass er auch bei allen anderen Teilnehmern den Platz wechselt. Verschiebt ein Kollege den Ordner in sein Unterverzeichnis „Archiv", verschwindet er beim Ersteller aus dem Sichtfeld. - **Einzelne Notizen:** Jeder Empfänger kann eine geteilte Notiz in einen eigenen Ordner legen (`doc_user_folders`); die Notiz des Eigentümers bleibt unberührt.
### Die MindSift-Lösung: Unabhängige Einhängepunkte (Mounts) ## Sitzungen und Zugriff
MindSift entkoppelt die physische Ordner-Identität von der persönlichen Ordner-Hierarchie des Benutzers:
``` - Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`.
Eigentümer (Alice): - Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
Ordner: "Kundenprojekte" - Bekannte Lücken in der Zugriffskontrolle: [FLAW-01](flaws.md), [FLAW-02](flaws.md), [FLAW-03](flaws.md), [FLAW-06](flaws.md), [FLAW-07](flaws.md), [FLAW-08](flaws.md), [FLAW-24](flaws.md).
└── Geteilter Ordner: "Projekt Phoenix" (wsf_123)
├── spec.md
└── roadmap.md
Empfänger (Bob):
Ordner: "Meine Aufgaben"
└── "Wichtiges"
└── Einhängepunkt: "Projekt Phoenix" (wsf_123 eingehängt via mount_path)
├── spec.md
└── roadmap.md
```
#### Wie es technisch funktioniert:
1. Der geteilte Ordner existiert als Entität in `workspace_shared_folders`.
2. Beim Eigentümer liegt der Ordner unter seinem Pfad.
3. Jeder Empfänger erhält in `workspace_folder_mounts` seinen **eigenen lokalen `mount_path`**.
4. **Unabhängiges Verschieben:** Schiebt Bob den Ordner an eine andere Stelle in seinem Dateibaum, wird **ausschließlich sein persönlicher `mount_path`** aktualisiert. Alice und andere Teammitglieder bemerken davon nichts.
5. **Inhaltliche Synchronisation:** Verschiebt, erstellt oder bearbeitet jemand eine Notiz *innerhalb* des geteilten Ordners, wird der relative Unterpfad (`sub_folder_path`) synchronisiert, sodass alle dieselbe interne Dokumentstruktur sehen.
---
## 📄 4. Entkoppelte Notizen-Freigabe (`doc_user_folders`)
Dasselbe Prinzip gilt für einzeln geteilte Dokumente:
- Erhält Benutzer Bob eine einzelne Notiz von Alice freigegeben, kann Bob diese Notiz in jeden beliebigen seiner eigenen Ordner einsortieren.
- Seine persönliche Zuordnung wird in der Tabelle `doc_user_folders` (`user_id`, `doc_id`, `folder_path`) gespeichert.
- Die Original-Notiz von Alice verbleibt an ihrem ursprünglichen Platz.
---
## 📋 5. Geteilte Aufgaben- & Einkaufslisten
- **Aufgabenlisten (`task_list_shares`):** Freigabe für Teams oder Einzelbenutzer mit Lese- oder Schreibrechten.
- **Geteilte Listen (`list_shares`):**
- Rolle `editor`: Einträge hinzufügen, ändern, abhaken und löschen.
- Rolle `viewer`: Nur Lesezugriff (Echtzeit-Ansicht).
- **Einheitlicher Freigabe-Dialog:** [`UnifiedShareModal.tsx`](../../frontend/src/components/UnifiedShareModal.tsx) bündelt alle Freigabeoptionen (Teams, Benutzer, Berechtigungsstufen) in einer konsistenten Benutzeroberfläche.
+8 -8
View File
@@ -6,7 +6,7 @@ MindSift implementiert ein radikal einfaches, modulares Theming-System. Anstatt
## 💡 1. Philosophie: 1 Datei = 1 komplettes Theme ## 💡 1. Philosophie: 1 Datei = 1 komplettes Theme
Jedes Theme liegt im Verzeichnis [`frontend/src/themes/`](../../frontend/src/themes/) und definiert alle visuellen Eigenschaften über standardisierte CSS-Variablen. Jedes Theme liegt im Verzeichnis [`frontend/src/themes/`](../frontend/src/themes/) und definiert alle visuellen Eigenschaften über standardisierte CSS-Variablen.
### Trennung von Struktur und Design ### Trennung von Struktur und Design
- **React-Komponenten** definieren ausschließlich semantische Klassen (wie `.panel-surface`, `.input-field`, `.btn-primary`, `text-main`, `bg-canvas`). - **React-Komponenten** definieren ausschließlich semantische Klassen (wie `.panel-surface`, `.input-field`, `.btn-primary`, `text-main`, `bg-canvas`).
@@ -78,17 +78,17 @@ Jede Theme-Datei gliedert sich in sechs exakt definierte Bereiche:
| Theme-ID | Datei | Stil & Charakter | Standard-Geometrie | | Theme-ID | Datei | Stil & Charakter | Standard-Geometrie |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| `dark` | [`theme-dark.css`](../../frontend/src/themes/theme-dark.css) | Standard Dark Slate / Monochrom | Streng eckig (`0px` Cards, `2px` Buttons) | | `dark` | [`theme-dark.css`](../frontend/src/themes/theme-dark.css) | Standard Dark Slate / Monochrom | Streng eckig (`0px` Cards, `2px` Buttons) |
| `light` | [`theme-light.css`](../../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) | | `light` | [`theme-light.css`](../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) |
| `high-contrast` | [`theme-high-contrast.css`](../../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) | | `high-contrast` | [`theme-high-contrast.css`](../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) |
| `soft` | [`theme-soft.css`](../../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) | | `soft` | [`theme-soft.css`](../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
| *Template* | [`theme-template.css`](../../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar | | *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
--- ---
## 🔌 4. Tailwind-Bridge & Semantische Klassen ## 🔌 4. Tailwind-Bridge & Semantische Klassen
In [`frontend/src/index.css`](../../frontend/src/index.css) und `tailwind.config.js` sind die semantischen Utility-Klassen definiert, die sich automatisch an das gewählte Theme anpassen: In [`frontend/src/index.css`](../frontend/src/index.css) und `tailwind.config.js` sind die semantischen Utility-Klassen definiert, die sich automatisch an das gewählte Theme anpassen:
- `.panel-surface`: Wendet `--bg-panel`, `--border-color` und `--radius-card` an. - `.panel-surface`: Wendet `--bg-panel`, `--border-color` und `--radius-card` an.
- `.input-field`: Wendet `--bg-input`, `--border-color`, `--radius-input` und `--text-main` an. - `.input-field`: Wendet `--bg-input`, `--border-color`, `--radius-input` und `--text-main` an.
@@ -105,5 +105,5 @@ Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") hinzuzufügen:
1. Kopiere `frontend/src/themes/theme-template.css` nach `frontend/src/themes/theme-meintheme.css`. 1. Kopiere `frontend/src/themes/theme-template.css` nach `frontend/src/themes/theme-meintheme.css`.
2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`. 2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`.
3. Passe Farben und Rundungen nach Belieben an. 3. Passe Farben und Rundungen nach Belieben an.
4. Importiere die CSS-Datei in [`frontend/src/lib/theme.ts`](../../frontend/src/lib/theme.ts) und trage das Theme im `AVAILABLE_THEMES`-Array ein. 4. Importiere die CSS-Datei in [`frontend/src/lib/theme.ts`](../frontend/src/lib/theme.ts) und trage das Theme im `AVAILABLE_THEMES`-Array ein.
5. Das neue Theme steht sofort in den Kontoeinstellungen und im Profil-Dropdown zur Verfügung! 5. Das neue Theme steht sofort in den Kontoeinstellungen und im Profil-Dropdown zur Verfügung!
+102 -143
View File
@@ -5,235 +5,194 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![Markdown](https://img.shields.io/badge/Datenhoheit-Pure%20Markdown%20%26%20Outliner-18181b?style=flat-square)](#) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![License](https://img.shields.io/badge/Lizenz-MIT-22c55e?style=flat-square)](LICENSE)
**MindSift** ist deine zentrale, kompromisslos schnelle Schaltstelle für spontane Gedanken, strukturierte Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen mit Zoom-Fokus und gemeinsame Echtzeit-Listen — ablenkungsfrei gestaltet, lokal als Standard-Markdown gespeichert und zu 100 % unter deiner eigenen Kontrolle. **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
<br />
<p align="center"> <p align="center">
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage Desktop" width="85%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" /> <img src="screenshots/Triage_desktop.png" alt="MindSift Triage Desktop" width="85%" />
</p> </p>
[Philosophie](#-die-philosophie) • [Funktionen](#-was-mindsift-auszeichnet) • [Schnellstart](#-schnellstart) • [Für wen?](#-für-wen-ist-mindsift-gemacht) [Philosophie](#-die-philosophie) • [Funktionen](#-funktionen) • [Schnellstart](#-schnellstart) • [Betrieb](#-betrieb--konfiguration) • [Entwicklung](#-entwicklung) • [Dokumentation](#-dokumentation)
</div> </div>
--- ---
## 💡 Die Philosophie: *Erst raus aus dem Kopf – später sortieren* ## 💡 Die Philosophie
Klassische Produktivitäts- und Notiz-Apps scheitern im Alltag oft an derselben Hürde: **Sie verlangen Struktur im falschen Moment.** Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn dir ein Gedanke kommt, willst du keinen Ordner suchen und keine Felder ausfüllen. MindSift trennt deshalb **Erfassen** und **Organisieren**:
Wenn dir mitten im Gespräch, unterwegs auf dem Smartphone oder kurz vor Feierabend ein wichtiger Gedanke kommt, willst du keinen Ordner suchen, keine Datenbankfelder ausfüllen und keine starren Formulare bedienen. Du willst den Gedanken in drei Sekunden festhalten – und sicher sein, dass er verlässlich da ist, wenn du Zeit hast.
**MindSift trennt das Erfassen radikal vom Organisieren:** 1. **Abladen:** In die Eingabeleiste tippen, `Enter`, fertig. Wer will, weist den Gedanken gleich einer Aufgabe, Liste oder Notiz zu.
2. **Sichten (Triage):** Später gehst du den Stapel Karte für Karte durch: verwerfen, zur Aufgabe machen, in eine Notiz oder Liste verwandeln.
1. **Sofort abladen (Quick Dump):** In die globale Eingabeleiste tippen, `Enter` drücken, Kopf wieder frei. Du kannst Gedanken aber auch direkt beim Eintippen gezielt einer Aufgabenliste, Einkaufsliste oder Notiz zuweisen. 3. **Vertiefen:** Aus losen Ideen werden Aufgaben mit Unteraufgaben, vernetzte Outliner-Notizen oder geteilte Listen für Familie und Team.
2. **Spielerisch sichten (Triage):** In ruhigen Momenten sichtest du deinen Stapel Karte für Karte – am Desktop per Tastaturkürzel oder auf dem Smartphone per Wischgeste. Mit einer Taste nach links verwerfen, auf „Später“ vertagen oder direkt in eine Aufgabe, Notiz oder Liste umwandeln.
3. **Strukturiert vertiefen:** Aus losen Ideen werden vernetzte Outliner-Dokumente, hierarchische Unteraufgaben im Kanban-Board oder geteilte Listen für Familie, WG und Team.
--- ---
## ✨ Was MindSift auszeichnet ## ✨ Funktionen
### ⚡ Quick-Dump & Triage-Deck (Das 3-Sekunden-Prinzip) ### ⚡ Schnellerfassung & Triage
* **Blitz-Erfassung:** Die Schnelleingabeleiste sitzt prominent im Header – jederzeit blitzschnell per Mausklick oder Tastendruck (`C` oder `/`) fokussierbar. * Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe (Browser-Funktion).
* **Tastatur-Triage am Desktop:** * Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
* `H` oder `←`: Wegwerfen / Erledigt * **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
* `L` oder `→`: Sofort zur Aufgabe machen * Papierkorb mit Wiederherstellen.
* `T`: Termin-Aufgabe mit Fälligkeitsdatum
* `D`: In Workspace-Notiz umwandeln
* `Leertaste`: Später (ans Ende des Stapels schieben)
* **Mobile-First & Touch-optimiert:** Unterwegs kannst du Gedanken per Wischgeste (Swipe nach links zum Verwerfen, Swipe nach rechts zum Behalten) sortieren.
<p align="center"> <p align="center">
<img src="screenshots/Mobile_Triage.png" alt="MindSift Mobile Triage" width="280px" style="border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.25);" /> <img src="screenshots/Mobile_Triage.png" alt="MindSift Mobile Triage" width="280px" />
</p> </p>
--- ### ✅ Aufgaben & Kanban
### 📝 Workspace: Der hierarchische Outliner mit Zoom-Fokus * Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar.
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
Der MindSift-Workspace vereint die Übersicht eines klassischen Dokumenten-Explorers mit der Leichtigkeit eines hierarchischen Outliners (inspiriert von Logseq und Roam). * Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board.
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
* **Alarme** bei Fälligkeit per E-Mail, Discord oder eigenem Webhook.
<p align="center"> <p align="center">
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace Outliner" width="90%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" /> <img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
</p> </p>
* **Jeder Gedanke ein Bullet-Point:** ### 📝 Workspace: Outliner-Notizen
* `Enter`: Neuer Bullet-Point auf derselben Ebene
* `Tab` / `Shift + Tab`: Unterordnen (einrücken) und ausrücken
* `Shift + Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks
* **🔍 Fokus-Modus & Zoom-in (Hoisting):**
* Klicke auf einen Bullet-Punkt (`•`), um **isoliert in diesen Ast hineinzuzoomen**.
* Der gewählte Punkt wird zur prominenten Überschrift, sodass du dich ohne jede Ablenkung auf genau dieses Thema konzentrieren kannst.
* Eine klickbare Breadcrumb-Leiste oben zeigt deinen aktuellen Pfad und führt dich mit einem Klick zurück in übergeordnete Ebenen oder ins Hauptdokument.
* **Intuitives Drag & Drop für Bullet-Points:**
* 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`.
* **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.
--- * Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
### ✅ Aufgabenlisten & Kanban-Board * Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`.
* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
Behalte offene Aufgaben, Projekte und Termine im Blick – wahlweise als strukturierte Liste oder als visuelles Kanban-Board. * Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt.
* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen **zum Lesen** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein.
<p align="center"> <p align="center">
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" /> <img src="screenshots/workspace_desktop.png" alt="MindSift Workspace Outliner" width="90%" />
</p> </p>
* **Flexible Ansichten (Liste & Kanban):** Wechsle mit einem Klick zwischen kompakter Listenansicht und dem interaktiven Kanban-Board (*Zu erledigen*, *In Arbeit*, *Wartend*, *Erledigt*). ### 🛒 Geteilte Listen
* **Unteraufgaben (Subtasks):**
* Gliedere komplexe Aufgaben in beliebig viele Teilaufgaben.
* Ein visueller Fortschrittsbalken und eine Prozentanzeige zeigen dir auf einen Blick, wie weit die Aufgabe gediehen ist.
* **Freies Drag & Drop:**
* Sortiere Aufgaben vertikal in deiner bevorzugten Reihenfolge.
* Im Kanban-Board verschiebst du Karten per Drag & Drop direkt zwischen Status-Spalten oder ordnest sie innerhalb einer Spalte um.
* Kein mühsames Suchen nach Greifern: Klicke und ziehe eine Aufgabe einfach direkt mit der Maus – ein kurzer Klick ohne Ziehen öffnet das Detail-Modal zum Bearbeiten.
* **Prioritäten & Termine:** Hebe dringende oder wichtige Aufgaben mit Farbcodes und Icons hervor (🔥 Dringend, ⚠️ Wichtig). Setze Fälligkeitsdaten und filtere nach Hashtags.
* **Geteilte Aufgabenlisten:** Erstelle separate Listen für Beruf, Homelab oder Familie und teile sie gezielt mit Gruppen, Teams oder einzelnen Nutzern.
--- * Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien.
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
### 🛒 Geteilte Listen (Einkauf & Checklisten)
Ob Wochenmarkt, Baumarkt oder Urlaubs-Packliste – erstelle geteilte Listen, die für gemeinsames Arbeiten in Echtzeit optimiert sind.
<p align="center"> <p align="center">
<img src="screenshots/Listen_desktop.png" alt="MindSift Geteilte Listen" width="90%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" /> <img src="screenshots/Listen_desktop.png" alt="MindSift Geteilte Listen" width="90%" />
</p> </p>
* **Automatische Hashtag-Kategorien:** ### 🏢 Benutzer, Gruppen, Administration
* Tagge Artikel einfach mit einem Hashtag (z. B. `Hafermilch #Kühlregal` oder `Bananen #Obst`).
* MindSift sortiert die Kategorien automatisch alphabetisch und zeigt sie in übersichtlichen Sektionen mit Kacheln an.
* **Hashtag-Autovervollständigung:** Bereits beim Tippen eines `#` schlägt dir die Eingabe existierende Hashtags vor (case-insensitive) – mit Tastaturnavigation und Sofort-Übernahme per `Enter`.
* **Mengenangaben & Schnell-Eingabe:** Erfasse Mengen (`2x`, `500g`) direkt im Eingabefeld.
* **Drag & Drop zwischen Kategorien:** Ziehe Einträge mit der Maus nach oben oder unten oder schiebe sie direkt in einen anderen Hashtag-Bereich – der Hashtag im Text wird automatisch aktualisiert.
* **Gemeinsam teilen mit Gruppen & Teams:**
* Über das einheitliche Teilen-Modal kannst du jede Liste mit deinen Teams, Arbeitsgruppen oder Einzelpersonen freigeben.
* Avatare und Freigabe-Badges im Header zeigen dir transparent, wer Zugriff hat.
* **Echtzeit-WebSocket-Synchronisation:** Wird ein Artikel im Supermarkt abgehakt, hakt er sich bei allen Beteiligten im selben Augenblick live ab.
* **Eckiges Abhaken & Aufräumen:** Erledigte Punkte wandern in einen separaten Bereich und können mit einem Klick aufgeräumt werden.
--- * Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
* Gruppen mit Gruppen-Admins für Freigaben.
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA.
### 📋 Globale To-Dos: Das automatische Notizen-Rollup ### 🔌 Integrationen
* **Automatische Erfassung:** MindSift durchsucht alle Workspace-Notizen und sammelt sämtliche Checkboxen (`- [ ]`) in einem zentralen Rollup-Dashboard. * **Webhook / API-Key:** `POST /api/v1/webhooks/ingest` legt Aufgaben aus Smarthome, Skripten oder Automationen an.
* **Ordner-Filter:** Isoliere Aufgaben bestimmter Projekte mit einem Klick. * Persönliche API-Keys, Datenexport als JSON.
* **2-Wege-Echtzeit-Sync:** Hakst du ein To-Do im Rollup ab, wird die physische Markdown-Datei auf der Festplatte augenblicklich als `- [x]` aktualisiert.
---
### 🏢 Benutzer, Gruppen & Administration
* **Geführte Ersteinrichtung:** Beim ersten Start führt dich ein Assistent durch die Einrichtung deines Administrator-Kontos.
* **Eigenständiger Gruppen-Bereich:** Verwalte eigene Gruppen und Teams direkt über das Benutzer-Menü oben rechts.
* **Gruppen-Administration:** Wer eine Gruppe anlegt oder zum Gruppen-Admin ernannt wird, hat die volle Kontrolle über die Mitglieder dieser Gruppe.
* **Server-Sicherheit:** Schalte die öffentliche Registrierung ab und lade neue Mitglieder kontrolliert per Einladungscode ein.
--- ---
## 🚀 Schnellstart ## 🚀 Schnellstart
MindSift läuft als leichtgewichtiger, einzelner Container und bringt Webserver, API, SQLite-Datenbank und Frontend direkt mit. MindSift läuft als einzelner Container mit Webserver, API, Datenbank und Frontend.
### Option 1: Der 1-Zeiler (Empfohlen) ### Option 1: Installationsskript
Führe diesen Befehl im Terminal deines Servers aus. Er erstellt das Verzeichnis, lädt die Konfiguration, generiert ein sicheres JWT-Geheimnis und startet den Container:
```bash ```bash
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/install.sh | bash curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/install.sh | bash
``` ```
--- Das Skript legt ein Verzeichnis an, lädt `docker-compose.yml` und `.env.example`, erzeugt ein `JWT_SECRET` und startet den Container.
### Option 2: Schneller Start mit zwei `curl`-Befehlen ### Option 2: Docker Compose
Falls du die Dateien direkt ohne Skript herunterladen möchtest:
```bash ```bash
mkdir -p mindsift && cd mindsift mkdir -p mindsift && cd mindsift
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/docker-compose.yml -o docker-compose.yml curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/docker-compose.yml -o docker-compose.yml
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -o .env curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -o .env
# In .env: JWT_SECRET durch einen langen Zufallswert ersetzen!
docker compose up -d docker compose up -d
``` ```
--- Minimal:
### Option 3: Manuelle `docker-compose.yml`
Erstelle eine Datei namens `docker-compose.yml`:
```yaml ```yaml
services: services:
mindsift: mindsift:
image: git.dsit.me/sandrodach/mindsift:dev image: git.dsit.me/sandrodach/mindsift:dev
container_name: mindsift
restart: unless-stopped restart: unless-stopped
ports: ports: ["8080:8080"]
- "8080:8080" volumes: ["./data:/data"]
volumes:
- ./data:/data
environment: environment:
- PORT=8080 - JWT_SECRET=hier-ein-langer-zufaelliger-wert
- DATA_DIR=/data - BASE_URL=https://mindsift.example.com
- JWT_SECRET=hier-ein-langes-sicheres-zufalls-geheimnis-eintragen
- ALLOW_REGISTRATION=false
# Optional: Mailserver für E-Mail-Alarme (auch im Admin-UI einstellbar)
- SMTP_HOST=
- SMTP_PORT=587
- SMTP_USER=
- SMTP_PASS=
- SMTP_FROM=
- SMTP_TLS=true
``` ```
Starte den Container: ### Ersteinrichtung
Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch das Anlegen des Administrator-Kontos.
> ⚠️ **Setze `JWT_SECRET`.** Ohne Angabe nutzt der Server einen öffentlich bekannten Standardwert, mit dem sich Anmelde-Tokens fälschen lassen. Rufe die Instanz außerdem direkt nach dem Start auf, damit kein Fremder das erste Konto anlegt.
---
## ⚙️ Betrieb & Konfiguration
| Variable | Standard | Zweck |
|---|---|---|
| `PORT` | `8080` | HTTP-Port |
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
| `JWT_SECRET` | unsicherer Standard | **Pflicht in Produktion**, Signaturschlüssel |
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL |
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin`, `user/user` an. **Nie auf öffentlichen Servern!** |
* **Reverse-Proxy:** WebSocket-Upgrade und ungepufferte SSE-Streams müssen durchgereicht werden. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
* **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer.
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst.
---
## 🛠 Entwicklung
Voraussetzungen: Go 1.23, Node 22.
```bash ```bash
docker compose up -d # Backend: Vet und Tests
go vet ./... && go test ./...
# Frontend
cd frontend
npm ci
npm run dev # Vite-Entwicklungsserver
npx tsc --noEmit # Typprüfung
npm run build # Produktions-Build
``` ```
--- Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Vet und Tests beim Image-Build aus.
### 🌐 Ersteinrichtung aufrufen **Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite) · React 18, TypeScript, Vite, Tailwind.
Öffne deinen Browser unter **`http://localhost:8080`** (bzw. die IP deines Servers).
MindSift erkennt den Erststart automatisch und führt dich durch den Einrichtungs-Assistenten zum Erstellen des Administrator-Kontos.
--- ---
## 👥 Für wen ist MindSift gemacht? ## 📚 Dokumentation
* **Für Macher & Wissensarbeiter**, die tagsüber dutzende Gedanken, Links und Aufgaben festhalten müssen, ohne aus dem Arbeitsfluss gerissen zu werden. Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank, REST- und Echtzeit-API, Module, Freigaben und Berechtigungen, Themes. Bekannte Schwachstellen und offene Punkte stehen in [Doku/flaws.md](Doku/flaws.md).
* **Für Outliner-Fans**, die die Flexibilität von Logseq schätzen, aber ihre Notizen in einer klaren Ordnerstruktur auf dem eigenen Server haben wollen.
* **Für Familien & WGs**, die gemeinsame Einkaufs- und Packlisten ohne Werbe-Apps und fremde Clouds synchronisieren möchten.
* **Für Homelab- & Self-Hosting-Begeisterte**, die volle Datenhoheit, saubere Markdown-Dateien und automatisierte Webhooks auf eigener Hardware schätzen.
--- ---
## 🔒 Deine Daten gehören dir ## 🔒 Deine Daten gehören dir
* **100 % Self-Hosted:** Keine fremden Cloud-Dienste, keine Telemetrie, kein Tracking. * Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
* **Kein Vendor-Lock-in:** Alle Notizen liegen als lesbare `.md`-Dateien im Ordner `./data/workspace`. Backups können jederzeit exportiert werden. * Alle Inhalte liegen in einer einzigen SQLite-Datei; Notizen lassen sich als Markdown exportieren, alle Daten eines Benutzers als JSON.
--- ---
## 📄 Lizenz ## 📄 Lizenz
Veröffentlicht unter der **MIT-Lizenz** – siehe [LICENSE](LICENSE). Im Repository liegt derzeit keine `LICENSE`-Datei; die Lizenz ist damit nicht festgelegt.
<div align="center"> <div align="center">
<sub>Gebaut für maximale Klarheit, Geschwindigkeit und digitale Souveränität.</sub> <sub>Gebaut für Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
</div> </div>
+33 -14
View File
@@ -4,7 +4,8 @@ import (
"context" "context"
"embed" "embed"
"io/fs" "io/fs"
"log" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -23,11 +24,16 @@ var embeddedDist embed.FS
func main() { func main() {
cfg := config.Load() 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) store, err := storage.NewStore(cfg.DatabasePath)
if err != nil { 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() defer store.Close()
@@ -35,11 +41,11 @@ func main() {
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS) store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS)
if cfg.DemoMode { 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 { 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 { } 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,23 +53,31 @@ func main() {
staticFS, err := fs.Sub(embeddedDist, "dist") staticFS, err := fs.Sub(embeddedDist, "dist")
if err != nil { 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) srv := api.NewServer(cfg, store, wsHub, staticFS)
serverCtx, serverCancel := context.WithCancel(context.Background())
defer serverCancel()
httpServer := &http.Server{ httpServer := &http.Server{
Addr: ":" + cfg.Port, Addr: ":" + cfg.Port,
Handler: srv.Router(), Handler: srv.Router(),
BaseContext: func(l net.Listener) context.Context {
return serverCtx
},
ReadTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
} }
go func() { 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 { 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,8 +85,13 @@ func main() {
signal.Notify(stop, os.Interrupt, syscall.SIGTERM) signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop <-stop
log.Println("Shutting down MindSift server gracefully...") slog.Info("Shutting down MindSift server gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) serverCancel()
wsHub.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
_ = httpServer.Shutdown(ctx) if err := httpServer.Shutdown(ctx); err != nil {
slog.Warn("HTTP server shutdown finished with error", "error", err)
}
} }
+3
View File
@@ -14,6 +14,9 @@ services:
- BASE_URL=${BASE_URL:-http://localhost:8080} - BASE_URL=${BASE_URL:-http://localhost:8080}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- LOG_LEVEL=${LOG_LEVEL:-info} - 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 # Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
- MINDA_DEMO=${MINDA_DEMO:-false} - MINDA_DEMO=${MINDA_DEMO:-false}
# Optional SMTP / Mail Configuration (can also be configured in Admin UI) # 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} - BASE_URL=${BASE_URL:-http://localhost:8080}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- LOG_LEVEL=${LOG_LEVEL:-info} - 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 # Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
- MINDA_DEMO=${MINDA_DEMO:-false} - MINDA_DEMO=${MINDA_DEMO:-false}
# Optional SMTP / Mail Configuration (can also be configured in Admin UI) # Optional SMTP / Mail Configuration (can also be configured in Admin UI)
+21
View File
@@ -14,6 +14,7 @@
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"y-indexeddb": "^9.0.12",
"yjs": "^13.6.33" "yjs": "^13.6.33"
}, },
"devDependencies": { "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+1 -2
View File
@@ -16,8 +16,7 @@
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1"
"yjs": "^13.6.33"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
+12 -3
View File
@@ -1,5 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" rx="128" fill="#0b0f19"/> <rect width="512" height="512" rx="128" fill="#8ab4f8"/>
<circle cx="256" cy="256" r="164" fill="none" stroke="#8ab4f8" stroke-width="36"/> <g transform="translate(80, 80) scale(14.666)" fill="none" stroke="#0b0f19" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M184 266 L236 318 L338 194" fill="none" stroke="#c58af9" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/> <path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/>
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/>
<path d="M3.477 10.896a4 4 0 0 1 .585-.396"/>
<path d="M19.938 10.5a4 4 0 0 1 .585.396"/>
<path d="M6 18a4 4 0 0 1-1.967-.516"/>
<path d="M19.967 17.484A4 4 0 0 1 18 18"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 374 B

After

Width:  |  Height:  |  Size: 851 B

+83 -62
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { import {
Layers, Layers,
CheckSquare, CheckSquare,
@@ -127,6 +127,8 @@ export const App: React.FC = () => {
const [activeTab, setActiveTab] = useState<ActiveTab>('triage'); const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage'); const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
const [selectedDocId, setSelectedDocId] = useState<string | null>(null); const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [captureFocusCount, setCaptureFocusCount] = useState(0); const [captureFocusCount, setCaptureFocusCount] = useState(0);
const [refreshRevision, setRefreshRevision] = useState(0); const [refreshRevision, setRefreshRevision] = useState(0);
@@ -143,6 +145,7 @@ export const App: React.FC = () => {
const [mobileVoiceActive, setMobileVoiceActive] = useState(false); const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false); const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
const mobileRecognitionRef = useRef<any>(null); const mobileRecognitionRef = useRef<any>(null);
const mobileBaseVoiceRef = useRef('');
const toggleMobileVoice = () => { const toggleMobileVoice = () => {
const SpeechRecognition = const SpeechRecognition =
@@ -157,6 +160,7 @@ export const App: React.FC = () => {
return; return;
} }
try { try {
mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim();
const recognition = new SpeechRecognition(); const recognition = new SpeechRecognition();
recognition.lang = 'de-DE'; recognition.lang = 'de-DE';
recognition.continuous = false; recognition.continuous = false;
@@ -170,10 +174,8 @@ export const App: React.FC = () => {
for (let i = 0; i < event.results.length; i++) { for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript; transcript += event.results[i][0].transcript;
} }
setMobileTriageContent((prev) => { const base = mobileBaseVoiceRef.current;
const base = prev.replace(/\s*\[…\]$/, '').trim(); setMobileTriageContent(base ? `${base} ${transcript}` : transcript);
return base ? `${base} ${transcript}` : transcript;
});
setIsVoiceCaptured(true); setIsVoiceCaptured(true);
}; };
recognition.onerror = () => setMobileVoiceActive(false); recognition.onerror = () => setMobileVoiceActive(false);
@@ -249,13 +251,6 @@ export const App: React.FC = () => {
id: 'triage-nav', id: 'triage-nav',
title: 'Bereiche wechseln', title: 'Bereiche wechseln',
items: [ items: [
{
id: 'goto-tasks',
label: 'Aufgaben & Kanban',
icon: <CheckSquare className="w-4 h-4" />,
badge: stats.tasksOpenCount,
onClick: () => setActiveTab('tasks'),
},
{ {
id: 'goto-workspace', id: 'goto-workspace',
label: 'Notizen & Workspace', label: 'Notizen & Workspace',
@@ -263,6 +258,13 @@ export const App: React.FC = () => {
badge: stats.docsCount, badge: stats.docsCount,
onClick: () => setActiveTab('workspace'), onClick: () => setActiveTab('workspace'),
}, },
{
id: 'goto-tasks',
label: 'Aufgaben & Kanban',
icon: <CheckSquare className="w-4 h-4" />,
badge: stats.tasksOpenCount,
onClick: () => setActiveTab('tasks'),
},
{ {
id: 'goto-lists', id: 'goto-lists',
label: 'Einkaufslisten', label: 'Einkaufslisten',
@@ -297,18 +299,18 @@ export const App: React.FC = () => {
variant: 'accent', variant: 'accent',
onClick: () => setActiveTab('triage'), onClick: () => setActiveTab('triage'),
}, },
{
id: 'goto-tasks',
label: 'Zu den Aufgaben',
icon: <CheckSquare className="w-4 h-4" />,
onClick: () => setActiveTab('tasks'),
},
{ {
id: 'goto-workspace', id: 'goto-workspace',
label: 'Zum Workspace', label: 'Zum Workspace',
icon: <FileText className="w-4 h-4" />, icon: <FileText className="w-4 h-4" />,
onClick: () => setActiveTab('workspace'), onClick: () => setActiveTab('workspace'),
}, },
{
id: 'goto-tasks',
label: 'Zu den Aufgaben',
icon: <CheckSquare className="w-4 h-4" />,
onClick: () => setActiveTab('tasks'),
},
{ {
id: 'goto-lists', id: 'goto-lists',
label: 'Zu den Listen', label: 'Zu den Listen',
@@ -414,7 +416,7 @@ export const App: React.FC = () => {
setActiveThemeState(themeId); setActiveThemeState(themeId);
}; };
const refreshAll = async () => { const refreshAll = useCallback(async () => {
try { try {
const [meRes, deckRes, listsRes] = await Promise.all([ const [meRes, deckRes, listsRes] = await Promise.all([
api.getMe(), api.getMe(),
@@ -428,7 +430,7 @@ export const App: React.FC = () => {
} catch { } catch {
// If token expired or invalid, api.ts dispatches mindsift:unauthorized // If token expired or invalid, api.ts dispatches mindsift:unauthorized
} }
}; }, []);
const checkBootAndAuth = async () => { const checkBootAndAuth = async () => {
setBootLoading(true); setBootLoading(true);
@@ -549,11 +551,17 @@ export const App: React.FC = () => {
refreshAll(); refreshAll();
}; };
const handleOpenDocument = (docId: string) => { const handleOpenDocument = (docId: string, blockId?: string) => {
setSelectedDocId(docId); setSelectedDocId(docId);
setSelectedBlockId(blockId || null);
setActiveTab('workspace'); setActiveTab('workspace');
}; };
const handleOpenTask = useCallback((taskId: string) => {
setSelectedTaskId(taskId);
setActiveTab('tasks');
}, []);
const handleTriggerAddButton = () => { const handleTriggerAddButton = () => {
triggerHaptic(20); triggerHaptic(20);
if (window.innerWidth < 640) { if (window.innerWidth < 640) {
@@ -567,7 +575,11 @@ export const App: React.FC = () => {
const handleLogout = async () => { const handleLogout = async () => {
triggerHaptic(20); triggerHaptic(20);
disconnectWebSocket(); disconnectWebSocket();
clearToken(); try {
await api.logout();
} catch {
clearToken();
}
setUser(null); setUser(null);
setShowProfileMenu(false); setShowProfileMenu(false);
setActiveTab('triage'); setActiveTab('triage');
@@ -632,9 +644,12 @@ export const App: React.FC = () => {
<Brain className="w-4 h-4" /> <Brain className="w-4 h-4" />
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="font-bold text-base text-main tracking-tight"> <div className="flex flex-col">
{authStatus.instanceName || 'MindSift'} <span className="font-bold text-base text-main tracking-tight leading-none">
</span> {authStatus.instanceName || 'MindSift'}
</span>
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">v0.1</span>
</div>
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md"> <span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
{activeTabLabelMap[activeTab]} {activeTabLabelMap[activeTab]}
</span> </span>
@@ -694,9 +709,12 @@ export const App: React.FC = () => {
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center"> <div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
<Brain className="w-4 h-4" /> <Brain className="w-4 h-4" />
</div> </div>
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none"> <div className="flex flex-col">
{authStatus.instanceName || 'MindSift'} <span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
</span> {authStatus.instanceName || 'MindSift'}
</span>
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">v0.1</span>
</div>
</div> </div>
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */} {/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
@@ -862,24 +880,6 @@ export const App: React.FC = () => {
</span> </span>
</button> </button>
<button
onClick={() => {
triggerHaptic(10);
setActiveTab('tasks');
}}
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
>
<CheckSquare className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
<span>Aufgaben</span>
{stats.tasksOpenCount > 0 && (
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
{stats.tasksOpenCount}
</span>
)}
</span>
</button>
<button <button
onClick={() => { onClick={() => {
triggerHaptic(10); triggerHaptic(10);
@@ -898,6 +898,24 @@ export const App: React.FC = () => {
</span> </span>
</button> </button>
<button
onClick={() => {
triggerHaptic(10);
setActiveTab('tasks');
}}
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
>
<CheckSquare className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
<span>Aufgaben</span>
{stats.tasksOpenCount > 0 && (
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
{stats.tasksOpenCount}
</span>
)}
</span>
</button>
<button <button
onClick={() => { onClick={() => {
triggerHaptic(10); triggerHaptic(10);
@@ -950,6 +968,7 @@ export const App: React.FC = () => {
onRefreshStats={refreshAll} onRefreshStats={refreshAll}
onOpenDocument={handleOpenDocument} onOpenDocument={handleOpenDocument}
refreshTrigger={refreshRevision} refreshTrigger={refreshRevision}
initialSelectedTaskId={selectedTaskId}
/> />
)} )}
@@ -957,7 +976,9 @@ export const App: React.FC = () => {
<WorkspaceView <WorkspaceView
currentUser={user} currentUser={user}
initialDocId={selectedDocId} initialDocId={selectedDocId}
initialBlockId={selectedBlockId}
onStatsChange={refreshAll} onStatsChange={refreshAll}
onOpenTask={handleOpenTask}
/> />
)} )}
@@ -1024,27 +1045,27 @@ export const App: React.FC = () => {
<span className="text-[10px] leading-tight">Triage</span> <span className="text-[10px] leading-tight">Triage</span>
</button> </button>
{/* Col 2: Aufgaben */} {/* Col 2: Workspace */}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
triggerHaptic(10); triggerHaptic(10);
setActiveTab('tasks'); setActiveTab('workspace');
}} }}
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${ className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
activeTab === 'tasks' ? 'text-accent font-semibold' : 'text-muted hover:text-main' activeTab === 'workspace' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
}`} }`}
title="Aufgaben" title="Workspace"
> >
<div className="relative"> <div className="relative">
<CheckSquare className="w-5 h-5" /> <FileText className="w-5 h-5" />
{stats.tasksOpenCount > 0 && ( {(stats.docsTodosOpenCount ?? 0) > 0 && (
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none"> <span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
{stats.tasksOpenCount} {stats.docsTodosOpenCount}
</span> </span>
)} )}
</div> </div>
<span className="text-[10px] leading-tight">Aufgaben</span> <span className="text-[10px] leading-tight">Workspace</span>
</button> </button>
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */} {/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
@@ -1066,27 +1087,27 @@ export const App: React.FC = () => {
</button> </button>
</div> </div>
{/* Col 4: Workspace */} {/* Col 4: Aufgaben */}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
triggerHaptic(10); triggerHaptic(10);
setActiveTab('workspace'); setActiveTab('tasks');
}} }}
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${ className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
activeTab === 'workspace' ? 'text-accent font-semibold' : 'text-muted hover:text-main' activeTab === 'tasks' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
}`} }`}
title="Workspace" title="Aufgaben"
> >
<div className="relative"> <div className="relative">
<FileText className="w-5 h-5" /> <CheckSquare className="w-5 h-5" />
{(stats.docsTodosOpenCount ?? 0) > 0 && ( {stats.tasksOpenCount > 0 && (
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none"> <span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
{stats.docsTodosOpenCount} {stats.tasksOpenCount}
</span> </span>
)} )}
</div> </div>
<span className="text-[10px] leading-tight">Workspace</span> <span className="text-[10px] leading-tight">Aufgaben</span>
</button> </button>
{/* Col 5: Listen */} {/* Col 5: Listen */}
+38 -4
View File
@@ -24,6 +24,7 @@ export interface UnifiedShareModalProps {
teams: Team[]; teams: Team[];
collaborators: User[]; collaborators: User[];
allowRoleSelection?: boolean; // Default true (editor vs viewer) allowRoleSelection?: boolean; // Default true (editor vs viewer)
fixedPermission?: 'editor' | 'viewer'; // When allowRoleSelection is false, force fixedPermission (default 'viewer' for documents, 'editor' for tasks/lists)
onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise<void>; onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise<void>;
onRemoveShare: (shareId: string) => Promise<void>; onRemoveShare: (shareId: string) => Promise<void>;
} }
@@ -40,13 +41,17 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
teams, teams,
collaborators, collaborators,
allowRoleSelection = true, allowRoleSelection = true,
fixedPermission,
onAddShare, onAddShare,
onRemoveShare, onRemoveShare,
}) => { }) => {
const effectiveFixedPerm = fixedPermission || 'viewer';
const [shareType, setShareType] = useState<'team' | 'user'>('team'); const [shareType, setShareType] = useState<'team' | 'user'>('team');
const [selectedTeamId, setSelectedTeamId] = useState(''); const [selectedTeamId, setSelectedTeamId] = useState('');
const [selectedUser, setSelectedUser] = useState<User | null>(null); const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor'); const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>(
allowRoleSelection ? 'editor' : effectiveFixedPerm
);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -63,7 +68,8 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
setLoading(true); setLoading(true);
try { try {
await onAddShare(shareType, targetId, selectedPermission); const permToUse = allowRoleSelection ? selectedPermission : effectiveFixedPerm;
await onAddShare(shareType, targetId, permToUse);
setSelectedTeamId(''); setSelectedTeamId('');
setSelectedUser(null); setSelectedUser(null);
} catch (err: any) { } catch (err: any) {
@@ -196,7 +202,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
</option> </option>
))} ))}
</select> </select>
{allowRoleSelection && ( {allowRoleSelection ? (
<select <select
value={selectedPermission} value={selectedPermission}
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')} onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
@@ -206,6 +212,20 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
<option value="editor" className="bg-panel text-main">Bearbeiten</option> <option value="editor" className="bg-panel text-main">Bearbeiten</option>
<option value="viewer" className="bg-panel text-main">Nur Lesen</option> <option value="viewer" className="bg-panel text-main">Nur Lesen</option>
</select> </select>
) : (
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
{effectiveFixedPerm === 'editor' ? (
<>
<Check className="w-3 h-3 text-accent" />
Bearbeiten
</>
) : (
<>
<Lock className="w-3 h-3 text-faint" />
Nur Lesen
</>
)}
</span>
)} )}
</div> </div>
) : ( ) : (
@@ -220,7 +240,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
placeholder="Benutzer suchen (Name oder @Benutzername)…" placeholder="Benutzer suchen (Name oder @Benutzername)…"
/> />
</div> </div>
{allowRoleSelection && ( {allowRoleSelection ? (
<select <select
value={selectedPermission} value={selectedPermission}
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')} onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
@@ -230,6 +250,20 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
<option value="editor" className="bg-panel text-main">Bearbeiten</option> <option value="editor" className="bg-panel text-main">Bearbeiten</option>
<option value="viewer" className="bg-panel text-main">Nur Lesen</option> <option value="viewer" className="bg-panel text-main">Nur Lesen</option>
</select> </select>
) : (
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
{effectiveFixedPerm === 'editor' ? (
<>
<Check className="w-3 h-3 text-accent" />
Bearbeiten
</>
) : (
<>
<Lock className="w-3 h-3 text-faint" />
Nur Lesen
</>
)}
</span>
)} )}
</div> </div>
)} )}
@@ -52,6 +52,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLFormElement>(null); const containerRef = useRef<HTMLFormElement>(null);
const recognitionRef = useRef<any>(null); const recognitionRef = useRef<any>(null);
const baseVoiceTextRef = useRef('');
const hasVoiceInputRef = useRef(false);
const adjustHeight = (el?: HTMLTextAreaElement | null) => { const adjustHeight = (el?: HTMLTextAreaElement | null) => {
const targetEl = el || inputRef.current; const targetEl = el || inputRef.current;
@@ -131,6 +133,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
} }
try { try {
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
hasVoiceInputRef.current = true;
const recognition = new SpeechRecognition(); const recognition = new SpeechRecognition();
recognition.lang = 'de-DE'; recognition.lang = 'de-DE';
recognition.continuous = false; recognition.continuous = false;
@@ -146,10 +151,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
for (let i = 0; i < event.results.length; i++) { for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript; transcript += event.results[i][0].transcript;
} }
setContent((prev) => { const base = baseVoiceTextRef.current;
const base = prev.replace(/\s*\[…\]$/, '').trim(); setContent(base ? `${base} ${transcript}` : transcript);
return base ? `${base} ${transcript}` : transcript;
});
}; };
recognition.onerror = () => { recognition.onerror = () => {
@@ -180,7 +183,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : ''); overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
const capturedPriority = priority; const capturedPriority = priority;
const capturedDueDate = dueDate; const capturedDueDate = dueDate;
const capturedSource = isListening ? 'voice' : 'manual'; const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
hasVoiceInputRef.current = false;
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers // Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
setContent(''); setContent('');
+204 -90
View File
@@ -8,6 +8,7 @@ import {
Sparkles, Sparkles,
X, X,
Hash, Hash,
ShoppingCart,
} from 'lucide-react'; } from 'lucide-react';
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types'; import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
@@ -84,6 +85,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [newItemText, setNewItemText] = useState(''); const [newItemText, setNewItemText] = useState('');
const [newItemQty, setNewItemQty] = useState(''); const [newItemQty, setNewItemQty] = useState('');
const [isSubmittingItem, setIsSubmittingItem] = useState(false); const [isSubmittingItem, setIsSubmittingItem] = useState(false);
const [editingQtyItemId, setEditingQtyItemId] = useState<string | null>(null);
const [editingQtyValue, setEditingQtyValue] = useState('');
const [wsConnected, setWsConnected] = useState(false); const [wsConnected, setWsConnected] = useState(false);
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none'); const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
@@ -105,7 +108,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null); const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null);
const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | null>(null); const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const realtimeConnRef = useRef<any>(null); const realtimeConnRef = useRef<any>(null);
const loadLists = async () => { const loadLists = async () => {
@@ -174,7 +176,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
return { ...l, items: deduplicateItems([newItem, ...l.items]) }; return { ...l, items: deduplicateItems([newItem, ...l.items]) };
}) })
); );
} else if (msg.event === 'ITEM_TOGGLED' && msgListId) { } else if ((msg.event === 'ITEM_TOGGLED' || msg.event === 'ITEM_UPDATED') && msgListId) {
const updatedItem: ListItem = msg.payload; const updatedItem: ListItem = msg.payload;
setLists((prev) => setLists((prev) =>
prev.map((l) => { prev.map((l) => {
@@ -273,6 +275,26 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
} }
}; };
const handleSaveQuantity = async (itemId: string, quantity: string) => {
setEditingQtyItemId(null);
if (!activeList) return;
const trimmed = quantity.trim();
try {
const updated = await api.updateListItem(activeList.id, itemId, { quantity: trimmed });
setLists((prev) =>
prev.map((l) => {
if (l.id !== activeList.id) return l;
return {
...l,
items: l.items.map((i) => (i.id === itemId ? { ...i, quantity: updated.quantity } : i)),
};
})
);
} catch (err: any) {
console.error('Fehler beim Aktualisieren der Menge', err);
}
};
const handleToggleItem = async (item: ListItem) => { const handleToggleItem = async (item: ListItem) => {
if (!activeList) return; if (!activeList) return;
triggerHaptic(10); triggerHaptic(10);
@@ -795,52 +817,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
return ( return (
<div className="w-full space-y-3"> <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) */} {/* 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 */} {/* List Tabs */}
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
{lists.map((lst) => { {lists.map((lst) => {
@@ -953,42 +931,106 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div> </div>
</div> </div>
{showNewListModal && ( {/* Mobile Compact Header Bar (List switching & actions live in MindSift Logo Menu) */}
<form <div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border !mt-0">
onSubmit={handleCreateList} <div className="flex items-center gap-2 min-w-0">
className="p-2 panel-surface flex flex-wrap items-center gap-2" <span className="text-sm font-semibold text-main truncate">
> {activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'}
<div className="flex items-center gap-1.5"> </span>
{['🛒', '🏔️', '🍕', '🎉', '🧰', '🎁'].map((ic) => ( {openItems.length > 0 && (
<button <span className="badge-neutral text-[11px] flex-shrink-0">
key={ic} {openItems.length} offen
type="button" </span>
onClick={() => setNewListIcon(ic)} )}
className={`w-9 h-9 flex items-center justify-center rounded-btn transition-colors ${ </div>
newListIcon === ic <div className="flex items-center gap-1.5 flex-shrink-0">
? 'bg-accent/20 border border-accent text-accent' {activeList && (
: 'bg-canvas hover:bg-panel-elevated border border-border' <button
}`} type="button"
> onClick={openShareModal}
{ic} className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
</button> title="Liste teilen"
))} >
</div> <Users className="w-4 h-4 text-accent" />
<input </button>
type="text" )}
value={newListTitle} <div
onChange={(e) => setNewListTitle(e.target.value)} className={`h-9 w-9 flex items-center justify-center rounded-btn border transition-colors ${
placeholder="Titel der Liste (z. B. Baumarkt, Drogerie, Camping)…" wsConnected
className="flex-1 h-9 px-3 input-field text-sm" ? realtimeTransport === 'sse'
autoFocus ? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
/> : 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
<button : 'bg-canvas border-border text-muted'
type="submit" }`}
className="btn-primary h-9 px-4 text-xs sm:text-sm" title={
wsConnected
? realtimeTransport === 'sse'
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
: 'Live synchronisiert (WebSocket online)'
: 'Offline'
}
> >
Liste erstellen <Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
</button> </div>
</form> </div>
</div>
{showNewListModal && (
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
<div className="flex items-center justify-between border-b border-border pb-2">
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
<ShoppingCart className="w-4 h-4 text-accent" />
<span>Neue Einkaufsliste erstellen</span>
</h3>
<button
type="button"
onClick={() => setShowNewListModal(false)}
className="text-muted hover:text-main"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleCreateList} className="space-y-3">
<div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5">
<input
type="text"
value={newListIcon}
onChange={(e) => setNewListIcon(e.target.value)}
maxLength={2}
className="w-10 h-8 text-center input-field text-sm"
title="Icon (Emoji)"
/>
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
autoFocus
required
className="flex-1 h-8 input-field px-2.5 text-xs"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<button
type="button"
onClick={() => setShowNewListModal(false)}
className="btn-action h-7 px-3 text-xs"
>
Abbrechen
</button>
<button type="submit" className="btn-primary h-7 px-3 text-xs">
Erstellen
</button>
</div>
</form>
</div>
</div>
)} )}
{/* Active Shared List View */} {/* Active Shared List View */}
@@ -1065,8 +1107,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<input <input
type="text" type="text"
value={newItemQty} value={newItemQty}
onChange={(e) => setNewItemQty(e.target.value)} onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
placeholder="Menge (z.B. 2x)" placeholder="Menge (z.B. 2)"
className="flex-1 sm:flex-none sm:w-28 h-9 sm:h-7 px-2.5 sm:px-2 input-field text-xs" className="flex-1 sm:flex-none sm:w-28 h-9 sm:h-7 px-2.5 sm:px-2 input-field text-xs"
/> />
<button <button
@@ -1176,10 +1218,45 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{displayContent} {displayContent}
</span> </span>
{item.quantity && ( {editingQtyItemId === item.id ? (
<span className="badge-accent"> <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{item.quantity} <input
</span> type="text"
autoFocus
value={editingQtyValue}
onChange={(e) => setEditingQtyValue(e.target.value.replace(/[^0-9.,xX]/g, ''))}
onKeyDown={async (e) => {
if (e.key === 'Enter') {
e.preventDefault();
await handleSaveQuantity(item.id, editingQtyValue);
} else if (e.key === 'Escape') {
setEditingQtyItemId(null);
}
}}
onBlur={async () => {
await handleSaveQuantity(item.id, editingQtyValue);
}}
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
placeholder="Menge"
/>
</div>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setEditingQtyItemId(item.id);
setEditingQtyValue(item.quantity || '');
}}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
item.quantity
? 'badge-accent cursor-pointer hover:brightness-125'
: 'opacity-0 group-hover:opacity-60 hover:!opacity-100 text-muted hover:text-accent border border-dashed border-border/70 hover:border-accent text-[11px]'
}`}
title="Menge bearbeiten"
>
{item.quantity || '+ Menge'}
</button>
)} )}
{cat && !group.label && ( {cat && !group.label && (
@@ -1254,8 +1331,45 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{displayContent} {displayContent}
</span> </span>
{item.quantity && ( {editingQtyItemId === item.id ? (
<span className="text-xs text-faint">({item.quantity})</span> <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
type="text"
autoFocus
value={editingQtyValue}
onChange={(e) => setEditingQtyValue(e.target.value.replace(/[^0-9.,xX]/g, ''))}
onKeyDown={async (e) => {
if (e.key === 'Enter') {
e.preventDefault();
await handleSaveQuantity(item.id, editingQtyValue);
} else if (e.key === 'Escape') {
setEditingQtyItemId(null);
}
}}
onBlur={async () => {
await handleSaveQuantity(item.id, editingQtyValue);
}}
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
placeholder="Menge"
/>
</div>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setEditingQtyItemId(item.id);
setEditingQtyValue(item.quantity || '');
}}
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
item.quantity
? 'text-faint hover:text-main cursor-pointer'
: 'opacity-0 group-hover:opacity-60 text-faint hover:text-main text-[11px]'
}`}
title="Menge bearbeiten"
>
{item.quantity ? `(${item.quantity})` : '+ Menge'}
</button>
)} )}
</div> </div>
@@ -49,6 +49,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false); const [savingProfile, setSavingProfile] = useState(false);
const [revokingSessions, setRevokingSessions] = useState(false);
const [isProcessingAvatar, setIsProcessingAvatar] = useState(false); const [isProcessingAvatar, setIsProcessingAvatar] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -155,6 +156,21 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
} }
}; };
const handleRevokeAllSessions = async () => {
if (!window.confirm('Möchtest du wirklich alle aktiven Sitzungen auf allen Geräten beenden? Du wirst dabei auch auf diesem Gerät abgemeldet.')) {
return;
}
setRevokingSessions(true);
try {
await api.revokeAllSessions();
await api.logout();
window.location.reload();
} catch (err: any) {
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Beenden der Sitzungen' });
setRevokingSessions(false);
}
};
// --- Handlers: Alarms --- // --- Handlers: Alarms ---
const handleSaveAlarms = async (e: React.FormEvent) => { const handleSaveAlarms = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -530,6 +546,32 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</button> </button>
</div> </div>
</form> </form>
{/* Sessions & Security */}
<div className="pt-5 border-t border-border space-y-3">
<div>
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
<p className="text-[11px] text-muted">
Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig.
</p>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
<div className="text-xs">
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
<p className="text-[11px] text-faint">
Invalidiert alle ausgestellten Tokens auf allen Geräten und Browsern.
</p>
</div>
<button
type="button"
onClick={handleRevokeAllSessions}
disabled={revokingSessions}
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer"
>
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
</button>
</div>
</div>
</div> </div>
)} )}
@@ -15,9 +15,11 @@ import {
ChevronRight, ChevronRight,
Folder, Folder,
Webhook, Webhook,
Lock,
} from 'lucide-react'; } from 'lucide-react';
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types'; import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
import { triggerHaptic } from '../../lib/api'; import { triggerHaptic } from '../../lib/api';
import { MarkdownViewer } from '../../lib/markdown';
interface TaskDetailModalProps { interface TaskDetailModalProps {
task: Thought | null; task: Thought | null;
@@ -36,7 +38,9 @@ interface TaskDetailModalProps {
onAddSubtask: (task: Thought, title: string) => Promise<void>; onAddSubtask: (task: Thought, title: string) => Promise<void>;
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>; onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
onConvertToDoc: (task: Thought) => void; onConvertToDoc: (task: Thought) => void;
onOpenDocument?: (docId: string, blockId?: string) => void;
onDeleteTask: (taskId: string) => void; onDeleteTask: (taskId: string) => void;
readOnly?: boolean; // Task belongs to a list shared with "viewer" permission
} }
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
@@ -56,7 +60,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onAddSubtask, onAddSubtask,
onDeleteSubtask, onDeleteSubtask,
onConvertToDoc, onConvertToDoc,
onOpenDocument,
onDeleteTask, onDeleteTask,
readOnly = false,
}) => { }) => {
if (!isOpen || !task) return null; if (!isOpen || !task) return null;
@@ -173,6 +179,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel"> <div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<button <button
disabled={readOnly}
onClick={() => { onClick={() => {
triggerHaptic(15); triggerHaptic(15);
const nextState = !task.isCompleted; const nextState = !task.isCompleted;
@@ -181,7 +188,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo'); const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
onUpdateStatus(task, nextKanban); onUpdateStatus(task, nextKanban);
}} }}
className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${ className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border disabled:cursor-default ${
task.isCompleted task.isCompleted
? 'bg-status-ok border-status-ok text-on-accent' ? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent' : 'border-strong hover:border-accent text-transparent'
@@ -191,8 +198,14 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<Check className="w-3.5 h-3.5 stroke-[3]" /> <Check className="w-3.5 h-3.5 stroke-[3]" />
</button> </button>
<span className="text-xs font-semibold text-main truncate"> <span className="text-xs font-semibold text-main truncate">
{task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'} {readOnly ? 'Aufgabe ansehen' : task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
</span> </span>
{readOnly && (
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
<Lock className="w-2.5 h-2.5 text-faint" />
<span>Nur Lesen</span>
</span>
)}
{task.source?.toLowerCase() === 'webhook' && ( {task.source?.toLowerCase() === 'webhook' && (
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0"> <span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
<Webhook className="w-2.5 h-2.5 text-accent" /> <Webhook className="w-2.5 h-2.5 text-accent" />
@@ -202,18 +215,36 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button {(task.convertedType === 'document_block' || task.docId) ? (
type="button" <button
onClick={() => { type="button"
onClose(); onClick={() => {
onConvertToDoc(task); onClose();
}} const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
title="Als Dokument / Notiz vertiefen" const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5" if (docId && onOpenDocument) onOpenDocument(docId, blockId);
> }}
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" /> title="Zur verlinkten Notiz springen"
<span className="hidden sm:inline">Als Notiz vertiefen</span> className="btn-primary min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 font-semibold"
</button> >
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span>In Notiz öffnen</span>
</button>
) : !readOnly && (
<button
type="button"
onClick={() => {
onClose();
onConvertToDoc(task);
}}
title="Als Dokument / Notiz vertiefen"
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5"
>
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
<span className="hidden sm:inline">Als Notiz vertiefen</span>
</button>
)}
{!readOnly && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -228,6 +259,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<Trash2 className="w-4 h-4 sm:w-3.5 sm:h-3.5" /> <Trash2 className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span className="sm:hidden font-medium">Löschen</span> <span className="sm:hidden font-medium">Löschen</span>
</button> </button>
)}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -239,8 +271,8 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</div> </div>
</div> </div>
{/* Scrollable Body */} {/* Scrollable Body (all controls disabled for read-only shares) */}
<div className="p-4 overflow-y-auto space-y-4 flex-1"> <fieldset disabled={readOnly} className="p-4 overflow-y-auto space-y-4 flex-1 min-w-0 border-0 m-0">
{/* Task Title */} {/* Task Title */}
<div> <div>
{isEditingTitle ? ( {isEditingTitle ? (
@@ -283,9 +315,11 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</div> </div>
) : ( ) : (
<div <div
onClick={() => setIsEditingTitle(true)} onClick={() => !readOnly && setIsEditingTitle(true)}
className="group cursor-pointer p-2 rounded-card hover:bg-panel transition-colors border border-transparent hover:border-border" className={`group p-2 rounded-card transition-colors border border-transparent ${
title="Klicken zum Bearbeiten des Titels" readOnly ? '' : 'cursor-pointer hover:bg-panel hover:border-border'
}`}
title={readOnly ? undefined : 'Klicken zum Bearbeiten des Titels'}
> >
<h2 <h2
className={`text-base font-semibold break-words leading-snug ${ className={`text-base font-semibold break-words leading-snug ${
@@ -294,9 +328,11 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
> >
{task.content} {task.content}
</h2> </h2>
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity"> {!readOnly && (
Klicken zum Umbenennen <span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
</span> Klicken zum Umbenennen
</span>
)}
</div> </div>
)} )}
</div> </div>
@@ -495,15 +531,18 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main" className="inline-flex items-center gap-1 px-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
> >
<span>#{tag}</span> <span>#{tag}</span>
<button {!readOnly && (
type="button" <button
onClick={() => handleRemoveTag(tag)} type="button"
className="text-muted hover:text-status-err" onClick={() => handleRemoveTag(tag)}
> className="text-muted hover:text-status-err"
<X className="w-3 h-3" /> >
</button> <X className="w-3 h-3" />
</button>
)}
</span> </span>
))} ))}
{!readOnly && (
<form onSubmit={handleAddTagSubmit} className="inline-flex items-center gap-1"> <form onSubmit={handleAddTagSubmit} className="inline-flex items-center gap-1">
<input <input
type="text" type="text"
@@ -513,6 +552,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
className="h-6 w-24 px-1.5 text-xs input-field" className="h-6 w-24 px-1.5 text-xs input-field"
/> />
</form> </form>
)}
</div> </div>
</div> </div>
@@ -567,6 +607,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
{st.title} {st.title}
</span> </span>
</div> </div>
{!readOnly && (
<button <button
type="button" type="button"
onClick={() => onDeleteSubtask(task, st.id)} onClick={() => onDeleteSubtask(task, st.id)}
@@ -574,12 +615,14 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
)}
</div> </div>
))} ))}
</div> </div>
)} )}
{/* Inline add subtask form */} {/* Inline add subtask form */}
{!readOnly && (
<form onSubmit={handleAddSubtaskSubmit} className="flex items-center gap-1.5"> <form onSubmit={handleAddSubtaskSubmit} className="flex items-center gap-1.5">
<input <input
type="text" type="text"
@@ -597,6 +640,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<span>Hinzufügen</span> <span>Hinzufügen</span>
</button> </button>
</form> </form>
)}
</div> </div>
{/* Notes Section */} {/* Notes Section */}
@@ -606,7 +650,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<FileText className="w-3.5 h-3.5 text-accent" /> <FileText className="w-3.5 h-3.5 text-accent" />
<span>Detailnotizen:</span> <span>Detailnotizen:</span>
</label> </label>
{!isEditingNotes && task.notes && ( {!readOnly && !isEditingNotes && task.notes && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -653,14 +697,19 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
) : task.notes ? ( ) : task.notes ? (
<div <div
onClick={() => { onClick={() => {
if (readOnly) return;
setNotesDraft(task.notes || ''); setNotesDraft(task.notes || '');
setIsEditingNotes(true); setIsEditingNotes(true);
}} }}
className="p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed whitespace-pre-wrap cursor-pointer hover:border-strong transition-colors" className={`p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed transition-colors ${
title="Klicken zum Bearbeiten" readOnly ? '' : 'cursor-pointer hover:border-strong'
}`}
title={readOnly ? undefined : 'Klicken zum Bearbeiten'}
> >
{task.notes} <MarkdownViewer content={task.notes} />
</div> </div>
) : readOnly ? (
<p className="text-xs text-faint">Keine Detailnotizen.</p>
) : ( ) : (
<button <button
type="button" type="button"
@@ -675,7 +724,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</button> </button>
)} )}
</div> </div>
</div> </fieldset>
{/* Modal Footer */} {/* Modal Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel"> <div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
+144 -52
View File
@@ -46,8 +46,9 @@ const getInitials = (name?: string): string => {
interface TasksViewProps { interface TasksViewProps {
currentUser?: User | null; currentUser?: User | null;
onRefreshStats: () => void; onRefreshStats: () => void;
onOpenDocument: (docId: string) => void; onOpenDocument: (docId: string, blockId?: string) => void;
refreshTrigger?: number; refreshTrigger?: number;
initialSelectedTaskId?: string | null;
} }
export const TasksView: React.FC<TasksViewProps> = ({ export const TasksView: React.FC<TasksViewProps> = ({
@@ -55,6 +56,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
onRefreshStats, onRefreshStats,
onOpenDocument, onOpenDocument,
refreshTrigger = 0, refreshTrigger = 0,
initialSelectedTaskId,
}) => { }) => {
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list'); const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open'); const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
@@ -101,6 +103,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
// Detail modal state // Detail modal state
const [detailTaskId, setDetailTaskId] = useState<string | null>(null); const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false); const [showDetailModal, setShowDetailModal] = useState(false);
const [extraTask, setExtraTask] = useState<Thought | null>(null);
useEffect(() => {
if (initialSelectedTaskId) {
setDetailTaskId(initialSelectedTaskId);
setShowDetailModal(true);
setFilter('open');
api.getThought(initialSelectedTaskId).then((th) => {
if (th) setExtraTask(th);
}).catch(() => {});
}
}, [initialSelectedTaskId]);
// Manage columns modal state // Manage columns modal state
const [showManageColumnsModal, setShowManageColumnsModal] = useState(false); const [showManageColumnsModal, setShowManageColumnsModal] = useState(false);
@@ -215,6 +229,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
const safeTaskLists = Array.isArray(taskLists) ? taskLists : []; const safeTaskLists = Array.isArray(taskLists) ? taskLists : [];
const currentSelectedList = safeTaskLists.find((l) => l.id === selectedListId); const currentSelectedList = safeTaskLists.find((l) => l.id === selectedListId);
const isSelectedListReadOnly = currentSelectedList?.permission === 'viewer';
const isTaskReadOnly = (task: Thought) =>
!!task.taskListId && safeTaskLists.find((l) => l.id === task.taskListId)?.permission === 'viewer';
// Active Kanban Columns (from selected list, or global custom, or default) // Active Kanban Columns (from selected list, or global custom, or default)
const activeColumns: KanbanColumn[] = useMemo(() => { const activeColumns: KanbanColumn[] = useMemo(() => {
@@ -243,8 +260,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
const detailTask = useMemo(() => { const detailTask = useMemo(() => {
if (!detailTaskId) return null; if (!detailTaskId) return null;
return tasks.find((t) => t.id === detailTaskId) || null; return tasks.find((t) => t.id === detailTaskId) || (extraTask?.id === detailTaskId ? extraTask : null);
}, [detailTaskId, tasks]); }, [detailTaskId, tasks, extraTask]);
const safeTeams = Array.isArray(teams) ? teams : []; const safeTeams = Array.isArray(teams) ? teams : [];
const safeCollaborators = Array.isArray(collaborators) ? collaborators : []; const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
@@ -588,6 +605,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
const handleTaskDragStart = (e: React.DragEvent, task: Thought) => { const handleTaskDragStart = (e: React.DragEvent, task: Thought) => {
e.stopPropagation(); e.stopPropagation();
if (isTaskReadOnly(task)) {
e.preventDefault();
return;
}
e.dataTransfer.setData('application/mindsift-task-id', task.id); e.dataTransfer.setData('application/mindsift-task-id', task.id);
e.dataTransfer.setData('text/plain', task.id); e.dataTransfer.setData('text/plain', task.id);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
@@ -851,7 +872,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
useEffect(() => { useEffect(() => {
setPrimaryAction(() => { setPrimaryAction(() => {
setMobileCreateListId(selectedListId === 'all' ? '' : selectedListId); setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId);
setShowMobileCreateModal(true); setShowMobileCreateModal(true);
}); });
@@ -862,7 +883,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
icon: <Plus className="w-4 h-4" />, icon: <Plus className="w-4 h-4" />,
variant: 'accent', variant: 'accent',
onClick: () => { onClick: () => {
setMobileCreateListId(selectedListId === 'all' ? '' : selectedListId); setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId);
setShowMobileCreateModal(true); setShowMobileCreateModal(true);
}, },
}, },
@@ -951,7 +972,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
}, },
]; ];
if (viewMode === 'kanban') { if (viewMode === 'kanban' && !isSelectedListReadOnly) {
viewItems.push({ viewItems.push({
id: 'manage-columns', id: 'manage-columns',
label: 'Kanban-Spalten verwalten', label: 'Kanban-Spalten verwalten',
@@ -1016,6 +1037,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
kanbanTasks.length, kanbanTasks.length,
safeTaskLists, safeTaskLists,
currentSelectedList, currentSelectedList,
isSelectedListReadOnly,
setPrimaryAction, setPrimaryAction,
setGroups, setGroups,
]); ]);
@@ -1057,13 +1079,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
// Clean Task Row in List View // Clean Task Row in List View
const renderTaskRow = (task: Thought) => { const renderTaskRow = (task: Thought) => {
const isCompleted = task.isCompleted; const isCompleted = task.isCompleted;
const readOnly = isTaskReadOnly(task);
const isDragging = draggingTaskId === task.id; const isDragging = draggingTaskId === task.id;
const isTarget = dragOverTaskTarget?.taskId === task.id; const isTarget = dragOverTaskTarget?.taskId === task.id;
return ( return (
<div <div
key={task.id} key={task.id}
draggable draggable={!readOnly}
onDragStart={(e) => handleTaskDragStart(e, task)} onDragStart={(e) => handleTaskDragStart(e, task)}
onDragEnd={() => { onDragEnd={() => {
setDraggingTaskId(null); setDraggingTaskId(null);
@@ -1077,7 +1100,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
setDetailTaskId(task.id); setDetailTaskId(task.id);
setShowDetailModal(true); setShowDetailModal(true);
}} }}
className={`px-3.5 py-3.5 sm:px-3 sm:py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-panel-hover group border-b border-border last:border-b-0 relative cursor-pointer cursor-grab active:cursor-grabbing select-none ${ className={`px-3.5 py-3.5 sm:px-3 sm:py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-panel-hover group border-b border-border last:border-b-0 relative cursor-pointer select-none ${
readOnly ? '' : 'cursor-grab active:cursor-grabbing'
} ${
isCompleted ? 'opacity-65' : '' isCompleted ? 'opacity-65' : ''
} ${isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''}`} } ${isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''}`}
> >
@@ -1094,17 +1119,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
<button <button
type="button" type="button"
draggable={false} draggable={false}
disabled={readOnly}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleToggleDone(task); handleToggleDone(task);
}} }}
className={`w-5 h-5 sm:w-4 sm:h-4 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${ className={`w-5 h-5 sm:w-4 sm:h-4 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border disabled:cursor-default ${
isCompleted isCompleted
? 'bg-status-ok border-status-ok text-on-accent' ? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent' : 'border-strong hover:border-accent text-transparent'
}`} }`}
title={isCompleted ? 'Als offen markieren' : 'Als erledigt abhaken'} title={readOnly ? 'Nur Lesen' : isCompleted ? 'Als offen markieren' : 'Als erledigt abhaken'}
> >
<Check className="w-3.5 h-3.5 sm:w-3 sm:h-3 stroke-[3]" /> <Check className="w-3.5 h-3.5 sm:w-3 sm:h-3 stroke-[3]" />
</button> </button>
@@ -1135,9 +1161,27 @@ export const TasksView: React.FC<TasksViewProps> = ({
className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden" className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{(task.convertedType === 'document_block' || task.docId) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
if (docId) onOpenDocument(docId, blockId);
}}
className="h-5 px-1.5 text-[10px] rounded-btn border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer flex items-center gap-1 font-medium"
title="In Notiz an der passenden Stelle öffnen"
>
<FileText className="w-2.5 h-2.5" />
<span>In Notiz</span>
</button>
)}
{/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */} {/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */}
<select <select
value={task.kanbanStatus || (isCompleted ? 'done' : 'todo')} value={task.kanbanStatus || (isCompleted ? 'done' : 'todo')}
disabled={readOnly}
onChange={(e) => handleSetKanbanStatus(task, e.target.value)} onChange={(e) => handleSetKanbanStatus(task, e.target.value)}
className="hidden sm:inline-block h-5 px-1.5 text-[10px] rounded-btn border border-border bg-panel text-muted hover:text-main cursor-pointer" className="hidden sm:inline-block h-5 px-1.5 text-[10px] rounded-btn border border-border bg-panel text-muted hover:text-main cursor-pointer"
title="Status / Zustand ändern" title="Status / Zustand ändern"
@@ -1153,8 +1197,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
{task.priority === 'urgent' && ( {task.priority === 'urgent' && (
<button <button
type="button" type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)} onClick={() => handleCyclePriority(task)}
className="badge-accent text-[10px] cursor-pointer hover:opacity-85" className="badge-accent text-[10px] cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Dringend -> Normal)" title="Klicken zum Ändern der Priorität (Dringend -> Normal)"
> >
🔥 Dringend 🔥 Dringend
@@ -1163,8 +1208,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
{task.priority === 'high' && ( {task.priority === 'high' && (
<button <button
type="button" type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)} onClick={() => handleCyclePriority(task)}
className="px-1.5 py-0.5 border border-status-warn text-status-warn rounded-btn text-[10px] font-medium cursor-pointer hover:opacity-85" className="px-1.5 py-0.5 border border-status-warn text-status-warn rounded-btn text-[10px] font-medium cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)" title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)"
> >
⚠️ Wichtig ⚠️ Wichtig
@@ -1235,10 +1281,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
setShowDetailModal(true); setShowDetailModal(true);
}} }}
className="text-faint hover:text-accent p-1.5 transition-colors" className="text-faint hover:text-accent p-1.5 transition-colors"
title="Aufgabe bearbeiten" title={readOnly ? 'Aufgabe ansehen' : 'Aufgabe bearbeiten'}
> >
<Edit3 className="w-3.5 h-3.5" /> {readOnly ? <Lock className="w-3.5 h-3.5" /> : <Edit3 className="w-3.5 h-3.5" />}
</button> </button>
{!readOnly && (
<>
<button <button
type="button" type="button"
draggable={false} draggable={false}
@@ -1257,6 +1305,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
</>
)}
</div> </div>
</div> </div>
); );
@@ -1264,36 +1314,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
return ( return (
<div className="w-full space-y-3"> <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) */} {/* 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 */} {/* Task Lists selector tabs */}
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
<button <button
@@ -1448,7 +1470,45 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div> </div>
</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>
{isSelectedListReadOnly && (
<div className="flex items-center gap-2 p-2.5 rounded-btn bg-panel-elevated border border-border text-xs text-muted">
<Lock className="w-3.5 h-3.5 text-faint shrink-0" />
<span>
Diese Liste wurde von {currentSelectedList?.ownerName || 'einem anderen Benutzer'} nur zum Lesen freigegeben.
</span>
</div>
)}
{/* Quick Add Task Input (Desktop only - on mobile, tapping the bottom '+' FAB opens mobile create sheet) */} {/* Quick Add Task Input (Desktop only - on mobile, tapping the bottom '+' FAB opens mobile create sheet) */}
{!isSelectedListReadOnly && (
<form <form
onSubmit={handleQuickAddTask} onSubmit={handleQuickAddTask}
className="hidden sm:flex items-center gap-1.5 panel-surface p-2 sm:p-1.5" className="hidden sm:flex items-center gap-1.5 panel-surface p-2 sm:p-1.5"
@@ -1483,6 +1543,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button> </button>
</div> </div>
</form> </form>
)}
{/* Mobile Active Filter Badge Banner (minimal and clean) */} {/* Mobile Active Filter Badge Banner (minimal and clean) */}
{(selectedTag || groupBy !== 'none' || sortBy !== 'custom') && ( {(selectedTag || groupBy !== 'none' || sortBy !== 'custom') && (
@@ -1567,6 +1628,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* Group By & Sort By Dropdowns & Manage Columns button */} {/* Group By & Sort By Dropdowns & Manage Columns button */}
<div className="flex items-center gap-2 flex-shrink-0 ml-auto"> <div className="flex items-center gap-2 flex-shrink-0 ml-auto">
{viewMode === 'kanban' ? ( {viewMode === 'kanban' ? (
!isSelectedListReadOnly && (
<button <button
onClick={() => setShowManageColumnsModal(true)} onClick={() => setShowManageColumnsModal(true)}
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1.5" className="btn-action h-7 px-2.5 text-xs flex items-center gap-1.5"
@@ -1575,6 +1637,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<Columns className="w-3.5 h-3.5 text-accent" /> <Columns className="w-3.5 h-3.5 text-accent" />
<span>Spalten anpassen</span> <span>Spalten anpassen</span>
</button> </button>
)
) : ( ) : (
<> <>
{/* Group By */} {/* Group By */}
@@ -1971,17 +2034,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'), targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
targetEmail: sh.email, targetEmail: sh.email,
avatarUrl: sh.avatarUrl, avatarUrl: sh.avatarUrl,
permission: 'editor' as const, permission: sh.permission === 'viewer' ? 'viewer' as const : 'editor' as const,
role: 'editor', role: sh.permission === 'viewer' ? 'viewer' : 'editor',
createdAt: sh.createdAt, createdAt: sh.createdAt,
}))} }))}
teams={safeTeams} teams={safeTeams}
collaborators={safeCollaborators} collaborators={safeCollaborators}
allowRoleSelection={false} onAddShare={async (targetType, targetId, permission) => {
onAddShare={async (targetType, targetId) => {
await api.addTaskListShare(sharingTaskList.id, { await api.addTaskListShare(sharingTaskList.id, {
targetType, targetType,
targetId, targetId,
permission,
}); });
const [freshLists, freshShares] = await Promise.all([ const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(), api.getTaskLists(),
@@ -2029,6 +2092,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
}} }}
activeColumns={activeColumns} activeColumns={activeColumns}
taskLists={safeTaskLists} taskLists={safeTaskLists}
onOpenDocument={onOpenDocument}
onUpdateTitle={handleUpdateTaskTitle} onUpdateTitle={handleUpdateTaskTitle}
onUpdateStatus={handleSetKanbanStatus} onUpdateStatus={handleSetKanbanStatus}
onUpdatePriority={handleSetPriority} onUpdatePriority={handleSetPriority}
@@ -2041,6 +2105,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
onDeleteSubtask={handleDeleteSubtask} onDeleteSubtask={handleDeleteSubtask}
onConvertToDoc={handleConvertToDoc} onConvertToDoc={handleConvertToDoc}
onDeleteTask={handleDeletePermanent} onDeleteTask={handleDeletePermanent}
readOnly={detailTask ? isTaskReadOnly(detailTask) : false}
/> />
{/* VIEW MODES: LIST OR KANBAN */} {/* VIEW MODES: LIST OR KANBAN */}
@@ -2097,11 +2162,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
{colTasks.map((task) => { {colTasks.map((task) => {
const isDragging = draggingTaskId === task.id; const isDragging = draggingTaskId === task.id;
const isTarget = dragOverTaskTarget?.taskId === task.id; const isTarget = dragOverTaskTarget?.taskId === task.id;
const readOnly = isTaskReadOnly(task);
return ( return (
<div <div
key={task.id} key={task.id}
draggable draggable={!readOnly}
onDragStart={(e) => handleTaskDragStart(e, task)} onDragStart={(e) => handleTaskDragStart(e, task)}
onDragEnd={() => { onDragEnd={() => {
setDraggingTaskId(null); setDraggingTaskId(null);
@@ -2115,7 +2181,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
setDetailTaskId(task.id); setDetailTaskId(task.id);
setShowDetailModal(true); setShowDetailModal(true);
}} }}
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all cursor-grab active:cursor-grabbing shadow-sm group relative ${ className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative ${
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
} ${
isDragging ? 'opacity-30 border border-dashed border-accent/40' : '' isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
}`} }`}
> >
@@ -2134,6 +2202,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
> >
{task.content} {task.content}
</p> </p>
{!readOnly && (
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}> <div className="hidden sm:flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<button <button
onClick={() => handleConvertToDoc(task)} onClick={() => handleConvertToDoc(task)}
@@ -2150,6 +2219,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
</div> </div>
)}
</div> </div>
{/* Subtasks progress */} {/* Subtasks progress */}
@@ -2163,13 +2233,30 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div> </div>
)} )}
{/* Badges: Priority, Status, Tags */} {/* Badges: In Notiz, Priority, Status, Tags */}
<div className="flex flex-wrap items-center gap-1 pt-0.5" onClick={(e) => e.stopPropagation()}> <div className="flex flex-wrap items-center gap-1 pt-0.5" onClick={(e) => e.stopPropagation()}>
{(task.convertedType === 'document_block' || task.docId) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
if (docId) onOpenDocument(docId, blockId);
}}
className="h-4 px-1.5 text-[9px] rounded-btn border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer flex items-center gap-1 font-medium shrink-0"
title="In Notiz an der passenden Stelle öffnen"
>
<FileText className="w-2.5 h-2.5" />
<span>In Notiz</span>
</button>
)}
{task.priority === 'urgent' && ( {task.priority === 'urgent' && (
<button <button
type="button" type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)} onClick={() => handleCyclePriority(task)}
className="badge-accent text-[9px] cursor-pointer hover:opacity-85" className="badge-accent text-[9px] cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Dringend -> Normal)" title="Klicken zum Ändern der Priorität (Dringend -> Normal)"
> >
🔥 Dringend 🔥 Dringend
@@ -2178,8 +2265,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
{task.priority === 'high' && ( {task.priority === 'high' && (
<button <button
type="button" type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)} onClick={() => handleCyclePriority(task)}
className="px-1.5 py-0.2 border border-status-warn text-status-warn rounded-btn text-[9px] font-medium cursor-pointer hover:opacity-85" className="px-1.5 py-0.2 border border-status-warn text-status-warn rounded-btn text-[9px] font-medium cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)" title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)"
> >
⚠️ Wichtig ⚠️ Wichtig
@@ -2217,6 +2305,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div> </div>
{/* Column Navigation controls: Move Left / Move Right */} {/* Column Navigation controls: Move Left / Move Right */}
{!readOnly && (
<div <div
className="flex items-center justify-between pt-1 border-t border-border text-[10px]" className="flex items-center justify-between pt-1 border-t border-border text-[10px]"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -2243,6 +2332,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<ArrowRight className="w-3 h-3" /> <ArrowRight className="w-3 h-3" />
</button> </button>
</div> </div>
)}
</div> </div>
); );
})} })}
@@ -2255,6 +2345,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div> </div>
{/* Quick Add Task to Column */} {/* Quick Add Task to Column */}
{!isSelectedListReadOnly && (
<div className="p-2 border-t border-border bg-panel"> <div className="p-2 border-t border-border bg-panel">
<button <button
type="button" type="button"
@@ -2265,6 +2356,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<span>Aufgabe hinzufügen</span> <span>Aufgabe hinzufügen</span>
</button> </button>
</div> </div>
)}
</div> </div>
); );
})} })}
File diff suppressed because it is too large. Load diff
@@ -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,957 @@
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from 'react';
import {
ChevronRight,
ChevronDown,
Check,
CheckSquare,
X,
Trash2,
MoreHorizontal,
Code,
Heading1,
Heading2,
Heading3,
ListTodo,
Minus,
MessageSquare,
Hash,
ExternalLink,
Maximize2,
Link as LinkIcon,
FileText,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal';
import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api';
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 {
docId: string;
block: DocumentBlock;
index: number;
isLast: boolean;
maxAllowedLevel: number;
hasChildren: boolean;
isFocused: boolean;
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
onFocusBlock?: (blockId: string) => void;
onOpenTask?: (taskId: string) => 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;
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
}
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
docId,
block,
index,
isLast,
maxAllowedLevel,
hasChildren,
isFocused,
canEdit,
levelOffset = 0,
onZoomBlock,
onUpdateBlock,
onCreateBlockBelow,
onDeleteBlock,
onIndentBlock,
onNavigateFocus,
onFocusBlock,
onOpenTask,
onTagClick,
onWikiLinkClick,
onDragStartBlock,
onDragOverBlock,
onDropBlock,
isDragTarget,
isTargeted,
registerTextareaRef,
onPasteMultiLines,
}) => {
const [localContent, setLocalContent] = useState<string>(block.content || '');
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false);
const activeFocus = isFocused || isLocallyFocused;
const [slashSearch, setSlashSearch] = useState<string | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const [showTodoMenu, setShowTodoMenu] = 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 onCreateBlockBelowRef = useRef(onCreateBlockBelow);
onCreateBlockBelowRef.current = onCreateBlockBelow;
const onPasteMultiLinesRef = useRef(onPasteMultiLines);
onPasteMultiLinesRef.current = onPasteMultiLines;
const onDeleteBlockRef = useRef(onDeleteBlock);
onDeleteBlockRef.current = onDeleteBlock;
const onIndentBlockRef = useRef(onIndentBlock);
onIndentBlockRef.current = onIndentBlock;
const onNavigateFocusRef = useRef(onNavigateFocus);
onNavigateFocusRef.current = onNavigateFocus;
const isPastingRef = useRef<boolean>(false);
// 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;
};
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, activeFocus, 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);
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 = () => {
isFocusedRef.current = true;
setIsLocallyFocused(true);
onFocusBlock?.(block.id);
setTimeout(adjustHeight, 0);
};
const handleBlur = () => {
isFocusedRef.current = false;
setIsLocallyFocused(false);
if (isPastingRef.current) {
isPastingRef.current = false;
return;
}
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
flushDebounce(localContentRef.current);
} else if (localContentRef.current !== latestBlockRef.current.content) {
flushDebounce(localContentRef.current);
}
};
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (!canEdit || block.type === 'code') return;
const pastedText = e.clipboardData.getData('text');
if (!pastedText || !pastedText.includes('\n')) return;
e.preventDefault();
const rawLines = pastedText.split(/\r?\n/);
if (rawLines.length <= 1) return;
const start = e.currentTarget.selectionStart ?? localContent.length;
const end = e.currentTarget.selectionEnd ?? start;
const textBefore = localContent.slice(0, start);
const textAfter = localContent.slice(end);
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
isPastingRef.current = true;
onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines);
};
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) -> If block is completely empty:
// - If level > minLevel, outdent (Shift+Tab behaviour)
// - If already at minLevel and is the last block, do NOT create another empty block below
// - Otherwise split paragraph at cursor and create new block below
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
e.preventDefault();
e.stopPropagation();
setSlashSearch(null);
const minLevel = levelOffset || 0;
if (localContent === '') {
if (Math.max(0, block.level || 0) > minLevel) {
flushDebounce(localContent);
onIndentBlockRef.current(block.id, -1);
return;
}
if (isLast) {
// Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block
return;
}
}
const start = e.currentTarget.selectionStart ?? localContent.length;
const end = e.currentTarget.selectionEnd ?? start;
let beforeText = localContent.slice(0, start);
let afterText = localContent.slice(end);
// Clean linebreaks at the split boundary if present
if (beforeText.endsWith('\r\n')) {
beforeText = beforeText.slice(0, -2);
} else if (beforeText.endsWith('\n')) {
beforeText = beforeText.slice(0, -1);
}
if (afterText.startsWith('\r\n')) {
afterText = afterText.slice(2);
} else if (afterText.startsWith('\n')) {
afterText = afterText.slice(1);
}
setLocalContent(beforeText);
localContentRef.current = beforeText;
flushDebounce(beforeText);
onCreateBlockBelowRef.current(block.id, afterText);
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));
const isEmpty = (!localContent || localContent.trim() === '') && !hasChildren;
// 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 px-1.5 rounded-card transition-all duration-300 ${
isDragTarget
? 'border-t-2 border-accent bg-accent/10'
: isTargeted
? 'bg-accent/15 ring-2 ring-accent'
: activeFocus
? 'bg-panel-elevated'
: 'hover:bg-panel'
}`}
style={{
paddingLeft: `${visualLevel * 22 + 4}px`,
}}
onDragOver={(e) => onDragOverBlock?.(e, index)}
onDrop={() => onDropBlock?.(index)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button, a, input, select')) return;
if (!activeFocus && canEdit && textareaRef.current) {
textareaRef.current.focus();
const len = textareaRef.current.value.length;
textareaRef.current.setSelectionRange(len, len);
}
}}
>
{/* 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: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
{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" />
)}
{/* Bullet handle for dragging and zoom focus */}
<button
type="button"
draggable={canEdit}
onDragStart={() => canEdit && onDragStartBlock?.(index)}
onClick={() => onZoomBlock?.(block.id)}
className={`w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover group/bullet transition-transform ${
canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
}`}
title={
canEdit
? 'Ziehen zum Verschieben • Klick: Ast hineinzoomen (Fokus-Modus)'
: 'Klick: In diesen Ast hineinzoomen (Fokus-Modus)'
}
>
<span
className={`w-1.5 h-1.5 rounded-full transition-all duration-150 ${
block.collapsed && hasChildren
? 'bg-accent ring-2 ring-accent/30'
: activeFocus
? 'bg-accent scale-125'
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
} ${
isEmpty
? 'opacity-0 group-hover:opacity-100 hover:opacity-100'
: 'opacity-100'
}`}
/>
</button>
{/* If To-Do block: checkbox alongside bullet */}
{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>
)}
</div>
{/* Main Block Content */}
<div className="flex-1 min-w-0 pr-1 relative min-h-[1.5rem]">
{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}
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}
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 min-h-[1.5rem]">
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
<div
onClick={() => {
if (!activeFocus && canEdit && textareaRef.current) {
textareaRef.current.focus();
const len = textareaRef.current.value.length;
textareaRef.current.setSelectionRange(len, len);
}
}}
className={`w-full min-h-[1.5rem] leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text'
} ${
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'
}`}
>
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
<span className={activeFocus ? 'invisible' : ''}>
{localContent ? renderInlineMarkdown(localContent) : '\u200b'}
</span>
{/* Inline Badge and 3-Dots directly behind the text */}
{block.type === 'todo' && (
<span className="inline-flex items-center align-middle ml-1.5 gap-1 shrink-0 pointer-events-auto z-10 relative select-none">
{block.taskId && (
<span
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.2 rounded font-medium shrink-0"
title="Mit erweiterter Aufgabe synchronisiert"
>
<CheckSquare className="w-2.5 h-2.5" />
<span>Erweitert</span>
</span>
)}
{canEdit && (
<span className="relative inline-flex items-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowTodoMenu((prev) => !prev);
}}
className="p-0.5 text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="Aufgaben-Optionen"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showTodoMenu && (
<div
className="absolute left-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
onClick={(e) => e.stopPropagation()}
>
{!block.taskId ? (
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
const targetDocId = docId || block.documentId || block.docId || '';
const thought = await api.createEnhancedTask(targetDocId, block.id, {
content: localContent || 'Aufgabe',
checked: Boolean(block.checked),
});
const updatedBlock: DocumentBlock = {
...latestBlockRef.current,
content: localContent,
taskId: thought.id,
type: 'todo',
};
onUpdateBlockRef.current(updatedBlock, true);
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
}
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
>
<CheckSquare className="w-3.5 h-3.5 text-accent" />
<span>In erweiterte Aufgabe umwandeln</span>
</button>
) : (
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
if (block.taskId) {
await api.unlinkEnhancedTask(block.taskId).catch(() => {});
}
const updatedBlock: DocumentBlock = {
...latestBlockRef.current,
content: localContent,
taskId: '',
type: 'todo',
};
onUpdateBlockRef.current(updatedBlock, true);
} catch (err: any) {
alert(err.message || 'Fehler beim Aufheben der Verknüpfung');
}
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-status-warn flex items-center gap-1.5 text-main font-medium cursor-pointer"
>
<X className="w-3.5 h-3.5 text-status-warn" />
<span>Verknüpfung aufheben</span>
</button>
)}
</div>
)}
</span>
)}
</span>
)}
</div>
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
className={`w-full min-h-[1.5rem] resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
} ${
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'
}`}
/>
</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 (Trash2 icon) */}
{canEdit && (
<button
type="button"
onClick={() => {
if (hasChildren) {
if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) {
onDeleteBlockRef.current(block.id, true);
}
} else {
onDeleteBlockRef.current(block.id, false);
}
}}
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 cursor-pointer"
title="Block löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{/* Link Insertion Modal */}
{showLinkModal && (
<LinkModal
isOpen={showLinkModal}
onClose={() => setShowLinkModal(false)}
onInsertLink={handleInsertLink}
/>
)}
</div>
);
};
@@ -0,0 +1,731 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { OutlinerBlock } from './OutlinerBlock';
export interface OutlinerTreeHandle {
focusFirstBlock: () => void;
focusBlock: (blockId: string, cursor?: 'start' | 'end') => void;
}
export interface OutlinerTreeProps {
docId: string;
blocks: DocumentBlock[];
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
onDeleteBlock: (blockId: string) => void;
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
onOpenTask?: (taskId: string) => void;
onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
initialFocusedBlockId?: string | null;
}
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
docId,
blocks,
canEdit,
levelOffset = 0,
onZoomBlock,
onUpdateBlock,
onUpdateBlocks,
onCreateBlock,
onInsertBlocks,
onDeleteBlock,
onMoveBlock,
onOpenTask,
onTagClick,
onWikiLinkClick,
initialFocusedBlockId,
}, ref) => {
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 };
}
},
[]
);
useImperativeHandle(
ref,
() => ({
focusFirstBlock: () => {
if (blocks.length > 0) {
focusBlockInput(blocks[0].id, 'end');
} 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: 'end' };
onCreateBlock(firstBlock);
}
},
focusBlock: (blockId: string, cursor: 'start' | 'end' = 'end') => {
focusBlockInput(blockId, cursor);
},
}),
[blocks, docId, levelOffset, onCreateBlock, focusBlockInput]
);
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, levelOffset]);
const handleCreateBlockBelow = useCallback(
(afterBlockId: string, newContent: 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: BlockType = 'text';
const newBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
documentId: docId,
type: inheritedType,
content: newContent,
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 handlePasteMultiLines = useCallback(
(blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => {
if (!canEdit || rawLines.length === 0) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
const currentBlock = blocks[idx];
const baseLevel = Math.max(0, currentBlock.level || 0);
// Detect indentation style (2-space vs 4-space tabs)
let detectedSpaceStep = 2;
const leadingSpacesList: number[] = [];
for (const line of rawLines) {
if (!line.trim()) continue;
let sp = 0;
while (sp < line.length && line[sp] === ' ') sp++;
if (sp > 0) leadingSpacesList.push(sp);
}
if (leadingSpacesList.length > 0 && leadingSpacesList.every((s) => s % 4 === 0)) {
detectedSpaceStep = 4;
}
// Parse lines into structured blocks
interface ParsedPasteItem {
type: BlockType;
checked?: boolean;
content: string;
indent: number;
}
const parsed: ParsedPasteItem[] = [];
let currentItem: ParsedPasteItem | null = null;
let inCodeBlock = false;
let afterHeading = false;
const pushCurrentItem = () => {
if (currentItem) {
currentItem.content = currentItem.content.trimEnd();
parsed.push(currentItem);
currentItem = null;
}
};
for (let i = 0; i < rawLines.length; i++) {
const rawLine = rawLines[i];
// 1. Inside a fenced code block
if (inCodeBlock) {
if (currentItem) {
currentItem.content += '\n' + rawLine;
}
if (rawLine.trim().startsWith('```')) {
inCodeBlock = false;
}
continue;
}
// 2. Starts a code fence
if (rawLine.trim().startsWith('```')) {
inCodeBlock = true;
if (afterHeading) {
afterHeading = false;
pushCurrentItem();
currentItem = {
type: 'text',
content: rawLine,
indent: 0,
};
} else if (currentItem) {
currentItem.content += (currentItem.content.endsWith('\n') ? '' : '\n') + rawLine;
} else {
currentItem = {
type: 'text',
content: rawLine,
indent: 0,
};
}
continue;
}
// Measure leading indent
let tabs = 0;
let spaces = 0;
let charIdx = 0;
while (charIdx < rawLine.length) {
if (rawLine[charIdx] === '\t') {
tabs++;
charIdx++;
} else if (rawLine[charIdx] === ' ') {
spaces++;
charIdx++;
} else {
break;
}
}
const strippedLine = rawLine.slice(charIdx);
const lineIndent = tabs + Math.floor(spaces / detectedSpaceStep);
// Blank lines
if (strippedLine.trim() === '') {
if (currentItem) {
currentItem.content += '\n';
}
continue;
}
// Headings: #, ##, ###
const h3Match = strippedLine.match(/^###\s+(.*)$/);
const h2Match = strippedLine.match(/^##\s+(.*)$/);
const h1Match = strippedLine.match(/^#\s+(.*)$/);
if (h1Match || h2Match || h3Match) {
pushCurrentItem();
afterHeading = true;
const type: BlockType = h1Match ? 'h1' : h2Match ? 'h2' : 'h3';
const content = (h1Match || h2Match || h3Match)![1];
parsed.push({ type, content, indent: lineIndent });
continue;
}
// Dividers: ---, ***, ___
if (/^(?:---|---|\*\*\*|___)\s*$/.test(strippedLine.trim())) {
pushCurrentItem();
afterHeading = false;
parsed.push({ type: 'divider', content: '---', indent: lineIndent });
continue;
}
// Todo items: - [ ], - [x], * [ ], * [x]
const todoMatch = strippedLine.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/);
if (todoMatch) {
pushCurrentItem();
afterHeading = false;
parsed.push({
type: 'todo',
checked: todoMatch[1].toLowerCase() === 'x',
content: todoMatch[2],
indent: lineIndent,
});
continue;
}
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
const numMatch = strippedLine.match(/^\d+[\.\)]\s+(.*)$/);
if (bulletMatch || numMatch) {
pushCurrentItem();
afterHeading = false;
parsed.push({
type: 'text',
content: (bulletMatch ? bulletMatch[1] : numMatch![1]),
indent: lineIndent,
});
continue;
}
// Regular text line
if (afterHeading) {
// This starts the single bullet point after the heading
afterHeading = false;
pushCurrentItem();
currentItem = {
type: 'text',
content: strippedLine,
indent: lineIndent,
};
} else if (currentItem) {
if (currentItem.content.endsWith('\n')) {
currentItem.content += strippedLine;
} else {
currentItem.content += '\n' + strippedLine;
}
} else {
currentItem = {
type: 'text',
content: strippedLine,
indent: lineIndent,
};
}
}
pushCurrentItem();
if (parsed.length === 0) return;
const line0 = parsed[0];
const baseIndent = line0.indent;
const isSingle = parsed.length === 1;
const line0Content = textBefore + line0.content + (isSingle ? textAfter : '');
const isCurrentEmpty = (currentBlock.content || '').trim() === '';
const adoptedType = line0.type !== 'text' ? line0.type : (isCurrentEmpty ? 'text' : currentBlock.type);
const adoptedChecked = line0.type === 'todo' ? (line0.checked ?? false) : currentBlock.checked;
const updatedCurrentBlock: DocumentBlock = {
...currentBlock,
content: line0Content,
type: adoptedType,
checked: adoptedChecked,
};
const newBlocks: DocumentBlock[] = [];
const nowBase = Date.now().toString(36);
for (let j = 1; j < parsed.length; j++) {
const p = parsed[j];
const isLastLine = j === parsed.length - 1;
const lineContent = isLastLine ? (p.content + textAfter) : p.content;
const lineLevel = Math.max(0, baseLevel + (p.indent - baseIndent));
newBlocks.push({
id: `blk_${nowBase}_${j}_${Math.random().toString(36).slice(2, 6)}`,
documentId: docId,
type: p.type,
checked: p.checked || false,
content: lineContent,
level: lineLevel,
collapsed: false,
position: (currentBlock.position || 1000) + j * 10,
});
}
const lastTargetId = newBlocks.length > 0 ? newBlocks[newBlocks.length - 1].id : currentBlock.id;
const existingEl = textareaMapRef.current.get(lastTargetId);
if (existingEl) {
pendingFocusRef.current = null;
focusBlockInput(lastTargetId, 'end');
} else {
pendingFocusRef.current = { blockId: lastTargetId, cursor: 'end' };
}
setFocusedBlockId(lastTargetId);
if (onInsertBlocks) {
onInsertBlocks(currentBlock.id, updatedCurrentBlock, newBlocks);
} else {
onUpdateBlock(updatedCurrentBlock, true);
for (let k = 0; k < newBlocks.length; k++) {
const afterId = k === 0 ? currentBlock.id : newBlocks[k - 1].id;
onCreateBlock(newBlocks[k], afterId);
}
}
},
[blocks, canEdit, docId, focusBlockInput, onCreateBlock, onInsertBlocks, onUpdateBlock]
);
const handleDeleteBlock = useCallback(
(blockId: string, deleteSubtree = false) => {
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');
}
if (deleteSubtree) {
const rootLevel = blocks[idx].level || 0;
const idsToDelete = [blockId];
for (let j = idx + 1; j < blocks.length; j++) {
if ((blocks[j].level || 0) > rootLevel) {
idsToDelete.push(blocks[j].id);
} else {
break;
}
}
for (const id of idsToDelete) {
onDeleteBlock(id);
}
} else {
onDeleteBlock(blockId);
}
},
[blocks, canEdit, focusBlockInput, levelOffset, 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 minLevel = levelOffset || 0;
const maxLevel = idx === 0 ? minLevel : prevLevel + 1;
const nextLevel =
delta === 1
? Math.min(maxLevel, currentLevel + 1)
: Math.max(minLevel, currentLevel - 1);
if (nextLevel === currentLevel) return;
const actualDelta = nextLevel - currentLevel;
// Find all contiguous children / descendants:
// contiguous blocks with level > currentLevel
let count = 1;
for (let i = idx + 1; i < blocks.length; i++) {
if (Math.max(0, blocks[i].level || 0) > currentLevel) {
count++;
} else {
break;
}
}
const updatedBlocks: DocumentBlock[] = [];
for (let i = idx; i < idx + count; i++) {
const b = blocks[i];
const newLvl = Math.max(minLevel, (b.level || 0) + actualDelta);
updatedBlocks.push({
...b,
level: newLvl,
});
}
if (onUpdateBlocks) {
onUpdateBlocks(updatedBlocks);
} else {
for (const b of updatedBlocks) {
onUpdateBlock(b, true);
}
}
},
[blocks, canEdit, levelOffset, onUpdateBlock, onUpdateBlocks]
);
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();
if (draggedIndex !== null) {
const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0);
let count = 1;
for (let i = draggedIndex + 1; i < blocks.length; i++) {
if (Math.max(0, blocks[i].level || 0) > parentLevel) {
count++;
} else {
break;
}
}
// Do not allow dropping inside its own subtree (self or descendants)
if (index >= draggedIndex && index < draggedIndex + count) {
setDragOverIndex(null);
return;
}
}
setDragOverIndex(index);
},
[blocks, draggedIndex]
);
const handleDropBlock = useCallback(
(targetIndex: number) => {
if (draggedIndex === null || draggedIndex === targetIndex) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
}
const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0);
let count = 1;
for (let i = draggedIndex + 1; i < blocks.length; i++) {
if (Math.max(0, blocks[i].level || 0) > parentLevel) {
count++;
} else {
break;
}
}
if (targetIndex >= draggedIndex && targetIndex < draggedIndex + count) {
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);
}, []);
return (
<div className="space-y-0.5">
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
const isLast = entryIdx === visibleEntries.length - 1;
return (
<OutlinerBlock
key={block.id}
docId={docId}
block={block}
index={index}
isLast={isLast}
maxAllowedLevel={maxAllowedLevel}
hasChildren={hasChildren}
isFocused={focusedBlockId === block.id}
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={onZoomBlock}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines}
onDeleteBlock={handleDeleteBlock}
onIndentBlock={handleIndentBlock}
onNavigateFocus={handleNavigateFocus}
onFocusBlock={setFocusedBlockId}
onOpenTask={onOpenTask}
onTagClick={onTagClick}
onWikiLinkClick={onWikiLinkClick}
onDragStartBlock={handleDragStartBlock}
onDragOverBlock={handleDragOverBlock}
onDropBlock={handleDropBlock}
isDragTarget={dragOverIndex === index && draggedIndex !== index}
isTargeted={highlightedBlockId === block.id}
registerTextareaRef={registerTextareaRef}
/>
);
})}
{canEdit && (
<div
className={`min-h-[14rem] pt-2 pl-2 transition-colors cursor-text ${
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
}`}
onClick={(e) => {
if (e.target !== e.currentTarget && (e.target as HTMLElement).closest('button, a, input, textarea, select')) {
return;
}
if (blocks.length === 0) {
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);
} else {
const lastBlock = blocks[blocks.length - 1];
if (lastBlock.content && lastBlock.content.trim() !== '') {
handleCreateBlockBelow(lastBlock.id, '');
} else {
focusBlockInput(lastBlock.id, 'end');
}
}
}}
onDragOver={(e) => {
e.preventDefault();
if (draggedIndex !== null) {
setDragOverIndex(blocks.length);
}
}}
onDrop={() => handleDropBlock(blocks.length)}
/>
)}
</div>
);
});
File diff suppressed because it is too large. Load diff
+207 -282
View File
@@ -5,7 +5,6 @@ import {
Document, Document,
DocumentBlock, DocumentBlock,
DocShare, DocShare,
DocLockInfo,
KanbanColumn, KanbanColumn,
ListItem, ListItem,
ListShare, ListShare,
@@ -37,6 +36,19 @@ export function getToken(): string | null {
return localStorage.getItem(TOKEN_STORAGE_KEY); return localStorage.getItem(TOKEN_STORAGE_KEY);
} }
export function getCurrentUserId(): string | null {
const token = getToken();
if (!token) return null;
try {
const parts = token.split('.');
if (parts.length >= 2) {
const payload = JSON.parse(atob(parts[1]));
return payload.userId || payload.sub || payload.id || null;
}
} catch {}
return null;
}
export function setToken(token: string): void { export function setToken(token: string): void {
localStorage.setItem(TOKEN_STORAGE_KEY, token); localStorage.setItem(TOKEN_STORAGE_KEY, token);
} }
@@ -117,6 +129,18 @@ export const api = {
return request<AuthStatus>('/api/v1/auth/status'); return request<AuthStatus>('/api/v1/auth/status');
}, },
async logout(): Promise<void> {
try {
await request<{ success: boolean }>('/api/v1/auth/logout', { method: 'POST' });
} finally {
clearToken();
}
},
async revokeAllSessions(): Promise<void> {
await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' });
},
async completeSetup(payload: { async completeSetup(payload: {
email: string; email: string;
password: string; password: string;
@@ -285,6 +309,10 @@ export const api = {
return ensureArray(res); return ensureArray(res);
}, },
async getThought(thoughtId: string): Promise<Thought> {
return request<Thought>(`/api/v1/thoughts/${encodeURIComponent(thoughtId)}`);
},
async triageAction( async triageAction(
thoughtId: string, thoughtId: string,
action: 'keep' | 'discard' | 'restore', action: 'keep' | 'discard' | 'restore',
@@ -402,7 +430,7 @@ export const api = {
async addTaskListShare( async addTaskListShare(
id: string, id: string,
payload: { targetType: 'team' | 'user'; targetId: string } payload: { targetType: 'team' | 'user'; targetId: string; permission?: 'editor' | 'viewer' }
): Promise<TaskListShare> { ): Promise<TaskListShare> {
return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, { return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, {
method: 'POST', method: 'POST',
@@ -602,31 +630,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 }> { async joinSSERoom(sseId: string, room: string, action: 'join' | 'leave' = 'join'): Promise<{ success: boolean }> {
return request<{ success: boolean }>('/api/v1/events/rooms', { return request<{ success: boolean }>('/api/v1/events/rooms', {
method: 'POST', method: 'POST',
@@ -650,6 +653,23 @@ export const api = {
}; };
}, },
async createEnhancedTask(
docId: string,
blockId: string,
payload: { content: string; checked: boolean }
): Promise<Thought> {
return request<Thought>(`/api/v1/documents/${docId}/blocks/${blockId}/enhanced-task`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
async unlinkEnhancedTask(taskId: string): Promise<{ unlinked: boolean }> {
return request<{ unlinked: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/unlink`, {
method: 'POST',
});
},
// --- DOCUMENT SHARING --- // --- DOCUMENT SHARING ---
async getDocShares(docId: string): Promise<DocShare[]> { async getDocShares(docId: string): Promise<DocShare[]> {
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`); const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
@@ -675,44 +695,6 @@ export const api = {
return ensureArray(res); 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 --- // --- SPACES & REALTIME LISTS ---
async getSpaces(): Promise<Space[]> { async getSpaces(): Promise<Space[]> {
const res = await request<Space[]>('/api/v1/spaces'); const res = await request<Space[]>('/api/v1/spaces');
@@ -835,6 +817,17 @@ export const api = {
}); });
}, },
async updateListItem(
listId: string,
itemId: string,
payload: { content?: string; category?: string; quantity?: string }
): Promise<ListItem> {
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
},
async deleteListItem(listId: string, itemId: string): Promise<void> { async deleteListItem(listId: string, itemId: string): Promise<void> {
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' }); await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
}, },
@@ -1140,6 +1133,11 @@ export function onRealtimeStatus(callback: (connected: boolean, transport: Realt
}; };
} }
let isWsBlockedByProxy = typeof window !== 'undefined' && sessionStorage.getItem('mindsift_ws_blocked') === 'true';
let sseReconnectTimer: any = null;
let sseReconnectDelay = 2000;
const globalSubscribedRooms = new Set<string>();
function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTransport) { function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTransport) {
isRealtimeConnectedState = connected; isRealtimeConnectedState = connected;
activeTransport = transport; activeTransport = transport;
@@ -1153,21 +1151,51 @@ function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTrans
window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected, transport } })); window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected, transport } }));
} }
function switchToSSE(reason: string) {
console.warn(`[MindSift Realtime] ${reason} -> Schalte sofort und dauerhaft auf Server-Sent Events (SSE) um.`);
isWsBlockedByProxy = true;
try {
sessionStorage.setItem('mindsift_ws_blocked', 'true');
} catch {}
clearTimeout(wsConnectionWatchdog);
clearTimeout(wsReconnectTimer);
if (activeGlobalWS) {
try {
activeGlobalWS.onclose = null;
activeGlobalWS.onerror = null;
activeGlobalWS.close();
} catch {}
activeGlobalWS = null;
}
connectSSE();
}
export function connectSSE(rooms?: string[]): void { export function connectSSE(rooms?: string[]): void {
if (rooms) {
rooms.forEach((r) => globalSubscribedRooms.add(r));
}
clearTimeout(sseReconnectTimer);
clearTimeout(wsReconnectTimer);
clearTimeout(wsConnectionWatchdog);
if (activeGlobalSSE) { if (activeGlobalSSE) {
try { activeGlobalSSE.close(); } catch {} try { activeGlobalSSE.close(); } catch {}
activeGlobalSSE = null; activeGlobalSSE = null;
} }
try { try {
const url = buildSSEURL(undefined, undefined, rooms); const url = buildSSEURL(undefined, undefined, Array.from(globalSubscribedRooms));
const es = new EventSource(url); const es = new EventSource(url);
activeGlobalSSE = es; activeGlobalSSE = es;
es.onopen = () => { es.onopen = () => {
console.info('[MindSift Realtime] Server-Sent Events (SSE) verbunden (Proxy-Fallback aktiv).'); sseReconnectDelay = 2000;
wsReconnectDelay = 2000;
notifyRealtimeStatusChange(true, 'sse'); notifyRealtimeStatusChange(true, 'sse');
if (activeSSEId && globalSubscribedRooms.size > 0) {
globalSubscribedRooms.forEach((room) => {
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
});
}
}; };
es.onmessage = (event) => { es.onmessage = (event) => {
@@ -1175,6 +1203,11 @@ export function connectSSE(rooms?: string[]): void {
const msg = JSON.parse(event.data) as WSMessage; const msg = JSON.parse(event.data) as WSMessage;
if (msg.event === 'SSE_CONNECTED') { if (msg.event === 'SSE_CONNECTED') {
activeSSEId = msg.payload?.sseId || null; activeSSEId = msg.payload?.sseId || null;
if (activeSSEId && globalSubscribedRooms.size > 0) {
globalSubscribedRooms.forEach((room) => {
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
});
}
} }
wsListeners.forEach((fn) => { wsListeners.forEach((fn) => {
try { try {
@@ -1190,17 +1223,21 @@ export function connectSSE(rooms?: string[]): void {
}; };
es.onerror = (e) => { es.onerror = (e) => {
console.warn('[MindSift Realtime] SSE getrennt oder Fehler aufgetreten:', e); console.warn('[MindSift Realtime] SSE Verbindungsfehler:', e);
if (activeTransport === 'sse') { if (activeTransport === 'sse') {
notifyRealtimeStatusChange(false, 'none'); notifyRealtimeStatusChange(false, 'none');
} }
try { es.close(); } catch {} try { es.close(); } catch {}
activeGlobalSSE = null; activeGlobalSSE = null;
if (!manualDisconnect && getToken()) { if (!manualDisconnect && getToken()) {
clearTimeout(wsReconnectTimer); clearTimeout(sseReconnectTimer);
wsReconnectTimer = setTimeout(() => { const delay = sseReconnectDelay;
connectWebSocket(); sseReconnectDelay = Math.min(30000, Math.round(sseReconnectDelay * 1.5));
}, 5000); sseReconnectTimer = setTimeout(() => {
if (!manualDisconnect && getToken()) {
connectSSE();
}
}, delay);
} }
}; };
} catch (err) { } catch (err) {
@@ -1214,26 +1251,31 @@ export function connectWebSocket(): void {
if (!token) return; if (!token) return;
manualDisconnect = false; 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)) { if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
return; return;
} }
clearTimeout(wsConnectionWatchdog); clearTimeout(wsConnectionWatchdog);
clearTimeout(wsReconnectTimer);
clearTimeout(sseReconnectTimer);
try { try {
const url = buildWebSocketURL(); const url = buildWebSocketURL();
const ws = new WebSocket(url); const ws = new WebSocket(url);
activeGlobalWS = ws; activeGlobalWS = ws;
// 3.5s watchdog for corporate proxies silently stripping Upgrade header // 2.5s watchdog for corporate proxies silently stripping Upgrade header or hanging
wsConnectionWatchdog = setTimeout(() => { wsConnectionWatchdog = setTimeout(() => {
if (ws && ws.readyState !== WebSocket.OPEN) { if (ws && ws.readyState !== WebSocket.OPEN) {
console.warn('[MindSift Realtime] WebSocket connection timeout (Proxy Upgrade Block). Wechsle automatisch zu SSE...'); switchToSSE('WebSocket Timeout nach 2.5s (Proxy Upgrade Header blockiert)');
try { ws.close(); } catch {}
activeGlobalWS = null;
connectSSE();
} }
}, 3500); }, 2500);
ws.onopen = () => { ws.onopen = () => {
clearTimeout(wsConnectionWatchdog); clearTimeout(wsConnectionWatchdog);
@@ -1243,6 +1285,13 @@ export function connectWebSocket(): void {
} }
wsReconnectDelay = 2000; wsReconnectDelay = 2000;
notifyRealtimeStatusChange(true, 'ws'); notifyRealtimeStatusChange(true, 'ws');
if (globalSubscribedRooms.size > 0) {
globalSubscribedRooms.forEach((room) => {
try {
ws.send(JSON.stringify({ event: 'JOIN_ROOM', room }));
} catch {}
});
}
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -1264,12 +1313,10 @@ export function connectWebSocket(): void {
ws.onclose = (evt: CloseEvent) => { ws.onclose = (evt: CloseEvent) => {
clearTimeout(wsConnectionWatchdog); clearTimeout(wsConnectionWatchdog);
activeGlobalWS = null; 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; // In corporate environments, blocked WebSockets close abruptly without clean handshake
if (isProxyIssue && !manualDisconnect && getToken()) { if (!evt.wasClean || evt.code === 1006 || evt.code === 1002 || evt.code === 1008 || evt.code === 1005) {
console.warn('[MindSift Realtime] Proxy/Firewall Inkompatibilität erkannt (Code ' + evt.code + '). Wechsle zu SSE...'); switchToSSE(`WebSocket unsauber beendet (Code ${evt.code}, wasClean=${evt.wasClean})`);
connectSSE();
return; return;
} }
@@ -1282,22 +1329,19 @@ export function connectWebSocket(): void {
const delay = wsReconnectDelay; const delay = wsReconnectDelay;
wsReconnectDelay = Math.min(30000, Math.round(wsReconnectDelay * 1.5)); wsReconnectDelay = Math.min(30000, Math.round(wsReconnectDelay * 1.5));
wsReconnectTimer = setTimeout(() => { wsReconnectTimer = setTimeout(() => {
connectWebSocket(); if (!manualDisconnect && getToken()) {
connectWebSocket();
}
}, delay); }, delay);
} }
}; };
ws.onerror = (e) => { ws.onerror = (e) => {
clearTimeout(wsConnectionWatchdog); clearTimeout(wsConnectionWatchdog);
console.warn('[MindSift Global WS] Error:', e); switchToSSE('WebSocket Fehler (Handshake/Verbindung verweigert)');
try {
ws.close();
} catch {}
}; };
} catch (e) { } catch (e) {
clearTimeout(wsConnectionWatchdog); switchToSSE('WebSocket Exception beim Initialisieren');
console.warn('Failed to connect WebSocket, switching to SSE:', e);
connectSSE();
} }
} }
@@ -1305,12 +1349,20 @@ export function connectWebSocket(): void {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('online', () => { window.addEventListener('online', () => {
if (getToken() && !isRealtimeConnected()) { if (getToken() && !isRealtimeConnected()) {
connectWebSocket(); if (isWsBlockedByProxy) {
connectSSE();
} else {
connectWebSocket();
}
} }
}); });
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && getToken() && !isRealtimeConnected()) { if (document.visibilityState === 'visible' && getToken() && !isRealtimeConnected()) {
connectWebSocket(); if (isWsBlockedByProxy) {
connectSSE();
} else {
connectWebSocket();
}
} }
}); });
} }
@@ -1319,6 +1371,7 @@ export function disconnectWebSocket(): void {
manualDisconnect = true; manualDisconnect = true;
clearTimeout(wsReconnectTimer); clearTimeout(wsReconnectTimer);
clearTimeout(wsConnectionWatchdog); clearTimeout(wsConnectionWatchdog);
clearTimeout(sseReconnectTimer);
if (activeGlobalWS) { if (activeGlobalWS) {
try { try {
activeGlobalWS.close(); activeGlobalWS.close();
@@ -1343,19 +1396,10 @@ export function sendWebSocketMessage(msg: any): void {
// SSE Fallback: dispatch via REST endpoints // SSE Fallback: dispatch via REST endpoints
try { try {
const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg; const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg;
if (parsed.event === 'CRDT_UPDATE' && parsed.docId) { if (parsed.event === 'JOIN_ROOM' && parsed.room) {
api.saveDocumentCRDT(parsed.docId, { update: parsed.payload?.update }); if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'join').catch(() => {});
} else if (parsed.event === 'CRDT_AWARENESS' && parsed.docId) { } else if (parsed.event === 'LEAVE_ROOM' && parsed.room) {
api.sendDocumentAwareness(parsed.docId, parsed.payload); if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'leave').catch(() => {});
} 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);
} }
} catch (err) { } catch (err) {
console.error('Error dispatching fallback REST message for SSE:', err); console.error('Error dispatching fallback REST message for SSE:', err);
@@ -1377,196 +1421,77 @@ export function createRealtimeConnection(options: {
onStatusChange?: (status: { connected: boolean; transport: RealtimeTransport; reason?: string }) => void; onStatusChange?: (status: { connected: boolean; transport: RealtimeTransport; reason?: string }) => void;
}) { }) {
let isClosed = false; 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) => { const joinedRooms: string[] = [];
currentTransport = transport; if (options.docId) joinedRooms.push(`doc:${options.docId}`);
options.onStatusChange?.({ connected, transport, reason }); 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 (isClosed) return;
if (es) { options.onStatusChange?.({
try { es.close(); } catch {} connected,
es = null; transport,
} reason: connected ? undefined : 'Verbindung getrennt',
});
});
try { // Subscribe to messages
const allRooms = [...(options.rooms || [])]; let unsubMsg: (() => void) | null = null;
if (options.docId) allRooms.push(`doc:${options.docId}`); if (options.onMessage) {
if (options.listId) allRooms.push(options.listId); unsubMsg = onWebSocketMessage((msg) => {
const url = buildSSEURL(options.listId, options.docId, allRooms); if (isClosed) return;
es = new EventSource(url); options.onMessage?.(msg);
});
}
es.onopen = () => { // Join rooms on the active connection
if (isClosed) return; const joinAll = () => {
reconnectDelay = 2000; joinedRooms.forEach((r) => {
notify(true, 'sse'); sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
}; if (activeSSEId) {
api.joinSSERoom(activeSSEId, r, 'join').catch(() => {});
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');
}
}; };
const connectWS = () => { if (isRealtimeConnected()) {
joinAll();
}
const unsubRejoin = onRealtimeStatus((connected) => {
if (isClosed) return; if (isClosed) return;
const token = getToken(); if (connected) {
if (!token) { joinAll();
notify(false, 'none', 'Kein Authentifizierungs-Token vorhanden');
return;
} }
});
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 { return {
getTransport: () => currentTransport, getTransport: () => activeTransport,
isConnected: () => currentTransport !== 'none', isConnected: () => isRealtimeConnected(),
send: (msg: any) => { send: (msg: any) => {
const str = typeof msg === 'string' ? msg : JSON.stringify(msg); if (isClosed) return;
if (ws && ws.readyState === WebSocket.OPEN) { sendWebSocketMessage(msg);
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);
}
}, },
close: () => { close: () => {
isClosed = true; isClosed = true;
clearTimeout(watchdogTimer); unsubStatus();
clearTimeout(reconnectTimer); unsubRejoin();
if (ws) { if (unsubMsg) unsubMsg();
try { ws.close(); } catch {} joinedRooms.forEach((r) => {
ws = null; globalSubscribedRooms.delete(r);
} sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
if (es) { if (activeSSEId) {
try { es.close(); } catch {} api.joinSSERoom(activeSSEId, r, 'leave').catch(() => {});
es = null; }
} });
notify(false, 'none');
}, },
}; };
} }
-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);
}
}
+228
View File
@@ -0,0 +1,228 @@
import React from 'react';
/**
* Parses inline markdown:
* - **bold** or __bold__
* - *italic* or _italic_
* - ~~strikethrough~~
* - `code`
* - [label](url)
*/
function renderInlineTokens(text: string): React.ReactNode[] {
if (!text) return [];
const regex = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|~~[^~]+~~|\*[^*]+\*|_[^_]+_|\[([^\]]+)\]\(([^)]+)\))/g;
const elements: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
elements.push(text.slice(lastIndex, match.index));
}
const token = match[0];
const key = `${match.index}-${token}`;
if (token.startsWith('`') && token.endsWith('`')) {
elements.push(
<code key={key} className="px-1.5 py-0.5 rounded bg-panel-elevated text-accent font-mono text-[0.9em] border border-border/50">
{token.slice(1, -1)}
</code>
);
} else if (
(token.startsWith('**') && token.endsWith('**')) ||
(token.startsWith('__') && token.endsWith('__'))
) {
elements.push(
<strong key={key} className="font-semibold text-main">
{token.slice(2, -2)}
</strong>
);
} else if (
(token.startsWith('*') && token.endsWith('*')) ||
(token.startsWith('_') && token.endsWith('_'))
) {
elements.push(
<em key={key} className="italic text-main/90">
{token.slice(1, -1)}
</em>
);
} else if (token.startsWith('~~') && token.endsWith('~~')) {
elements.push(
<del key={key} className="line-through text-muted/80">
{token.slice(2, -2)}
</del>
);
} else if (token.startsWith('[') && match[2] && match[3]) {
const label = match[2];
const url = match[3];
elements.push(
<a
key={key}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline hover:opacity-80 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
{label}
</a>
);
} else {
elements.push(token);
}
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
elements.push(text.slice(lastIndex));
}
return elements;
}
/**
* Parses inline markdown and fenced code blocks:
* - ```lang\ncode\n```
* - **bold** or __bold__
* - *italic* or _italic_
* - ~~strikethrough~~
* - `code`
* - [label](url)
*/
export function renderInlineMarkdown(text: string): React.ReactNode[] {
if (!text) return [];
if (text.includes('```')) {
const codeBlockRegex = /(```[a-zA-Z0-9_-]*\r?\n[\s\S]*?```|```[\s\S]*?```)/g;
const parts = text.split(codeBlockRegex);
const nodes: React.ReactNode[] = [];
parts.forEach((part, partIdx) => {
if (part.startsWith('```') && part.endsWith('```')) {
const lines = part.split(/\r?\n/);
const firstLine = lines[0].slice(3).trim();
const codeLines = lines.slice(1, lines.length - 1);
const code = codeLines.join('\n');
nodes.push(
<div
key={`fenced-${partIdx}`}
className="p-2.5 my-2 rounded bg-code text-accent font-mono text-xs overflow-x-auto border border-border/60 select-text"
>
{firstLine && (
<div className="text-[10px] text-faint uppercase font-sans pb-1 select-none">
{firstLine}
</div>
)}
<pre className="whitespace-pre overflow-x-auto font-mono text-status-ok leading-relaxed">
<code>{code}</code>
</pre>
</div>
);
} else if (part) {
nodes.push(...renderInlineTokens(part));
}
});
return nodes;
}
return renderInlineTokens(text);
}
export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) {
if (!content) return null;
const lines = content.split('\n');
const renderedNodes: React.ReactNode[] = [];
let inCodeBlock = false;
let codeBlockLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith('```')) {
if (inCodeBlock) {
renderedNodes.push(
<pre key={`code-${i}`} className="p-2.5 my-2 rounded bg-panel-elevated text-accent font-mono text-xs overflow-x-auto border border-border/60">
<code>{codeBlockLines.join('\n')}</code>
</pre>
);
codeBlockLines = [];
inCodeBlock = false;
} else {
inCodeBlock = true;
codeBlockLines = [];
}
continue;
}
if (inCodeBlock) {
codeBlockLines.push(line);
continue;
}
const trimmed = line.trim();
if (trimmed.startsWith('### ')) {
renderedNodes.push(
<h3 key={i} className="text-sm font-bold text-main mt-2 mb-1">
{renderInlineMarkdown(trimmed.slice(4))}
</h3>
);
} else if (trimmed.startsWith('## ')) {
renderedNodes.push(
<h2 key={i} className="text-base font-bold text-main mt-3 mb-1">
{renderInlineMarkdown(trimmed.slice(3))}
</h2>
);
} else if (trimmed.startsWith('# ')) {
renderedNodes.push(
<h1 key={i} className="text-lg font-bold text-main mt-3 mb-1.5">
{renderInlineMarkdown(trimmed.slice(2))}
</h1>
);
} else if (trimmed.startsWith('> ')) {
renderedNodes.push(
<blockquote key={i} className="border-l-2 border-accent pl-2.5 my-1 text-muted italic">
{renderInlineMarkdown(trimmed.slice(2))}
</blockquote>
);
} else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
renderedNodes.push(
<div key={i} className="flex items-start gap-1.5 my-0.5">
<span className="text-accent mt-0.5">•</span>
<span className="flex-1 text-main">{renderInlineMarkdown(trimmed.slice(2))}</span>
</div>
);
} else if (/^\d+\.\s/.test(trimmed)) {
const matchNum = trimmed.match(/^(\d+)\.\s(.*)$/);
renderedNodes.push(
<div key={i} className="flex items-start gap-1.5 my-0.5">
<span className="text-muted text-[11px] font-mono mt-0.5">{matchNum ? matchNum[1] + '.' : '•'}</span>
<span className="flex-1 text-main">{renderInlineMarkdown(matchNum ? matchNum[2] : trimmed)}</span>
</div>
);
} else if (trimmed === '') {
renderedNodes.push(<div key={i} className="h-2" />);
} else {
renderedNodes.push(
<div key={i} className="my-0.5 text-main leading-relaxed">
{renderInlineMarkdown(line)}
</div>
);
}
}
if (inCodeBlock && codeBlockLines.length > 0) {
renderedNodes.push(
<pre key="code-end" className="p-2.5 my-2 rounded bg-panel-elevated text-accent font-mono text-xs overflow-x-auto border border-border/60">
<code>{codeBlockLines.join('\n')}</code>
</pre>
);
}
return <div className={`markdown-body text-xs ${className}`}>{renderedNodes}</div>;
}
+11 -9
View File
@@ -53,8 +53,10 @@ export interface Thought {
source: 'manual' | 'voice' | 'webhook'; source: 'manual' | 'voice' | 'webhook';
status: ThoughtStatus; status: ThoughtStatus;
isCompleted: boolean; isCompleted: boolean;
convertedType?: 'task' | 'document' | 'list_item'; convertedType?: 'task' | 'document' | 'list_item' | 'document_block';
convertedRefId?: string; convertedRefId?: string;
docId?: string;
blockId?: string;
dueDate?: string; dueDate?: string;
taskListId?: string; taskListId?: string;
kanbanStatus?: string; kanbanStatus?: string;
@@ -81,6 +83,7 @@ export interface TaskListShare {
targetName?: string; targetName?: string;
email?: string; email?: string;
avatarUrl?: string; avatarUrl?: string;
permission?: 'editor' | 'viewer';
createdAt: string; createdAt: string;
} }
@@ -102,6 +105,7 @@ export interface TaskList {
ownerId?: string; ownerId?: string;
ownerName?: string; ownerName?: string;
isOwner?: boolean; isOwner?: boolean;
permission?: 'owner' | 'editor' | 'viewer';
members?: TaskListMember[]; members?: TaskListMember[];
shares?: TaskListShare[]; shares?: TaskListShare[];
columns?: KanbanColumn[]; columns?: KanbanColumn[];
@@ -153,16 +157,19 @@ export interface SystemSettings {
smtpConfiguredViaEnv?: boolean; 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 { export interface DocumentBlock {
id: string; id: string;
docId?: string; docId?: string;
documentId?: string;
taskId?: string;
type: BlockType; type: BlockType;
content: string; content: string;
checked?: boolean; checked?: boolean;
lang?: string; lang?: string;
level?: number; level?: number;
position?: number;
collapsed?: boolean; collapsed?: boolean;
sortOrder?: number; sortOrder?: number;
updatedAt?: string; updatedAt?: string;
@@ -198,19 +205,14 @@ export interface DocShare {
createdAt: string; createdAt: string;
} }
export interface DocLockInfo {
docId: string;
userId: string;
userName: string;
expiresAt: string;
}
export interface Document { export interface Document {
id: string; id: string;
userId: string; userId: string;
spaceId?: string; spaceId?: string;
title: string; title: string;
icon: string; icon: string;
folderId?: string;
markdownCache?: string;
content: string; content: string;
blocks: DocumentBlock[]; blocks: DocumentBlock[];
tags: string[]; tags: string[];
+258 -242
View File
@@ -3,11 +3,12 @@ package api
import ( import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"log/slog"
"net/http" "net/http"
"net/smtp" "net/smtp"
"strings" "strings"
@@ -31,9 +32,6 @@ type Server struct {
} }
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
if wsHub != nil && store != nil {
wsHub.SetCRDTStore(store)
}
srv := &Server{ srv := &Server{
cfg: cfg, cfg: cfg,
store: store, store: store,
@@ -49,6 +47,7 @@ func (s *Server) Router() http.Handler {
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(s.apiLoggingMiddleware)
r.Use(cors.Handler(cors.Options{ r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"}, AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
@@ -77,6 +76,8 @@ func (s *Server) Router() http.Handler {
r.Use(s.authMiddleware) r.Use(s.authMiddleware)
r.Get("/api/v1/auth/me", s.handleMe) r.Get("/api/v1/auth/me", s.handleMe)
r.Post("/api/v1/auth/logout", s.handleLogout)
r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions)
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings) r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm) r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory) r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
@@ -88,6 +89,7 @@ func (s *Server) Router() http.Handler {
// Instant Capture & Thoughts // Instant Capture & Thoughts
r.Post("/api/v1/inbox", s.handleCaptureInbox) r.Post("/api/v1/inbox", s.handleCaptureInbox)
r.Get("/api/v1/thoughts", s.handleGetThoughts) r.Get("/api/v1/thoughts", s.handleGetThoughts)
r.Get("/api/v1/thoughts/{id}", s.handleGetThought)
r.Patch("/api/v1/thoughts/{id}", s.handleUpdateThought) r.Patch("/api/v1/thoughts/{id}", s.handleUpdateThought)
r.Delete("/api/v1/thoughts/{id}", s.handleDeleteThought) r.Delete("/api/v1/thoughts/{id}", s.handleDeleteThought)
r.Delete("/api/v1/thoughts/discarded/purge", s.handlePurgeDiscarded) r.Delete("/api/v1/thoughts/discarded/purge", s.handlePurgeDiscarded)
@@ -96,6 +98,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/triage/deck", s.handleGetTriageDeck) r.Get("/api/v1/triage/deck", s.handleGetTriageDeck)
r.Patch("/api/v1/triage/{id}", s.handleTriageAction) r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
r.Get("/api/v1/tasks", s.handleGetTasks) r.Get("/api/v1/tasks", s.handleGetTasks)
r.Post("/api/v1/tasks/{id}/unlink", s.handleUnlinkEnhancedTask)
r.Post("/api/v1/tasks/reorder", s.handleReorderTasks) r.Post("/api/v1/tasks/reorder", s.handleReorderTasks)
r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks) r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks)
@@ -135,24 +138,13 @@ func (s *Server) Router() http.Handler {
r.Patch("/api/v1/documents/{id}/blocks", s.handlePatchDocumentBlocks) r.Patch("/api/v1/documents/{id}/blocks", s.handlePatchDocumentBlocks)
r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument) r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument)
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument) r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask)
// Document Sharing & Collaboration // Document Sharing & Collaboration
r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares) r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares)
r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare) r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare)
r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare) 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 // Shared Lists & Realtime Collaboration
r.Get("/api/v1/lists", s.handleGetAllLists) r.Get("/api/v1/lists", s.handleGetAllLists)
r.Post("/api/v1/lists", s.handleCreateList) r.Post("/api/v1/lists", s.handleCreateList)
@@ -163,6 +155,7 @@ func (s *Server) Router() http.Handler {
r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare) r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare)
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem) r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem) r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
r.Put("/api/v1/lists/{id}/items/{itemId}", s.handleUpdateListItem)
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem) r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems) r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems) r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
@@ -251,6 +244,63 @@ func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message}) writeJSON(w, status, map[string]string{"error": message})
} }
// writeStoreError maps read-only task list violations to 403 and everything else to the given fallback status.
func writeStoreError(w http.ResponseWriter, fallbackStatus int, err error) {
if errors.Is(err, storage.ErrTaskListReadOnly) {
writeError(w, http.StatusForbidden, err.Error())
return
}
writeError(w, fallbackStatus, err.Error())
}
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 { func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Check Authorization Bearer Token // 1. Check Authorization Bearer Token
@@ -266,10 +316,14 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
} }
claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr) claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr)
if err == nil && claims != nil { if err == nil && claims != nil {
user, userErr := s.store.GetUserByID(claims.UserID) if !s.store.IsTokenRevoked(tokenStr) {
if userErr == nil && user != nil && user.IsActive { user, userErr := s.store.GetUserByID(claims.UserID)
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID))) if userErr == nil && user != nil && user.IsActive {
return if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
return
}
}
} }
} }
} }
@@ -405,7 +459,7 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
_ = s.store.SeedStarterContent(user) _ = s.store.SeedStarterContent(user)
} }
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return return
@@ -481,7 +535,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
} }
} }
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return return
@@ -519,21 +573,31 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
} }
user, err := s.store.GetUserByLogin(identifier) user, err := s.store.GetUserByLogin(identifier)
if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) { if err != nil {
slog.Warn("Login fehlgeschlagen: Benutzer existiert nicht", "login", identifier, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
return
}
if !auth.CheckPassword(req.Password, user.PasswordHash) {
slog.Warn("Login fehlgeschlagen: Falsches Passwort", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig") writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
return return
} }
if !user.IsActive { if !user.IsActive {
slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert") writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
return return
} }
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil { if err != nil {
slog.Error("Login-Token konnte nicht erstellt werden", "login", identifier, "user_id", user.ID, "error", err)
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return return
} }
slog.Info("Login erfolgreich", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "role", user.Role, "remote_addr", r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]interface{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token, "token": token,
"user": user, "user": user,
@@ -619,7 +683,7 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "") thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "")
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeStoreError(w, http.StatusBadRequest, err)
return return
} }
@@ -650,6 +714,17 @@ func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, thoughts) writeJSON(w, http.StatusOK, thoughts)
} }
func (s *Server) handleGetThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
thought, err := s.store.GetThoughtByID(userID, thoughtID)
if err != nil || thought == nil {
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
return
}
writeJSON(w, http.StatusOK, thought)
}
func (s *Server) handleGetTriageDeck(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetTriageDeck(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
thoughts, err := s.store.GetThoughts(userID, "inbox", 100) thoughts, err := s.store.GetThoughts(userID, "inbox", 100)
@@ -699,7 +774,7 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate, taskListID) updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate, taskListID)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeStoreError(w, http.StatusBadRequest, err)
return return
} }
@@ -740,6 +815,17 @@ func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
return return
} }
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
for _, it := range req.Items {
if th, err := s.store.GetThoughtByID(userID, it.ID); err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
}
if err := s.store.ReorderTasks(taskListID, req.Items); err != nil { if err := s.store.ReorderTasks(taskListID, req.Items); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -775,18 +861,31 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags) updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeStoreError(w, http.StatusBadRequest, err)
return return
} }
s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted)
s.wsHub.BroadcastToAll("TASK_UPDATED", updated) s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated) writeJSON(w, http.StatusOK, updated)
} }
func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
writeJSON(w, http.StatusOK, map[string]bool{"unlinked": true})
}
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id") thoughtID := chi.URLParam(r, "id")
if th, err := s.store.GetThoughtByID(userID, thoughtID); err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
if err := s.store.DeleteThought(userID, thoughtID); err != nil { if err := s.store.DeleteThought(userID, thoughtID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeStoreError(w, http.StatusInternalServerError, err)
return return
} }
s.wsHub.BroadcastToAll("TASK_DELETED", map[string]string{"id": thoughtID}) s.wsHub.BroadcastToAll("TASK_DELETED", map[string]string{"id": thoughtID})
@@ -810,11 +909,18 @@ func (s *Server) broadcastDocToAuthorizedUsers(docID, event string) {
if err != nil { if err != nil {
return return
} }
var firstDoc *domain.Document
for _, uid := range users { for _, uid := range users {
if userDoc, err := s.store.GetDocumentByID(uid, docID); err == nil && userDoc != nil { if userDoc, err := s.store.GetDocumentByID(uid, docID); err == nil && userDoc != nil {
if firstDoc == nil {
firstDoc = userDoc
}
s.wsHub.BroadcastToUser(uid, event, userDoc) s.wsHub.BroadcastToUser(uid, event, userDoc)
} }
} }
if s.wsHub != nil && firstDoc != nil {
s.wsHub.BroadcastRoom("doc:"+docID, event, firstDoc, nil)
}
} }
func (s *Server) handleGetDocuments(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetDocuments(w http.ResponseWriter, r *http.Request) {
@@ -848,7 +954,6 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.wsHub.BroadcastDocEvent(doc.ID, "DOC_CREATED", doc)
s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED") s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED")
writeJSON(w, http.StatusCreated, doc) writeJSON(w, http.StatusCreated, doc)
} }
@@ -868,16 +973,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id") 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 { var req struct {
Title *string `json:"title"` Title *string `json:"title"`
Icon *string `json:"icon"` Icon *string `json:"icon"`
@@ -897,7 +992,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil { 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") s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
} }
writeJSON(w, http.StatusOK, doc) writeJSON(w, http.StatusOK, doc)
@@ -907,16 +1001,6 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id") 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 var req domain.DocBlocksPatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON") writeError(w, http.StatusBadRequest, "Ungültiges JSON")
@@ -950,14 +1034,42 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id") docID := chi.URLParam(r, "id")
authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID)
if err := s.store.DeleteDocument(userID, docID); err != nil { if err := s.store.DeleteDocument(userID, docID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return 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}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
} }
func (s *Server) handleCreateEnhancedTask(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
blockID := chi.URLParam(r, "blockId")
var req struct {
Content string `json:"content"`
Checked bool `json:"checked"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
thought, err := s.store.CreateTaskFromDocBlock(userID, docID, blockID, req.Content, req.Checked)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(userID, "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, thought)
}
// --- WORKSPACE FOLDERS --- // --- WORKSPACE FOLDERS ---
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
@@ -1003,7 +1115,6 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
} }
for _, docID := range deletedDocIDs { for _, docID := range deletedDocIDs {
s.wsHub.ReleaseDocLock(docID, userID, true)
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID}) s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
} }
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath}) s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath})
@@ -1163,6 +1274,11 @@ func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request)
return return
} }
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", share) 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) writeJSON(w, http.StatusCreated, share)
} }
@@ -1170,11 +1286,16 @@ func (s *Server) handleDeleteFolderShare(w http.ResponseWriter, r *http.Request)
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id") folderID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId") shareID := chi.URLParam(r, "shareId")
docIDs, _ := s.store.GetSharedFolderDocIDs(folderID)
if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil { if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "shareId": shareID}) 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}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
} }
@@ -1324,175 +1445,30 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, users) writeJSON(w, http.StatusOK, users)
} }
// --- DOCUMENT LOCKING --- func (s *Server) authenticateUserFromReq(r *http.Request) string {
token := r.URL.Query().Get("token")
func (s *Server) handleGetDocLock(w http.ResponseWriter, r *http.Request) { if token == "" {
docID := chi.URLParam(r, "id") if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
lock := s.wsHub.GetDocLock(docID) token = strings.TrimPrefix(authHdr, "Bearer ")
if lock == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": false})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": true, "lock": lock})
}
func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
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,
})
return
}
s.wsHub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
writeJSON(w, http.StatusOK, map[string]interface{}{
"locked": true,
"acquired": true,
"lock": info,
})
}
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)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges Base64 Format für Update")
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}) if token != "" {
} if strings.HasPrefix(token, "mindsift_sec_") {
if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive {
func (s *Server) handlePostDocAwareness(w http.ResponseWriter, r *http.Request) { return user.ID
userID := auth.GetUserID(r.Context()) }
docID := chi.URLParam(r, "id") }
doc, err := s.store.GetDocumentByID(userID, docID) if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil {
if err != nil { if !s.store.IsTokenRevoked(token) {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden") if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive {
return if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
return claims.UserID
}
}
}
}
} }
_ = doc return ""
var payload json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
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})
} }
// --- REALTIME LISTS & SHARING --- // --- REALTIME LISTS & SHARING ---
@@ -1671,6 +1647,30 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, item) writeJSON(w, http.StatusOK, item)
} }
func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
var req struct {
Content *string `json:"content"`
Category *string `json:"category"`
Quantity *string `json:"quantity"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
item, err := s.store.UpdateListItem(itemID, req.Content, req.Category, req.Quantity)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item)
writeJSON(w, http.StatusOK, item)
}
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id") listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId") itemID := chi.URLParam(r, "itemId")
@@ -1749,17 +1749,9 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
roomID = r.URL.Query().Get("spaceId") roomID = r.URL.Query().Get("spaceId")
} }
token := r.URL.Query().Get("token") userID := s.authenticateUserFromReq(r)
if token == "" { if userID == "" {
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { userID = "anonymous"
token = strings.TrimPrefix(authHdr, "Bearer ")
}
}
userID := "anonymous"
if token != "" {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
userID = claims.UserID
}
} }
s.wsHub.ServeWS(w, r, roomID, userID) s.wsHub.ServeWS(w, r, roomID, userID)
} }
@@ -1770,17 +1762,9 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
roomID = r.URL.Query().Get("spaceId") roomID = r.URL.Query().Get("spaceId")
} }
token := r.URL.Query().Get("token") userID := s.authenticateUserFromReq(r)
if token == "" { if userID == "" {
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { userID = "anonymous"
token = strings.TrimPrefix(authHdr, "Bearer ")
}
}
userID := "anonymous"
if token != "" {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
userID = claims.UserID
}
} }
var initialRooms []string var initialRooms []string
@@ -1996,6 +1980,33 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
// --- USER SETTINGS & ALARMS --- // --- USER SETTINGS & ALARMS ---
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil {
var exp time.Time
if claims.ExpiresAt != nil {
exp = claims.ExpiresAt.Time
} else {
exp = time.Now().Add(30 * 24 * time.Hour)
}
_ = s.store.RevokeToken(tokenStr, userID, exp)
}
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleRevokeAllSessions(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
if err := s.store.RevokeAllUserTokens(userID); err != nil {
writeError(w, http.StatusInternalServerError, "Sitzungen konnten nicht beendet werden")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
var req struct { var req struct {
@@ -2171,7 +2182,7 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
} }
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns) tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeStoreError(w, http.StatusBadRequest, err)
return return
} }
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl) s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl)
@@ -2219,12 +2230,17 @@ func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Reques
var req struct { var req struct {
TargetType string `json:"targetType"` // team | user TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"` TargetID string `json:"targetId"`
Permission string `json:"permission"` // editor | viewer
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON") writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return return
} }
share, err := s.store.AddTaskListShare(listID, req.TargetType, req.TargetID, userID) permission := strings.ToLower(strings.TrimSpace(req.Permission))
if permission != "viewer" {
permission = "editor"
}
share, err := s.store.AddTaskListShare(listID, req.TargetType, req.TargetID, permission, userID)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
+405 -120
View File
@@ -45,7 +45,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
hub := ws.NewHub() hub := ws.NewHub()
srv := NewServer(cfg, store, hub, nil) srv := NewServer(cfg, store, hub, nil)
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName) token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil { if err != nil {
t.Fatalf("failed to generate token: %v", err) t.Fatalf("failed to generate token: %v", err)
} }
@@ -219,7 +219,7 @@ func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
router := srv.Router() router := srv.Router()
// Verify stale token from non-existent user is rejected (401) // Verify stale token from non-existent user is rejected (401)
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old") staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+staleToken) req.Header.Set("Authorization", "Bearer "+staleToken)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -548,11 +548,11 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
t.Fatalf("failed to get normal user: %v", err) t.Fatalf("failed to get normal user: %v", err)
} }
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName) adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion)
if err != nil { if err != nil {
t.Fatalf("failed to generate token: %v", err) t.Fatalf("failed to generate token: %v", err)
} }
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName) userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion)
if err != nil { if err != nil {
t.Fatalf("failed to generate token: %v", err) t.Fatalf("failed to generate token: %v", err)
} }
@@ -663,7 +663,7 @@ func TestWorkspaceTagsAPI(t *testing.T) {
router := srv.Router() router := srv.Router()
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member") user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
// Create document with hashtags // Create document with hashtags
blocks := []domain.DocumentBlock{ blocks := []domain.DocumentBlock{
@@ -841,77 +841,7 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
} }
} }
func TestDocumentCRDTEndpoints(t *testing.T) { func TestSSEGlobalEndpoint(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "crdt_api_test.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
cfg := &config.Config{
JWTSecret: "test-jwt-secret-key",
AllowRegistration: true,
}
hub := ws.NewHub()
srv := NewServer(cfg, store, hub, nil)
router := srv.Router()
user, err := store.CreateUser("crdt_api@example.com", "hash", "CRDT API 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)
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)
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())
}
// 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)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from PUT crdt, got %d: %s", rec.Code, rec.Body.String())
}
// 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)
}
var crdtResp struct {
DocID string `json:"docId"`
State string `json:"state"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &crdtResp)
if crdtResp.State != "AQIDBA==" {
t.Fatalf("expected state 'AQIDBA==', got '%s'", crdtResp.State)
}
}
func TestSSEAndAwarenessEndpoints(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test_sse.db") dbPath := filepath.Join(t.TempDir(), "test_sse.db")
store, err := storage.NewStore(dbPath) store, err := storage.NewStore(dbPath)
if err != nil { if err != nil {
@@ -920,7 +850,6 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
defer store.Close() defer store.Close()
hub := ws.NewHub() hub := ws.NewHub()
hub.SetCRDTStore(store)
cfg := &config.Config{ cfg := &config.Config{
JWTSecret: "test-jwt-secret-key", JWTSecret: "test-jwt-secret-key",
AllowRegistration: false, AllowRegistration: false,
@@ -928,7 +857,6 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
srv := NewServer(cfg, store, hub, nil) srv := NewServer(cfg, store, hub, nil)
router := srv.Router() router := srv.Router()
// 1. Setup user
setupBody := []byte(`{ setupBody := []byte(`{
"email": "sse_user@example.com", "email": "sse_user@example.com",
"password": "secretpassword", "password": "secretpassword",
@@ -951,51 +879,13 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
_ = json.Unmarshal(rec.Body.Bytes(), &authResp) _ = json.Unmarshal(rec.Body.Bytes(), &authResp)
token := authResp.Token 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()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() 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() rec = httptest.NewRecorder()
go func() { go func() {
// Cancel context after short moment to terminate stream cleanly
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
cancel() cancel()
}() }()
@@ -1006,12 +896,407 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
if !strings.Contains(contentType, "text/event-stream") { if !strings.Contains(contentType, "text/event-stream") {
t.Fatalf("expected Content-Type text/event-stream, got %s", contentType) 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() bodyStr := rec.Body.String()
if !strings.Contains(bodyStr, "SSE_CONNECTED") { if !strings.Contains(bodyStr, "SSE_CONNECTED") {
t.Fatalf("expected SSE_CONNECTED event in body, got: %s", bodyStr) 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, userB.TokenVersion)
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 (read-only recipient) cannot move documents within the shared folder:
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.StatusBadRequest {
t.Fatalf("Expected User B move doc in read-only shared folder to fail, got %d", rec.Code)
}
// User A (the owner) moves the document into a subfolder: "TeamProjekt/Archiv"
moveDocPayload = map[string]string{
"folderPath": "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 "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User A move doc into subfolder failed: %s", rec.Body.String())
}
// User B must now see the document synchronized at "MeineOrdner/TeamProjekt/Archiv"!
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 docBSynced domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docBSynced)
if docBSynced.FolderPath != "MeineOrdner/TeamProjekt/Archiv" {
t.Fatalf("expected User B to see subfolder sync at 'MeineOrdner/TeamProjekt/Archiv', got %q", docBSynced.FolderPath)
}
}
func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
// 1. Create a fresh user with known password
password := "initialPassword123"
hash, _ := auth.HashPassword(password)
user, err := store.CreateUser("revocation_test@example.com", hash, "Revocation Tester", "member")
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// 2. Perform login to get initial JWT
loginBody, _ := json.Marshal(map[string]string{
"login": user.Email,
"password": password,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login failed: %s", rec.Body.String())
}
var loginResp struct {
Token string `json:"token"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp)
token1 := loginResp.Token
if token1 == "" {
t.Fatalf("expected token in login response")
}
// 3. Verify token1 works
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token1)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("token1 should be valid, got %d", rec.Code)
}
// 4. Test explicit logout (blacklists token1)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
req.Header.Set("Authorization", "Bearer "+token1)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("logout failed: %s", rec.Body.String())
}
// Token1 must now be blacklisted and rejected (401)
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token1)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for blacklisted token1, got %d", rec.Code)
}
// Also check authenticateUserFromReq rejects token1
req = httptest.NewRequest(http.MethodGet, "/api/v1/ws?token="+token1, nil)
if uid := srv.authenticateUserFromReq(req); uid != "" {
t.Fatalf("authenticateUserFromReq should reject blacklisted token1, got uid %q", uid)
}
// 5. Login again to get token2
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
var loginResp2 struct {
Token string `json:"token"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp2)
token2 := loginResp2.Token
// Token2 works
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token2)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("token2 should be valid, got %d", rec.Code)
}
// 6. Change password via UpdateUserPassword -> increments token_version
newPassword := "newSecretPassword456"
newHash, _ := auth.HashPassword(newPassword)
if err := store.UpdateUserPassword(user.ID, newHash); err != nil {
t.Fatalf("failed to update password: %v", err)
}
// Token2 must now be REJECTED (401) because its token_version is outdated!
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token2)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for token2 after password change, got %d", rec.Code)
}
// 7. Login with new password to get token3
loginNewBody, _ := json.Marshal(map[string]string{
"login": user.Email,
"password": newPassword,
})
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginNewBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
var loginResp3 struct {
Token string `json:"token"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp3)
token3 := loginResp3.Token
// Token3 works
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token3)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("token3 should be valid, got %d", rec.Code)
}
// 8. Test revoke-all endpoint -> increments token_version and invalidates all existing sessions
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/revoke-all", nil)
req.Header.Set("Authorization", "Bearer "+token3)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("revoke-all failed: %s", rec.Body.String())
}
// Token3 must now be REJECTED (401)
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token3)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for token3 after revoke-all, got %d", rec.Code)
}
}
func TestDocumentSharingStrictlyReadOnly(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
userA, _ := store.CreateUser("user_a_ro@example.com", "hash", "User A", "member")
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion)
userB, _ := store.CreateUser("user_b_ro@example.com", "hash", "User B", "member")
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
// Create document owned by User A
blocks := []domain.DocumentBlock{
{ID: "b1", Type: "bullet", Content: "Row 1", Level: 0},
{ID: "b2", Type: "bullet", Content: "Row 2", Level: 0},
}
doc, err := store.CreateDocument(userA.ID, "", "Read Only Test Doc", "📝", "", blocks, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
// Share with User B (forced read-only)
_, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "read")
if err != nil {
t.Fatalf("CreateDocShare failed: %v", err)
}
// 1. User B can read document
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil)
req.Header.Set("Authorization", "Bearer "+tokenB)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User B should be able to read doc, got status: %d", rec.Code)
}
// 2. User B tries to update document via PUT -> must fail (not owner)
newTitle := "Hacked Title"
updateBody, _ := json.Marshal(map[string]interface{}{"title": newTitle})
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(updateBody))
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("expected error for User B updating document, got 200 OK")
}
// 3. User B tries to patch document blocks -> must fail (not owner)
patchBody, _ := json.Marshal(domain.DocBlocksPatchRequest{
Upserted: []domain.DocumentBlock{{ID: "b2", Content: "Hacked by User B"}},
})
req = httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+doc.ID+"/blocks", bytes.NewReader(patchBody))
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("expected error for User B patching document blocks, got 200 OK")
}
// 4. User A (owner) can successfully update document
ownerTitle := "Owner Updated Title"
ownerBody, _ := json.Marshal(map[string]interface{}{"title": ownerTitle})
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(ownerBody))
req.Header.Set("Authorization", "Bearer "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("owner should be able to update document, got %d", rec.Code)
}
}
+20 -9
View File
@@ -18,9 +18,10 @@ type contextKey string
const UserIDContextKey contextKey = "mindsift_user_id" const UserIDContextKey contextKey = "mindsift_user_id"
type Claims struct { type Claims struct {
UserID string `json:"userId"` UserID string `json:"userId"`
Email string `json:"email"` Email string `json:"email"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
TokenVersion int `json:"tokenVersion"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@@ -34,14 +35,24 @@ func CheckPassword(password, hash string) bool {
return err == nil return err == nil
} }
func GenerateToken(secret, userID, email, displayName string) (string, error) { func GenerateToken(secret, userID, email, displayName string, tokenVersion int) (string, error) {
if tokenVersion <= 0 {
tokenVersion = 1
}
b := make([]byte, 16)
_, _ = rand.Read(b)
jti := hex.EncodeToString(b)
now := time.Now()
claims := &Claims{ claims := &Claims{
UserID: userID, UserID: userID,
Email: email, Email: email,
DisplayName: displayName, DisplayName: displayName,
TokenVersion: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)), ID: jti,
IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: "mindsift", Issuer: "mindsift",
}, },
} }
+25 -1
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -14,6 +15,7 @@ type Config struct {
BaseURL string BaseURL string
AllowRegistration bool AllowRegistration bool
LogLevel string LogLevel string
Debug bool
DemoMode bool DemoMode bool
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings) // 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") jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")
baseURL := getEnv("BASE_URL", "http://localhost:"+port) baseURL := getEnv("BASE_URL", "http://localhost:"+port)
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true" 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")))) demoVal := strings.ToLower(getEnv("MINDA_DEMO", getEnv("MINDSIFT_DEMO", getEnv("DEMO_MODE", "false"))))
demoMode := demoVal == "true" || demoVal == "1" || demoVal == "yes" || demoVal == "on" demoMode := demoVal == "true" || demoVal == "1" || demoVal == "yes" || demoVal == "on"
@@ -60,6 +69,7 @@ func Load() *Config {
BaseURL: baseURL, BaseURL: baseURL,
AllowRegistration: allowReg, AllowRegistration: allowReg,
LogLevel: logLevel, LogLevel: logLevel,
Debug: debug,
DemoMode: demoMode, DemoMode: demoMode,
SMTPHost: smtpHost, SMTPHost: smtpHost,
SMTPPort: smtpPort, 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 { func getEnv(key, fallback string) string {
if val, ok := os.LookupEnv(key); ok && strings.TrimSpace(val) != "" { if val, ok := os.LookupEnv(key); ok && strings.TrimSpace(val) != "" {
return 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")
}
+63 -28
View File
@@ -101,6 +101,7 @@ type User struct {
AvatarURL string `json:"avatarUrl,omitempty"` AvatarURL string `json:"avatarUrl,omitempty"`
Role string `json:"role"` // admin | member Role string `json:"role"` // admin | member
IsActive bool `json:"isActive"` IsActive bool `json:"isActive"`
TokenVersion int `json:"tokenVersion"`
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
AlarmEmail string `json:"alarmEmail"` AlarmEmail string `json:"alarmEmail"`
DiscordWebhookURL string `json:"discordWebhookUrl"` DiscordWebhookURL string `json:"discordWebhookUrl"`
@@ -147,8 +148,10 @@ type Thought struct {
Source string `json:"source"` // manual | voice | webhook Source string `json:"source"` // manual | voice | webhook
Status ThoughtStatus `json:"status"` Status ThoughtStatus `json:"status"`
IsCompleted bool `json:"isCompleted"` IsCompleted bool `json:"isCompleted"`
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item | document_block
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
DocID string `json:"docId,omitempty"`
BlockID string `json:"blockId,omitempty"`
DueDate string `json:"dueDate,omitempty"` DueDate string `json:"dueDate,omitempty"`
TaskListID string `json:"taskListId,omitempty"` TaskListID string `json:"taskListId,omitempty"`
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
@@ -181,6 +184,7 @@ type TaskListShare struct {
TargetName string `json:"targetName,omitempty"` TargetName string `json:"targetName,omitempty"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"` AvatarURL string `json:"avatarUrl,omitempty"`
Permission string `json:"permission"` // editor | viewer
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
} }
@@ -208,6 +212,7 @@ type TaskList struct {
TeamName string `json:"teamName,omitempty"` TeamName string `json:"teamName,omitempty"`
OwnerName string `json:"ownerName,omitempty"` OwnerName string `json:"ownerName,omitempty"`
IsOwner bool `json:"isOwner"` IsOwner bool `json:"isOwner"`
Permission string `json:"permission,omitempty"` // owner | editor | viewer
Members []TaskListMember `json:"members,omitempty"` Members []TaskListMember `json:"members,omitempty"`
Shares []TaskListShare `json:"shares,omitempty"` Shares []TaskListShare `json:"shares,omitempty"`
Columns []KanbanColumn `json:"columns,omitempty"` Columns []KanbanColumn `json:"columns,omitempty"`
@@ -260,13 +265,48 @@ type SystemSettings struct {
} }
type DocumentBlock struct { type DocumentBlock struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress DocumentID string `json:"documentId,omitempty"`
Content string `json:"content"` Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
Checked bool `json:"checked,omitempty"` Content string `json:"content"`
Lang string `json:"lang,omitempty"` Checked bool `json:"checked,omitempty"`
Level int `json:"level,omitempty"` Lang string `json:"lang,omitempty"`
Collapsed bool `json:"collapsed,omitempty"` Level int `json:"level"`
Position float64 `json:"position"`
Collapsed bool `json:"collapsed,omitempty"`
TaskID string `json:"taskId,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 { type DocBlocksPatchRequest struct {
@@ -302,27 +342,22 @@ type DocShare struct {
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
} }
type DocLockInfo struct {
DocID string `json:"docId"`
UserID string `json:"userId"`
UserName string `json:"userName"`
ExpiresAt string `json:"expiresAt"`
}
type Document struct { type Document struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"userId"` UserID string `json:"userId"`
SpaceID string `json:"spaceId,omitempty"` SpaceID string `json:"spaceId,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon"` Icon string `json:"icon"`
Content string `json:"content"` Content string `json:"content"`
Blocks []DocumentBlock `json:"blocks"` MarkdownCache string `json:"markdownCache,omitempty"`
Tags []string `json:"tags"` Blocks []DocumentBlock `json:"blocks"`
FolderPath string `json:"folderPath,omitempty"` Tags []string `json:"tags"`
IsPinned bool `json:"isPinned"` FolderID string `json:"folderId,omitempty"`
IsArchived bool `json:"isArchived"` FolderPath string `json:"folderPath,omitempty"`
CreatedAt string `json:"createdAt"` IsPinned bool `json:"isPinned"`
UpdatedAt string `json:"updatedAt"` IsArchived bool `json:"isArchived"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
// Collaboration & Permissions // Collaboration & Permissions
Permission string `json:"permission"` // owner | write | read Permission string `json:"permission"` // owner | write | read
+624 -228
View File
File diff suppressed because it is too large. Load diff
+92 -57
View File
@@ -1,6 +1,7 @@
package storage package storage
import ( import (
"errors"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -118,7 +119,7 @@ func TestStoreLifecycle(t *testing.T) {
} }
// Test TaskList Sharing with Team // Test TaskList Sharing with Team
shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, user.ID) shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, "editor", user.ID)
if err != nil || shareTeam.ID == "" { if err != nil || shareTeam.ID == "" {
t.Fatalf("AddTaskListShare team failed: %v", err) t.Fatalf("AddTaskListShare team failed: %v", err)
} }
@@ -147,6 +148,45 @@ func TestStoreLifecycle(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("CreateUser collab failed: %v", err) t.Fatalf("CreateUser collab failed: %v", err)
} }
// Test read-only TaskList share
ownerTask, err := store.CreateThoughtWithDetails(user.ID, "Rasen mähen", "normal", "manual", "", taskList.ID, "todo", "")
if err != nil {
t.Fatalf("CreateThoughtWithDetails in own list failed: %v", err)
}
if _, err := store.UpdateThoughtTriage(user.ID, ownerTask.ID, "keep", "task", "", "", taskList.ID); err != nil {
t.Fatalf("UpdateThoughtTriage in own list failed: %v", err)
}
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "viewer", user.ID); err != nil {
t.Fatalf("AddTaskListShare viewer failed: %v", err)
}
if perm := store.GetTaskListPermission(user2.ID, taskList.ID); perm != "viewer" {
t.Fatalf("expected viewer permission, got %q", perm)
}
viewerTasks, err := store.GetTasks(user2.ID, taskList.ID)
if err != nil || len(viewerTasks) == 0 {
t.Fatalf("viewer should see tasks of shared list: %v", err)
}
done := true
if _, err := store.UpdateThoughtDetails(user2.ID, ownerTask.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
t.Fatalf("viewer must not update tasks, got %v", err)
}
if _, err := store.CreateThoughtWithDetails(user2.ID, "Hack", "normal", "manual", "", taskList.ID, "todo", ""); !errors.Is(err, ErrTaskListReadOnly) {
t.Fatalf("viewer must not create tasks, got %v", err)
}
if err := store.DeleteThought(user2.ID, ownerTask.ID); !errors.Is(err, ErrTaskListReadOnly) {
t.Fatalf("viewer must not delete tasks, got %v", err)
}
newTitle := "Umbenannt"
if _, err := store.UpdateTaskList(user2.ID, taskList.ID, &newTitle, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
t.Fatalf("viewer must not update task list, got %v", err)
}
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "editor", user.ID); err != nil {
t.Fatalf("upgrading share to editor failed: %v", err)
}
if !store.CanEditTaskList(user2.ID, taskList.ID) {
t.Fatalf("editor share should allow editing")
}
newDoc, err := store.CreateDocument(user.ID, "", "Test Note", "📄", "", []domain.DocumentBlock{ newDoc, err := store.CreateDocument(user.ID, "", "Test Note", "📄", "", []domain.DocumentBlock{
{ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0}, {ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0},
}, []string{"test"}, false) }, []string{"test"}, false)
@@ -173,16 +213,10 @@ func TestStoreLifecycle(t *testing.T) {
t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded") t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded")
} }
// Upgrade to write permission // user2 is a read-only collaborator -> editing must fail
_, err = store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "write")
if err != nil {
t.Fatalf("Upgrade CreateDocShare failed: %v", err)
}
// user2 edits writeable doc -> must succeed
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil) _, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
if err != nil { if err == nil {
t.Fatalf("UpdateDocument failed for write collaborator: %v", err) t.Fatalf("expected UpdateDocument to fail for read-only collaborator, but succeeded")
} }
// Remove share // Remove share
@@ -551,8 +585,7 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
t.Fatalf("MoveDocument by userB failed: %v", err) t.Fatalf("MoveDocument by userB failed: %v", err)
} }
// User B edits the document via PatchDocumentBlocks (modifying b2 and adding b3) // User B is a read-only collaborator -> PatchDocumentBlocks must fail
// Simultaneously, User A edits b1 and adds b4 using a slightly stale order
_, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{ _, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{
Upserted: []domain.DocumentBlock{ Upserted: []domain.DocumentBlock{
{ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0}, {ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0},
@@ -560,8 +593,8 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
}, },
BlockOrder: []string{"b1", "b2", "b3"}, BlockOrder: []string{"b1", "b2", "b3"},
}) })
if err != nil { if err == nil {
t.Fatalf("PatchDocumentBlocks by userB failed: %v", err) t.Fatalf("expected PatchDocumentBlocks by read-only userB to fail, but succeeded")
} }
// User A's client only knew about b1, b2 when sending its patch (adds b4) // User A's client only knew about b1, b2 when sending its patch (adds b4)
@@ -576,9 +609,9 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
t.Fatalf("PatchDocumentBlocks by userA failed: %v", err) t.Fatalf("PatchDocumentBlocks by userA failed: %v", err)
} }
// Verify NO blocks were lost! All 4 blocks (b1, b2, b3, b4) must exist with both users' edits preserved // Verify User A's blocks are preserved: b1, b2, b4 (3 blocks)
if len(patchedDocA.Blocks) != 4 { if len(patchedDocA.Blocks) != 3 {
t.Fatalf("expected 4 blocks after concurrent patches, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks) t.Fatalf("expected 3 blocks after patch, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks)
} }
blockContent := make(map[string]string) blockContent := make(map[string]string)
for _, b := range patchedDocA.Blocks { for _, b := range patchedDocA.Blocks {
@@ -587,12 +620,6 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
if blockContent["b1"] != "Zeile 1 von A bearbeitet" { if blockContent["b1"] != "Zeile 1 von A bearbeitet" {
t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"]) t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"])
} }
if blockContent["b2"] != "Zeile 2 von B bearbeitet" {
t.Fatalf("expected b2 updated by B to be preserved, got %q", blockContent["b2"])
}
if blockContent["b3"] != "Zeile 3 neu von B" {
t.Fatalf("expected b3 added by B to be preserved even with stale order from A, got %q", blockContent["b3"])
}
if blockContent["b4"] != "Zeile 4 neu von A" { if blockContent["b4"] != "Zeile 4 neu von A" {
t.Fatalf("expected b4 added by A, got %q", blockContent["b4"]) t.Fatalf("expected b4 added by A, got %q", blockContent["b4"])
} }
@@ -683,9 +710,9 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
} }
} }
func TestDocumentCRDTState(t *testing.T) { func TestFlushDocumentStateAndSchema(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "crdt_test.db") dbPath := filepath.Join(tmpDir, "tabula_rasa_test.db")
store, err := NewStore(dbPath) store, err := NewStore(dbPath)
if err != nil { if err != nil {
@@ -693,52 +720,60 @@ func TestDocumentCRDTState(t *testing.T) {
} }
defer store.Close() 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 { if err != nil {
t.Fatalf("CreateUser failed: %v", err) 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 { if err != nil {
t.Fatalf("CreateDocument failed: %v", err) t.Fatalf("CreateDocument failed: %v", err)
} }
// 1. Initial CRDT state is empty blocks := []domain.DocumentBlock{
state, updates, err := store.GetDocumentCRDTState(doc.ID) {
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 { if err != nil {
t.Fatalf("GetDocumentCRDTState failed: %v", err) t.Fatalf("GetDocumentByID failed: %v", err)
} }
if len(state) != 0 || len(updates) != 0 { if updatedDoc.Title != "Architektur Notiz" {
t.Fatalf("expected empty CRDT state, got state=%d updates=%d", len(state), len(updates)) 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 tags, err := store.GetWorkspaceTags(user.ID)
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)
if err != nil { if err != nil {
t.Fatalf("GetDocumentCRDTState after compact failed: %v", err) t.Fatalf("GetWorkspaceTags failed: %v", err)
} }
if len(stateAfter) != 3 || len(updatesAfterCompact) != 0 { if len(tags) != 3 {
t.Fatalf("expected compacted state of length 3 and 0 updates, got state=%d updates=%d", len(stateAfter), len(updatesAfterCompact)) t.Fatalf("expected 3 extracted tags in block_tags (#backend, #go, #sqlite), got %d: %+v", len(tags), tags)
} }
} }
+121 -341
View File
@@ -1,10 +1,9 @@
package ws package ws
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log/slog"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
@@ -29,6 +28,29 @@ type Client struct {
userID string userID string
send chan []byte send chan []byte
subscribedRooms map[string]bool subscribedRooms map[string]bool
sendMu sync.RWMutex
closed bool
}
func (c *Client) closeSend() {
c.sendMu.Lock()
defer c.sendMu.Unlock()
if !c.closed {
c.closed = true
close(c.send)
}
}
func (c *Client) trySend(data []byte) {
c.sendMu.RLock()
defer c.sendMu.RUnlock()
if c.closed {
return
}
select {
case c.send <- data:
default:
}
} }
type SSEClient struct { type SSEClient struct {
@@ -37,20 +59,29 @@ type SSEClient struct {
roomID string roomID string
send chan []byte send chan []byte
subscribedRooms map[string]bool subscribedRooms map[string]bool
sendMu sync.RWMutex
closed bool
} }
type docLockItem struct { func (c *SSEClient) closeSend() {
docID string c.sendMu.Lock()
userID string defer c.sendMu.Unlock()
userName string if !c.closed {
lockedAt time.Time c.closed = true
expiresAt time.Time close(c.send)
}
} }
type CRDTStore interface { func (c *SSEClient) trySend(data []byte) {
GetDocumentCRDTState(docID string) ([]byte, [][]byte, error) c.sendMu.RLock()
AppendDocumentCRDTUpdate(docID string, update []byte, userID string) error defer c.sendMu.RUnlock()
SaveDocumentCRDTState(docID string, stateBlob []byte, userID string) error if c.closed {
return
}
select {
case c.send <- data:
default:
}
} }
type Hub struct { type Hub struct {
@@ -61,28 +92,18 @@ type Hub struct {
sseClients map[*SSEClient]bool sseClients map[*SSEClient]bool
sseUserRooms map[string]map[*SSEClient]bool sseUserRooms map[string]map[*SSEClient]bool
sseRooms map[string]map[*SSEClient]bool sseRooms map[string]map[*SSEClient]bool
locks map[string]*docLockItem closed bool
crdtStore CRDTStore
} }
func NewHub() *Hub { func NewHub() *Hub {
h := &Hub{ return &Hub{
rooms: make(map[string]map[*Client]bool), rooms: make(map[string]map[*Client]bool),
userRooms: make(map[string]map[*Client]bool), userRooms: make(map[string]map[*Client]bool),
allClients: make(map[*Client]bool), allClients: make(map[*Client]bool),
sseClients: make(map[*SSEClient]bool), sseClients: make(map[*SSEClient]bool),
sseUserRooms: make(map[string]map[*SSEClient]bool), sseUserRooms: make(map[string]map[*SSEClient]bool),
sseRooms: make(map[string]map[*SSEClient]bool), sseRooms: make(map[string]map[*SSEClient]bool),
locks: make(map[string]*docLockItem),
} }
go h.cleanupLocksLoop()
return h
}
func (h *Hub) SetCRDTStore(store CRDTStore) {
h.mu.Lock()
defer h.mu.Unlock()
h.crdtStore = store
} }
func (h *Hub) Register(c *Client) { func (h *Hub) Register(c *Client) {
@@ -122,7 +143,6 @@ func (h *Hub) Unregister(c *Client) {
} }
} }
var docRoomsLeft []string
if c.subscribedRooms != nil { if c.subscribedRooms != nil {
for rID := range c.subscribedRooms { for rID := range c.subscribedRooms {
if room, ok := h.rooms[rID]; ok { if room, ok := h.rooms[rID]; ok {
@@ -131,52 +151,19 @@ func (h *Hub) Unregister(c *Client) {
delete(h.rooms, rID) delete(h.rooms, rID)
} }
} }
if strings.HasPrefix(rID, "doc:") {
docRoomsLeft = append(docRoomsLeft, strings.TrimPrefix(rID, "doc:"))
}
} }
} }
userRemaining := 0
if c.userID != "" && c.userID != "anonymous" { if c.userID != "" && c.userID != "anonymous" {
if uRoom, ok := h.userRooms[c.userID]; ok { if uRoom, ok := h.userRooms[c.userID]; ok {
delete(uRoom, c) delete(uRoom, c)
userRemaining += len(uRoom)
if len(uRoom) == 0 { if len(uRoom) == 0 {
delete(h.userRooms, c.userID) delete(h.userRooms, c.userID)
} }
} }
if sseRoom, ok := h.sseUserRooms[c.userID]; ok {
userRemaining += len(sseRoom)
}
}
close(c.send)
// If this user has no other active client connections, release their document locks
var releasedDocIDs []string
if userRemaining == 0 && c.userID != "" {
now := time.Now()
for docID, l := range h.locks {
if l.userID == c.userID || l.expiresAt.Before(now) {
releasedDocIDs = append(releasedDocIDs, docID)
delete(h.locks, docID)
}
}
} }
c.closeSend()
h.mu.Unlock() h.mu.Unlock()
// Broadcast unlock events for released locks
for _, docID := range releasedDocIDs {
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
}
// Broadcast awareness leave for doc rooms
for _, docID := range docRoomsLeft {
h.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
"docId": docID,
"userId": c.userID,
}, nil)
}
} }
func (h *Hub) RegisterSSE(c *SSEClient) { func (h *Hub) RegisterSSE(c *SSEClient) {
@@ -223,7 +210,6 @@ func (h *Hub) UnregisterSSE(c *SSEClient) {
} }
} }
} }
var docRoomsLeft []string
if c.subscribedRooms != nil { if c.subscribedRooms != nil {
for rID := range c.subscribedRooms { for rID := range c.subscribedRooms {
if room, ok := h.sseRooms[rID]; ok { if room, ok := h.sseRooms[rID]; ok {
@@ -232,48 +218,18 @@ func (h *Hub) UnregisterSSE(c *SSEClient) {
delete(h.sseRooms, rID) delete(h.sseRooms, rID)
} }
} }
if strings.HasPrefix(rID, "doc:") {
docRoomsLeft = append(docRoomsLeft, strings.TrimPrefix(rID, "doc:"))
}
} }
} }
userRemaining := 0
if c.userID != "" && c.userID != "anonymous" { if c.userID != "" && c.userID != "anonymous" {
if uRoom, ok := h.sseUserRooms[c.userID]; ok { if uRoom, ok := h.sseUserRooms[c.userID]; ok {
delete(uRoom, c) delete(uRoom, c)
userRemaining += len(uRoom)
if len(uRoom) == 0 { if len(uRoom) == 0 {
delete(h.sseUserRooms, c.userID) delete(h.sseUserRooms, c.userID)
} }
} }
if wsRoom, ok := h.userRooms[c.userID]; ok {
userRemaining += len(wsRoom)
}
}
close(c.send)
var releasedDocIDs []string
if userRemaining == 0 && c.userID != "" {
now := time.Now()
for docID, l := range h.locks {
if l.userID == c.userID || l.expiresAt.Before(now) {
releasedDocIDs = append(releasedDocIDs, docID)
delete(h.locks, docID)
}
}
} }
c.closeSend()
h.mu.Unlock() h.mu.Unlock()
for _, docID := range releasedDocIDs {
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
}
for _, docID := range docRoomsLeft {
h.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
"docId": docID,
"userId": c.userID,
}, nil)
}
} }
func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool { func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
@@ -315,120 +271,35 @@ func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool {
return false return false
} }
// Document Locking Methods
func (h *Hub) AcquireDocLock(docID, userID, userName string, ttl time.Duration) (*domain.DocLockInfo, bool, *domain.DocLockInfo) {
// Close gracefully disconnects all WebSocket and SSE connections so HTTP shutdown completes immediately.
func (h *Hub) Close() {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() if h.closed {
now := time.Now()
existing, exists := h.locks[docID]
if exists && existing.expiresAt.After(now) {
if existing.userID != userID {
// Locked by another user
info := &domain.DocLockInfo{
DocID: existing.docID,
UserID: existing.userID,
UserName: existing.userName,
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
}
return nil, false, info
}
// Same user extending lock
existing.expiresAt = now.Add(ttl)
if userName != "" {
existing.userName = userName
}
info := &domain.DocLockInfo{
DocID: existing.docID,
UserID: existing.userID,
UserName: existing.userName,
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
}
return info, true, nil
}
// Create fresh lock
item := &docLockItem{
docID: docID,
userID: userID,
userName: userName,
lockedAt: now,
expiresAt: now.Add(ttl),
}
h.locks[docID] = item
info := &domain.DocLockInfo{
DocID: item.docID,
UserID: item.userID,
UserName: item.userName,
ExpiresAt: item.expiresAt.Format(time.RFC3339),
}
return info, true, nil
}
func (h *Hub) HeartbeatDocLock(docID, userID string, ttl time.Duration) bool {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
item, ok := h.locks[docID]
if !ok || item.userID != userID || item.expiresAt.Before(now) {
return false
}
item.expiresAt = now.Add(ttl)
return true
}
func (h *Hub) ReleaseDocLock(docID, userID string, isAdmin bool) bool {
h.mu.Lock()
defer h.mu.Unlock()
item, ok := h.locks[docID]
if !ok {
return false
}
if item.userID == userID || isAdmin || userID == "admin" {
delete(h.locks, docID)
return true
}
return false
}
func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo {
h.mu.RLock()
defer h.mu.RUnlock()
item, ok := h.locks[docID]
if !ok || item.expiresAt.Before(time.Now()) {
return nil
}
return &domain.DocLockInfo{
DocID: item.docID,
UserID: item.userID,
UserName: item.userName,
ExpiresAt: item.expiresAt.Format(time.RFC3339),
}
}
func (h *Hub) cleanupLocksLoop() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
h.mu.Lock()
now := time.Now()
var expiredDocIDs []string
for docID, l := range h.locks {
if l.expiresAt.Before(now) {
expiredDocIDs = append(expiredDocIDs, docID)
delete(h.locks, docID)
}
}
h.mu.Unlock() h.mu.Unlock()
return
}
h.closed = true
for _, docID := range expiredDocIDs { allWsClients := make([]*Client, 0, len(h.allClients))
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) for c := range h.allClients {
} allWsClients = append(allWsClients, c)
}
allSSEClients := make([]*SSEClient, 0, len(h.sseClients))
for c := range h.sseClients {
allSSEClients = append(allSSEClients, c)
}
h.mu.Unlock()
for _, c := range allWsClients {
_ = c.conn.Close()
c.closeSend()
}
for _, c := range allSSEClients {
c.closeSend()
} }
} }
@@ -446,19 +317,17 @@ func (h *Hub) BroadcastToAll(event string, payload interface{}) {
} }
h.mu.RLock() h.mu.RLock()
if h.closed {
h.mu.RUnlock()
return
}
defer h.mu.RUnlock() defer h.mu.RUnlock()
for client := range h.allClients { for client := range h.allClients {
select { client.trySend(data)
case client.send <- data:
default:
}
} }
for sseClient := range h.sseClients { for sseClient := range h.sseClients {
select { sseClient.trySend(data)
case sseClient.send <- data:
default:
}
} }
} }
@@ -474,22 +343,20 @@ func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
} }
h.mu.RLock() h.mu.RLock()
if h.closed {
h.mu.RUnlock()
return
}
defer h.mu.RUnlock() defer h.mu.RUnlock()
if room, ok := h.userRooms[userID]; ok { if room, ok := h.userRooms[userID]; ok {
for client := range room { for client := range room {
select { client.trySend(data)
case client.send <- data:
default:
}
} }
} }
if sseRoom, ok := h.sseUserRooms[userID]; ok { if sseRoom, ok := h.sseUserRooms[userID]; ok {
for sseClient := range sseRoom { for sseClient := range sseRoom {
select { sseClient.trySend(data)
case sseClient.send <- data:
default:
}
} }
} }
} }
@@ -499,7 +366,11 @@ func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) {
} }
func (h *Hub) BroadcastDocEvent(docID, 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{}) { func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
@@ -515,6 +386,10 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
} }
h.mu.RLock() h.mu.RLock()
if h.closed {
h.mu.RUnlock()
return
}
defer h.mu.RUnlock() defer h.mu.RUnlock()
targetRoom := listID targetRoom := listID
@@ -524,19 +399,12 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
if room, ok := h.rooms[targetRoom]; ok { if room, ok := h.rooms[targetRoom]; ok {
for client := range room { for client := range room {
select { client.trySend(data)
case client.send <- data:
default:
// Slow client buffer full
}
} }
} }
if sseRoom, ok := h.sseRooms[targetRoom]; ok { if sseRoom, ok := h.sseRooms[targetRoom]; ok {
for sseClient := range sseRoom { for sseClient := range sseRoom {
select { sseClient.trySend(data)
case sseClient.send <- data:
default:
}
} }
} }
} }
@@ -584,6 +452,10 @@ func (h *Hub) BroadcastRoom(roomID, event string, payload interface{}, excludeCl
} }
h.mu.RLock() h.mu.RLock()
if h.closed {
h.mu.RUnlock()
return
}
defer h.mu.RUnlock() defer h.mu.RUnlock()
if room, ok := h.rooms[roomID]; ok { if room, ok := h.rooms[roomID]; ok {
@@ -591,18 +463,12 @@ func (h *Hub) BroadcastRoom(roomID, event string, payload interface{}, excludeCl
if excludeClient != nil && client == excludeClient { if excludeClient != nil && client == excludeClient {
continue continue
} }
select { client.trySend(data)
case client.send <- data:
default:
}
} }
} }
if sseRoom, ok := h.sseRooms[roomID]; ok { if sseRoom, ok := h.sseRooms[roomID]; ok {
for sseClient := range sseRoom { for sseClient := range sseRoom {
select { sseClient.trySend(data)
case sseClient.send <- data:
default:
}
} }
} }
} }
@@ -615,17 +481,14 @@ func (c *Client) SendEvent(event string, payload interface{}) {
} }
data, err := json.Marshal(msg) data, err := json.Marshal(msg)
if err == nil { if err == nil {
select { c.trySend(data)
case c.send <- data:
default:
}
} }
} }
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID string) { func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID string) {
conn, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != 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 return
} }
@@ -686,39 +549,6 @@ func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request, roomID, userID st
fmt.Fprintf(w, "data: %s\n\n", connectedMsg) fmt.Fprintf(w, "data: %s\n\n", connectedMsg)
flusher.Flush() 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) ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop() defer ticker.Stop()
@@ -761,10 +591,11 @@ func (c *Client) readPump() {
break break
} }
// Handle client-initiated lock, CRDT, and presence messages // Handle client-initiated lock, CRDT, room subscription, and presence messages
var incoming struct { var incoming struct {
Event string `json:"event"` Event string `json:"event"`
DocID string `json:"docId"` DocID string `json:"docId"`
Room string `json:"room"`
Payload json.RawMessage `json:"payload"` Payload json.RawMessage `json:"payload"`
} }
if json.Unmarshal(message, &incoming) == nil && c.userID != "" && c.userID != "anonymous" { if json.Unmarshal(message, &incoming) == nil && c.userID != "" && c.userID != "anonymous" {
@@ -777,73 +608,22 @@ func (c *Client) readPump() {
docID = p.DocID docID = p.DocID
} }
if docID != "" { // Room subscription without requiring docID
switch incoming.Event { if incoming.Event == "JOIN_ROOM" {
case "LOCK_DOC": room := incoming.Room
if info, ok, _ := c.hub.AcquireDocLock(docID, c.userID, "", 30*time.Second); ok && info != nil { if room == "" {
c.hub.BroadcastDocEvent(docID, "DOC_LOCKED", info) room = docID
} }
case "HEARTBEAT_DOC": if room != "" {
c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second) c.hub.JoinRoom(c, room)
case "UNLOCK_DOC": }
if c.hub.ReleaseDocLock(docID, c.userID, false) { } else if incoming.Event == "LEAVE_ROOM" {
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) room := incoming.Room
} if room == "" {
case "CRDT_JOIN": room = docID
c.hub.JoinRoom(c, "doc:"+docID) }
if c.hub.crdtStore != nil { if room != "" {
stateBlob, updates, err := c.hub.crdtStore.GetDocumentCRDTState(docID) c.hub.LeaveRoom(c, room)
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)
} }
} }
} }
+58
View File
@@ -0,0 +1,58 @@
package ws
import (
"sync"
"testing"
"time"
)
func TestHubCloseAndGracefulShutdown(t *testing.T) {
hub := NewHub()
// Register multiple dummy SSE clients
var sseClients []*SSEClient
for i := 0; i < 5; i++ {
client := &SSEClient{
id: "sse-test",
userID: "user-1",
send: make(chan []byte, 10),
}
hub.RegisterSSE(client)
sseClients = append(sseClients, client)
}
// Verify broadcast works
hub.BroadcastToAll("TEST_EVENT", map[string]string{"msg": "hello"})
// Concurrently invoke Close() and Broadcast() to verify no panics or deadlocks
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
hub.BroadcastToAll("SPAM", map[string]int{"i": i})
time.Sleep(1 * time.Millisecond)
}
}()
go func() {
defer wg.Done()
time.Sleep(5 * time.Millisecond)
hub.Close()
}()
wg.Wait()
// Verify that all SSE client channels are closed
for _, c := range sseClients {
select {
case _, ok := <-c.send:
if ok {
// Drained a message, check next
_, ok = <-c.send
}
default:
}
}
}